← Commit history
Cargo.lock+1−1
@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"  [[package]] name = "adom-aiflow-reverse"-version = "0.4.0"+version = "0.5.0" dependencies = [  "clap",  "image",
Cargo.toml+2−2
@@ -1,9 +1,9 @@-# adom-aiflow-reverse 0.4: reverse engineer a PCB from photos. The program measures (board outline,+# adom-aiflow-reverse 0.2: reverse engineer a PCB from photos. The program measures (board outline, # rectification, holes, part bodies, copper islands, vias, connectivity), generates the KiCad copy with the # photographed pours and tracks, and routes the rest on its own N-layer router; the AI names. [package] name = "adom-aiflow-reverse"-version = "0.4.0"+version = "0.5.0" edition = "2021" license = "MIT" 
README.md+173−13
@@ -3,12 +3,60 @@ ::adom-video{src="/adom/ai-flow-reverse-engineering/render/videos/driver-re3-full-flow.video.json"}  [Download the 1080p video](/download/adom/ai-flow-reverse-engineering/0.3.0/driver-re3-full-flow-1080p.mp4), which shows the photos better than the player copy, or the [full run](/download/adom/ai-flow-reverse-engineering/0.3.0/driver-re3-full-run.tar.gz) with every board, audit and recorded view.+Photos of a PCB in, a KiCad copy out: the parts identified, the functional blocks named, every island of copper traced from the photos, the connections nobody can see listed and left unrouted with their evidence, the layer count stated from what is known, two confidence scores, and the tests that would settle what is still unknown. -Photos of a PCB in, a KiCad copy out: the parts identified, the functional blocks named, every island of copper read from the photos, each piece of copper given a net with a record of how, the connections nobody can see routed, a layer count proposed from how hard that was, a measured confidence score, and the tests that would settle what is still unknown.+It is a standalone Rust program. It measures from the photos, generates the board and checks. Routing the hidden connections is an optional later stage. The AI running it supplies what takes judgement: what a part is, what a block does, what a net is called. Every claim carries an evidence tag: `SEEN`, `MEASURED`, `DATASHEET`, `INFERRED` or `UNKNOWN`. -It is a standalone Rust program. It measures from the photos, generates the board, routes and checks. The AI running it supplies what takes judgement: what a part is, what a block does, what a net is called. Every claim carries an evidence tag: `SEEN`, `MEASURED`, `DATASHEET`, `INFERRED` or `UNKNOWN`.+**Status: version 0.5.0, tested on thirteen boards, not a finished result.** The flow now runs from a folder of photos to its last file without stopping for a person. It places parts well. It does not yet recover a board's connections: no test board came out as a copy you could build from, and every run says so in its own report. The section below says what to expect. [docs/status.md](docs/status.md) lists every known gap. -**Status: under test on two boards, not a finished result.** On both boards the review loop measures an overall confidence of 0 against a target of 90, and it reports STALLED. Version 0.4.0 fixes what independent audits found on the second board, a Raspberry Pi Compute Module 5. The Driver board figures below are from 0.3.0: the Driver has not been run to completion or audited again on 0.4.0. [docs/status.md](docs/status.md) lists every known gap.+## Version 0.5.0: what to expect from a run++Eleven boards were run from photos alone, from a 2-layer launchpad to a 6-layer control board, on green, red and blue solder mask. Seven of them were then graded against their real designs (KiCad files, one Eagle board through KiCad's import, one placement list). The designs were never an input to a run.++**What every run delivers.** A KiCad board and project, a KiCad schematic with real symbols and datasheet pin names, a picture of the copper coloured by how well each net is proven, a picture per net, a list of bench tests, a report with a measured confidence line, a bench plan (an optional set of probes you can take and feed back), and a two minute narrated video made from the run's own files.++<img src="screenshots/example-net-confidence.jpg" width="900" alt="Net confidence picture of a TI MSP-EXP430F5529LP run: top copper in blue where a net was seen joined, bottom face marked not read">++*The net confidence picture from the TI MSP-EXP430F5529LP run. Blue copper is joined to its net by copper that was seen. The reader merged much of the top face into one area, and the bottom face is marked as not read: nothing is claimed there.* [Download this run's video](/download/adom/ai-flow-reverse-engineering/0.5.0/example-run-msp-exp430f5529lp.mp4).++**Strong points**++- **Where the parts are.** On whole-board photo sets the placed parts sit 0.08 to 0.44 mm (median) from their real positions, and 78 to 99 percent of the placed parts are real parts. On six of the seven graded boards 66 to 96 percent of the real parts were found; the seventh, photographed in part and on one face, reached 35 percent.+- **The outline and the scale.** The board size came out within 0.2 to 0.8 mm on the four green boards photographed whole and graded. On the red board it came out 2.5 to 2.8 mm too large.+- **Vias, on green boards with open vias.** On the USB hub board 90 percent of the real vias were found and 99 percent of the kept ones are real.+- **It says what it does not know.** Every claim carries a tag (`SEEN`, `MEASURED`, `DATASHEET`, `INFERRED`, `UNKNOWN`), a face that could not be read is marked not read, and the confidence score is the lowest category, measured by auditors who did not make the run. Planted false claims void an audit sample when an auditor accepts one.+- **It finishes.** `auto` runs every step a program can run and stops only where judgement is needed, with the exact instruction.++**Weak points**++- **The connections.** Of the pin pairs a run puts on one net, 23 to 39 percent are right, and it finds 0.2 to 22 percent of the real ones. Independent auditors judged 8 to 31 percent of sampled connection claims right. The copper reader merges neighbouring copper into large areas on photos stitched from close-ups, so nets taken from copper are few and often wrong.+- **Nets from the pinouts help, and are new.** Building nets from what each chip is, which way round it sits and its datasheet pinout, with copper only confirming, raised the USB hub board to 87 percent of stated pin pairs right (7 percent of the real ones found). This is in 0.5.0 and needs the chips identified and their pin tables entered.+- **The part finder needs help.** On most boards the AI running the flow read the parts from the photos and entered them; the program's own finder proposes few whole parts.+- **The schematic is a parts sheet.** Symbols and pin names are right, but where nets are weak the pins end in labels marked `?` and passives stand unconnected.+- **Vias under solder mask, and on red or blue boards, are not read.** Seven of the eleven runs kept no vias at all.+- **The layer count is never stated.** A run gives a minimum from what the photos show. No minimum was above the real count, and on six of the seven boards whose stackup is known the real count is higher (table below).+- **The copper audit cannot be judged on stitched photos.** Five audit samples were voided by planted claims, and careful auditors answered "cannot tell" on most points.+- **Faces are laid on each other by the outline alone on three boards and not at all on one**, so a top to bottom connection there is not trustworthy.++**The eleven boards.** Unpublished designs are described, not named.++| Board | Real layers | Layers the run stated | Parts found / placed parts real | Pin pairs right / found | Vias found |+|---|---|---|---|---|---|+| 1 Control board, 160 x 45 mm | 4 | at least 2 | 66% / 89% | 23% / 22% | 0 of 134 |+| 2 Battery monitor evaluation board | not known | at least 4, inferred | not graded | | |+| 3 Raspberry Pi 5 | not published | at least 4, inferred | not graded | | |+| 4 MOSFET driver board | 2 | at least 2 | 95% / 87% | 24% / 13% | 33% |+| 5 Automotive Ethernet switch | 4 | at least 2 | 96% / 96% | 30% / 4% | 0 of 314 |+| 6 Sequent Microsystems 16 inputs HAT | not published | at least 2 | not graded | | |+| 7 TI MSP-EXP430F5529LP, red | 2 | at least 1 | 70% / 78% | 39% / 3% | 0 of 444 |+| 8 Earth People Technology MAX V board, blue | not published | at least 1 | not graded | | |+| 9 Battery charger board, two partial photos | 4 | at least 2 | 67% / 84% (placement list only) | | |+| 10 USB 2.0 hub board | 4 | at least 2 | 75% / 99% | 38% / 0.2% (87% / 7% with nets from the pinouts) | 90% |+| 11 Control board, one face, partial photos | 6 | at least 1 | 35% / 67% | 23% / 1% | 0 of 640 |++Every run's own confidence line read between 0.0 and 4.1 of 100 against a target of 90. That is the honest figure: a copy is as trustworthy as its weakest category, and connectivity or copper is near zero on every board.++**Use it today for** a placed, scaled KiCad starting point with the parts identified, a clear picture of what the photos do and do not show, and a list of the measurements that would settle the rest. **Do not use it yet for** a netlist or a board you intend to build without checking every connection.  ## Version 0.4.0: a second board, and what the audits found @@ -132,31 +180,102 @@ adom-wiki pkg install adom/ai-flow-reverse-engineering   # or ./build.sh from th adom-wiki pkg install adom/service-kicad adom/adom-2dboard adom/adom-schematic  mkdir my-board && cd my-board-adom-aiflow-reverse init --top a.jpg b.jpg --bottom c.jpg --rules kicad-default --ai-thread me+adom-aiflow-reverse init --photos ~/photos/my-board/ --rules kicad-default --ai-thread me   # every photo of the board, unsorted adom-aiflow-reverse serve --port 8891 &   # the flow window: open this in a browser and leave it open-adom-aiflow-reverse next                  # always says what to do now+adom-aiflow-reverse next                  # always says what to do now: the exact command, or the input it needs from you+adom-aiflow-reverse auto --ai-thread me   # runs every program-only step, stops when it needs you or the run is complete+adom-aiflow-reverse video build           # the two minute video of the run; `auto` builds it after the report ``` -`next` walks the run. `steps` shows every stage and whether it is done, stale or next. An edit to an earlier artifact makes the later stages stale, and the flow window draws the loop back.+`init --photos` takes a folder or a list of files and needs no face labels: close-up tiles, whole-board overviews and both faces can come in any order. The first `prep` sorts them and stitches one image per face (see "Photo intake" below). `init --top a.jpg --bottom b.jpg` still starts a run from one finished photo per face.++`next` walks the run and names the one next action. `steps` shows every stage and whether it is done, stale or next. An edit to an earlier artifact makes the later stages stale, and the flow window draws the loop back.++## Running without a human: `auto`++`auto` runs every program-only step in order and ends with one line:++| Line | Exit | Meaning |+|---|---|---|+| `AUTO NEEDS AI: <input>` | 2 | The AI's judgement is needed. The hints name the exact file or command: the scale, parts.json, the datasheet pins of each identified chip, blocks.json, what a failed check lists, the audit verdicts, report.md |+| `AUTO COMPLETE: every step is done; video: <file>` | 0 | Every step is done and the run video is built. When the video could not be built the line says `the video was NOT built: <reason>` and the exit is still 0: the video never holds a run up |+| `AUTO FAILED: ...` | 1 | A command failed, or the flow is not moving |++A script alternates `auto` with the AI's inputs until it says COMPLETE. The last result is also in `reverse-flow/auto.json`, and every command's full output in `reverse-flow/auto-log.txt`.  ## The stages  | Stage | What happens | Who decides | |---|---|---|+| stitch (inside intake) | Sorts an unlabelled set of photos into faces, close-up tiles and overviews, picks the top, and builds one mosaic per face at the close-ups' resolution, with a report of how well the tiles agree and what was not covered | program (Python with OpenCV, called by the binary) | | intake | Finds the outline, straightens each photo, measures sharpness, plated holes and hole rows | program | | scale | Pixels per mm from standard hole pitches, with one part body from a datasheet to pick the pitch standard; aligns bottom to top on shared holes | program, AI names the body | | parts | Finds bodies, matches packages, then snaps each footprint's pads onto the metal in the photo | program measures, AI reads markings | | blocks | Proposes groups of parts that sit together | AI names them |-| copper | Reads every island of copper on both faces into pours and tracks, keeps vias by dome shading, and reads silkscreen, mask openings, exposed metal and holes into their own layers | program |+| copper | Traces every island of copper on both faces into pours, tracks and vias | program | | mimic | Writes the KiCad project with everything visible | program | | snapshot | Saves the copy as it stands, before anything is inferred | program |-| connect | Gives every piece of copper a net with a record of how, then routes what is hidden, jumping the layer count until it closes and the DRC passes; proposes the stackup | program, AI adds datasheet nets |-| review | Scores every category 0 to 100 from measurements and random audits, and loops until each reaches 90 or the loop stalls with a stated reason | program scores, an independent auditor judges the crops |+| connect | Lists the connections nobody can see and leaves them unrouted, each with its evidence tag; states the layer count from outside evidence, or UNKNOWN with its test and the minimum the photos give. `connect route` is optional | program, AI adds datasheet nets and any outside evidence |+| review | Scores every category 0 to 100 from measurements and random audits, and loops until the photo score reaches 90 or the loop stalls with a stated reason. The bench score is reported beside it | program scores, an independent auditor judges the crops | | tests | One test for every `INFERRED` or `UNKNOWN` item | program lists, AI writes the method | | report | The whole result with its evidence | program |  Each stage records its interactive PCB or schematic in the flow window, numbered and never overwritten, so the window replays the whole process afterwards. +## Photo intake: any set of photos, no sorting by hand++A board usually arrives as several close-ups per face plus, often, one phone photo of each whole face. `init --photos <folder or files>` keeps them as they came (`reverse-flow/photos-raw/`), and `stitch`, which `prep` runs by itself on such a run, does the rest:++1. **Sorts.** Every photo is matched against every other, with its EXIF rotation applied. Photos that match show the same face. Within a face, the photos with the finest pixels are close-up tiles and a photo more than 1.5 times coarser is an overview. Two overviews that do not match each other are two faces, so a row of holes that looks the same from both sides cannot join them.+2. **Picks the top**: the face with the larger share of its board area under part bodies (areas that are neither the mask colour nor white print). `stitch.json` gives both shares and says when they are close; then look at both mosaics and, if the other face is the top, run `stitch --top <name of a photo that shows the top>`.+3. **Registers the tiles** to each other at full resolution on the board surface. The solder mask colour is measured in each photo, so green, red and blue boards all work. One similarity per tile is solved for all tiles together, with one small camera correction per face (a radial term and a tilt) when it makes the tiles agree better. The overview only says where to look and steadies a tile whose matches sit in one corner.+4. **Builds the mosaic** at the close-ups' own pixel size: each tile pixel is resampled once, nothing is sharpened, and the only tone change is a smooth gain per tile. Joins are moved by a graph cut to where two tiles look the same, which keeps them off parts. Where no tile covers the board the overview fills in and the area is recorded as low resolution; where nothing covers it the mosaic is black and recorded as not seen. A few millimetres of table stay round the board for `prep`.+5. **Reports** in `stitch.json`: each photo's role, each tile's registration, how far the tiles disagree in their overlaps (median and 95th percentile, in pixels, on cells that were held out of the fit), coverage by source, and the evidence pictures: `stitch/<face>-sources.jpg`, `stitch/<face>-seams.jpg` with full-size crops of the joins, and `stitch/<face>-overlap.jpg`.++The mosaics become the run's photos (`photos/top.jpg`, `photos/bottom.jpg`), so `prep`, `scale` and every later step run on them unchanged.++The registration is Python with OpenCV, not Rust: `tools/stitch.py`, compiled into the binary, written to `reverse-flow/stitch/stitch.py` and run with `python3` (set `ADOM_REVERSE_PYTHON` to choose another interpreter; it needs numpy and an OpenCV build with SIFT). The command line, the script's hash and its exit status go into the run ledger as a `stitch` event, and its output into `stitch/stitch.log`.++Thin sets are taken as they are. One close-up of a face is used as that face, with a note of every side on which the board runs out of the frame. Photos that match nothing are listed under `unplaced` and left out. Nothing is filled in that no photo shows. Two groups of photos that share nothing are taken as the two faces, and the report says that this is an assumption; with more groups than that, only the largest is built.++Red and blue boards stitch like green ones. `prep` itself looks for a green board; when it finds none it uses the board box recorded in `stitch.json` and says so. `copper trace` reads copper through a red or a blue mask (docs/method.md, "Whole faces of the red and the blue board"); tented vias are not read, and `prep` and `detect` still use green thresholds for holes and bodies. When no hole shows on both faces, give `scale --flip x|y`.++## Two scores++One overall figure was always 0, because connectivity, the layer count and the values of unmarked parts cannot be earned from photos. There are now two, both computed from measurements and independent audits, with no formula changed:++| Score | What it covers | Target |+|---|---|---|+| Photo score | What photos can show: part identity and placement, values of marked parts, blocks, visible copper, vias, the joins a crop can show, the drawn layers against the photos | 90, and the review loop works towards it |+| Bench score | What needs the real board or outside documents: pins and connections no photo shows joined, the net of copper that reaches no pin, the layer count, values of unmarked parts | Reported beside the photo score with what would raise it; nothing waits for it |++The lower of the two is the lowest category, which is what the single figure was. [docs/method.md](docs/method.md), "Two scores", gives the membership of every ceiling.++## The bench passback (optional)++After a run is complete, `bench plan` writes ordered instructions for a person with the real board and a multimeter: a continuity probe for every hidden connection, most settled first, each with a picture showing where both pads are, then the unmarked parts to measure and the layer count. `bench ingest --file <results.json>` records the answers as `MEASURED`, and the bench score moves by computation. [docs/bench.md](docs/bench.md) gives the results file format and a worked example.++## The run video++Every run ends with a video of about two minutes (110 to 130 seconds), made by the program from the run folder alone. `auto` builds it after `report`; `video build` builds it by hand, on a finished run or on one still in progress (it then says which steps are not done).++```+reverse-flow/video/<project>-run.mp4          1920x1080, 30 fps, H.264 with AAC+reverse-flow/video/<project>-run.vtt, .srt    captions, never burned in+reverse-flow/video/<project>-run-poster.jpg   the title card+reverse-flow/video/video.json                 every scene: its times, the narration, the files it showed, and each figure with the file it was read from+reverse-flow/video/stills/                    the middle frame of every scene, and KiCad's own picture of the copy+```++The sequence is fixed and works on any board: the photos and how they were joined, the outline and scale, the parts (a box on each, the main chips close up), the functional blocks, the copper and vias over the photo with a zoom into the densest area, the KiCad copy wiped across the photo with its layer score, the nets by confidence and one or two nets lit, the schematic, both scores with what holds them down, and the tests and what is still unknown. A scene whose input is missing is left out, `video.json` says why, and the others are held longer so the total stays near two minutes.++- **Every figure is read at build time**, from the run's files or computed by the program from them (both scores, the review's verdict, the hidden connections). Nothing is typed. If the review did not reach its target, the video says so.+- **The narration is filled from templates.** The AI running the flow may add `reverse-flow/video-notes.json`: `{"board": "one line naming the board", "chips": {"U1": "one line about this chip"}}`. Those lines are spoken as written and `video.json` marks them as coming from that file. The house voice takes about five seconds for a fifteen word sentence, so the lines that must be said are short and the others are added by importance while the video stays under 125 seconds; `video.json` lists what was left out.+- **Rendering needs no browser:** Python with numpy, Pillow and OpenCV draws the frames and pipes them to ffmpeg. The tool's source is in `tools/video/` and is compiled into the binary. The voice is `adom-tts say` (en-US-AndrewNeural, default rate), one take per line; when it does not answer the video is built with captions only and `video.json` says so. KiCad's own picture of the copy needs KiCad's command line (`ADOM_AIFLOW_KICAD_CLI`); without it the scene shows the program's overlay.+- **A build takes one and a half to two and a half minutes** on four cores (70 to 140 seconds measured on two boards). `--no-voice` skips the voice; `--workers 1` uses fewer cores.++When the run sorted and joined its photos itself (`init --photos`), the photo scene shows the photos as they came (up to twelve), then each face with the close-up every area was taken from, the overlap figure and the coverage, all read from `stitch.json`. When photos given with `--top` and `--bottom` come from a stitcher's folder (a `stitch.json` beside them), `init` keeps that record and its source maps under `reverse-flow/photos/stitch/` and the scene uses them the same way.+ ## Rules are set at the start  `init --rules` takes a named profile or a JSON file. The same values drive the zones, the router and the gate, so nothing is checked against a rule it was not built to.@@ -169,19 +288,60 @@ Each stage records its interactive PCB or schematic in the flow window, numbered ## What it writes  ```-kicad/<name>.kicad_pcb, .kicad_sch, .kicad_pro      the copy+kicad/<name>.kicad_pcb, .kicad_pro                  the copy+kicad/<name>.kicad_sch, <name>-<block>.kicad_sch    the schematic: root sheet (hierarchy, legend) and one sheet per block+kicad/RE.kicad_sym, sym-lib-table, fp-lib-table     the schematic's symbols, and the footprints the run was built from+reverse-flow/photos-raw/                            the photos given to `init --photos`, as they came+reverse-flow/stitch.json, stitch/                   `stitch`: roles, registration, overlap disagreement, coverage; mosaics, sources maps, seam crops, the script and its log reverse-flow/parts.json blocks.json copper.json nets.json layers.json review.json tests.json reverse-flow/report.md                              the result reverse-flow/audits/<category>/                     audit samples, crops, contact sheets, verdicts reverse-flow/confidence-scores.json                 every score with its inputs, for reading; nothing reads it back reverse-flow/photo-layers.json                      `layers trace`: silk, pad shapes, holes and outline read from the photos; `build` draws them reverse-flow/fidelity.json                          `check fidelity`: what KiCad draws against the photos, per layer, with what could not be scored-reverse-flow/routes/L<n>/                           one folder per stackup tried: routed.json, board, DRC reports, gate.json+reverse-flow/unrouted.json                          the connections no photo shows, left unrouted, each with its evidence tag+reverse-flow/bench/                                 `bench plan`: plan.md, plan.json, crops/, results-template.json; `bench ingest`: measured.json, ingested/+reverse-flow/auto.json, auto-log.txt                how the last `auto` ended, and every command it ran with its output+reverse-flow/routes/L<n>/                           only after the optional `connect route`: one folder per stackup tried+reverse-flow/net-confidence.json                    `nets draw`: each net's level (its weakest link, with its place), pins seen and copper by rung+reverse-flow/nets/                                  `nets draw`, also run by `report`: confidence.jpg (both faces, copper by how well its net is proven),+                                                    confidence-top.jpg and -bottom.jpg at full size, attribution.jpg, confidence-table-<n>.jpg,+                                                    net/<name>.jpg per net, sections/ (ground, each rail, each bus, the rest), index.md, and+                                                    view.html (a net list beside the board, by confidence or by net; also a numbered view in the flow window) reverse-flow/evidence/                              overlays to check every measurement by eye+reverse-flow/video/                                 `video build`, run by `auto` after the report: <project>-run.mp4, captions, poster, video.json, stills/+reverse-flow/photos/stitch/                         `init`: the stitcher's record and source maps, when the photos came with a stitch.json+reverse-flow/schematic/                             `report`: schematic.pdf and one picture per sheet, listed in the report tables reverse-flow/views/                                 the recorded PCB and schematic views ``` -## How the copper is read and the board is routed+## The schematic++`build` (and `schematic`, which skips the board) draws the schematic from the run's own files, so it cannot disagree with the board: every symbol pin is a footprint pad number and every connection is a net of `nets.json`.++- **Sheets.** A root sheet with the hierarchy and the legend, and one sheet per block of `blocks.json`: A4 when it fits, A3 at most, split when it does not. Each block is a frame with a centred uppercase title.+- **Symbols.** Resistors, capacitors, inductors, diodes, LEDs, crystals, connectors, test points, switches and mounting holes take KiCad's standard shapes, fetched once and kept under `reverse-flow/lib/sym`. A library symbol is used only when its pin numbers are exactly the footprint's pads. An IC with no symbol is a box: by function when the run names its pins, by pad order otherwise.+- **Wires.** Every pin of an IC or connector starts a strip: parts in series sit in line, parts to a rail hang from it, decoupling hangs at the supply pin it sits nearest on the board, and a net that goes elsewhere ends in a label on the end of its wire. Rails are power symbols.+- **Evidence.** A solid wire leads to a pad seen joined to that net's copper in the photos; a dashed wire is inferred. A pin whose net is not known ends in a `?` marker, never a no-connect flag. Each symbol carries hidden `Evidence`, `Test` and `Marking` fields.++What the AI may add, in `parts.json` and `nets.json`:++| Field | Meaning |+|---|---|+| part `symbol` | `"Library:Name"` in KiCad's standard libraries, for an identified part. Refused, with a note, when its pins are not the footprint's pads |+| part `pins` | pad number to pin name, or to `{"name", "type"}` (a KiCad pin type), from the datasheet. An IC becomes a box grouped by function; a transistor named `G`/`S`/`D` or `B`/`C`/`E` whose value says N or P, NPN or PNP gets its drawing on those pads |+| `datasheets/<ref>.pins.json` | for each identified chip: the datasheet cited by name, revision and page, and every pad's `name`, `type` and optional `expects` (docs/method.md, "Datasheet pins"). `datasheet apply` writes it into the part's `pins`, attribution names the copper at those pins from it, and `auto` stops with `AUTO NEEDS AI: datasheet pins` until each identified chip has one or a reasoned `{"available": false, "reason"}` |+| `connect witness` (run by `connect attribute`) | each pin of a chip with a pins file against the copper graph: `agree`, `disagree` or `not visible`, in `detect/witness.json`; disagreements as findings with a picture and a test under `evidence/witness/`; each net's `datasheet` witness in net-evidence.json, `datasheetWitness` in nets.json and net-confidence.json, and the Datasheet column of nets/index.md. It changes neither copper nor nets |+| `connect pinout` (run by `auto` before attribution) | nets from the parts first: the chain identity, orientation, pinout per part in `detect/pinout.json`, and a net for every pin with a known function, by named rules (`ground`, `pin function`, `datasheet tie`, `claimed part`), tagged DATASHEET or INFERRED, never SEEN or MEASURED. `auto` asks once for the orientation of a chip that has a pinout and no settled pin 1. Header pads named on silkscreen: type them into the part's `pins` |+| part `polarised` | `true` draws a capacitor with its polarity |+| net `rail` | `"supply"`, `"ground"` or `"signal"`, when the name alone would mislead (a net is taken as a rail when it is called like one or feeds a supply input) |+| `noConnect` in nets.json | pins a datasheet or the copper shows are unused: `["U1.5", {"pin": "U1.6", "why": "..."}]`. Only these get a no-connect flag |++`check schematic` passes when KiCad's ERC reports no error, the netlist KiCad exports equals `nets.json` pin by pin, and the sheet rules hold (labels on wires, right angles, titled blocks, no decoupling-only block, decoupling wired at its IC, no text under 40 mil or over a pin or body, nothing outside the border). It needs KiCad's command line: set `ADOM_AIFLOW_KICAD_CLI` to one that runs. The full report is `reverse-flow/checks/schematic.json`.++`next` and `auto` keep it current by themselves: in the connect step, after the nets are attributed and checked, `auto` runs `schematic` whenever parts.json, blocks.json, nets.json or net-evidence.json are newer than the sheets, then `check schematic`. A failed check stops `auto` with the list of what is wrong; a later edit to those files sends the flow back to it. `report` lists the sheets and writes `reverse-flow/schematic/schematic.pdf` and a picture of each sheet.++## How the copper is read and what is left unrouted  [docs/method.md](docs/method.md) explains the measurements and why each works. Read it before changing `trace.rs`, `islands.rs` or `route.rs`. @@ -191,4 +351,4 @@ Run `cargo test --release` before a change and look at `evidence/copper-islands-  ## License -MIT. The timeline page in `assets/` is copied from `adom/adom-aiflow` (MIT, Adom Industries).+MIT. The timeline page in `assets/` is copied from `adom/adom-aiflow` (MIT, Adom Industries). The label font atlases in `assets/` are rendered from DejaVu Sans (Bitstream Vera licence) by `tools/make_label_font.py`.
assets/flow.html+1−1
@@ -73,7 +73,7 @@ const wantKind = hp.get("v") ? null : hp.get("k");   // #k=timeline|findings|pcb if (hp.get("s")) selStep = hp.get("s");             // #s=<step> shows that step's findings const $ = id => document.getElementById(id); const esc = s => String(s ?? "").replace(/[&<>"]/g, c => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[c]));-const label = {timeline: "AI Flow timeline", pcb: "PCB", sch: "Schematic", photos: "Photos", findings: "Findings"};+const label = {timeline: "AI Flow timeline", pcb: "PCB", sch: "Schematic", photos: "Photos", nets: "Nets", findings: "Findings"}; const time = t => (t || "").replace("T", " ").replace("Z", " UTC");  async function poll() {
assets/label-font-bold.jsonadded+1
@@ -0,0 +1 @@+{"size":56,"ascent":52,"height":66,"glyphs":{" ":{"x":3,"w":21,"left":0,"adv":19.5},"!":{"x":27,"w":27,"left":0,"adv":25.55},"\"":{"x":57,"w":30,"left":0,"adv":29.17},"#":{"x":90,"w":48,"left":0,"adv":46.92},"$":{"x":141,"w":40,"left":0,"adv":38.97},"%":{"x":184,"w":57,"left":0,"adv":56.11},"&":{"x":244,"w":50,"left":0,"adv":48.84},"'":{"x":297,"w":18,"left":0,"adv":17.14},"(":{"x":318,"w":27,"left":0,"adv":25.59},")":{"x":348,"w":27,"left":0,"adv":25.59},"*":{"x":378,"w":30,"left":0,"adv":29.28},"+":{"x":411,"w":48,"left":0,"adv":46.92},",":{"x":462,"w":22,"left":0,"adv":21.28},"-":{"x":487,"w":24,"left":0,"adv":23.25},".":{"x":514,"w":22,"left":0,"adv":21.28},"/":{"x":539,"w":22,"left":0,"adv":20.45},"0":{"x":564,"w":40,"left":0,"adv":38.97},"1":{"x":607,"w":40,"left":0,"adv":38.97},"2":{"x":650,"w":40,"left":0,"adv":38.97},"3":{"x":693,"w":40,"left":0,"adv":38.97},"4":{"x":736,"w":40,"left":0,"adv":38.97},"5":{"x":779,"w":40,"left":0,"adv":38.97},"6":{"x":822,"w":40,"left":0,"adv":38.97},"7":{"x":865,"w":40,"left":0,"adv":38.97},"8":{"x":908,"w":40,"left":0,"adv":38.97},"9":{"x":951,"w":40,"left":0,"adv":38.97},":":{"x":994,"w":23,"left":0,"adv":22.39},";":{"x":1020,"w":23,"left":0,"adv":22.39},"<":{"x":1046,"w":48,"left":0,"adv":46.92},"=":{"x":1097,"w":48,"left":0,"adv":46.92},">":{"x":1148,"w":48,"left":0,"adv":46.92},"?":{"x":1199,"w":33,"left":0,"adv":32.48},"@":{"x":1235,"w":57,"left":0,"adv":56.0},"A":{"x":1295,"w":45,"left":0,"adv":43.34},"B":{"x":1343,"w":44,"left":0,"adv":42.69},"C":{"x":1390,"w":42,"left":0,"adv":41.09},"D":{"x":1435,"w":47,"left":0,"adv":46.48},"E":{"x":1485,"w":39,"left":0,"adv":38.25},"F":{"x":1527,"w":39,"left":0,"adv":38.25},"G":{"x":1569,"w":47,"left":0,"adv":45.97},"H":{"x":1619,"w":48,"left":0,"adv":46.88},"I":{"x":1670,"w":22,"left":0,"adv":20.84},"J":{"x":1695,"w":26,"left":-4,"adv":20.84},"K":{"x":1724,"w":47,"left":0,"adv":43.39},"L":{"x":1774,"w":37,"left":0,"adv":35.69},"M":{"x":1814,"w":57,"left":0,"adv":55.73},"N":{"x":1874,"w":48,"left":0,"adv":46.88},"O":{"x":1925,"w":49,"left":0,"adv":47.61},"P":{"x":1977,"w":42,"left":0,"adv":41.05},"Q":{"x":2022,"w":49,"left":0,"adv":47.61},"R":{"x":2074,"w":44,"left":0,"adv":43.12},"S":{"x":2121,"w":41,"left":0,"adv":40.33},"T":{"x":2165,"w":39,"left":0,"adv":38.2},"U":{"x":2207,"w":46,"left":0,"adv":45.47},"V":{"x":2256,"w":45,"left":0,"adv":43.34},"W":{"x":2304,"w":63,"left":0,"adv":61.77},"X":{"x":2370,"w":44,"left":0,"adv":43.17},"Y":{"x":2417,"w":44,"left":-1,"adv":40.55},"Z":{"x":2464,"w":42,"left":0,"adv":40.61},"[":{"x":2509,"w":27,"left":0,"adv":25.59},"\\":{"x":2539,"w":22,"left":0,"adv":20.45},"]":{"x":2564,"w":27,"left":0,"adv":25.59},"^":{"x":2594,"w":48,"left":0,"adv":46.92},"_":{"x":2645,"w":29,"left":0,"adv":28.0},"`":{"x":2677,"w":29,"left":0,"adv":28.0},"a":{"x":2709,"w":39,"left":0,"adv":37.8},"b":{"x":2751,"w":41,"left":0,"adv":40.09},"c":{"x":2795,"w":34,"left":0,"adv":33.2},"d":{"x":2832,"w":41,"left":0,"adv":40.09},"e":{"x":2876,"w":39,"left":0,"adv":37.98},"f":{"x":2918,"w":26,"left":0,"adv":24.36},"g":{"x":2947,"w":41,"left":0,"adv":40.09},"h":{"x":2991,"w":41,"left":0,"adv":39.88},"i":{"x":3035,"w":20,"left":0,"adv":19.2},"j":{"x":3058,"w":22,"left":-2,"adv":19.2},"k":{"x":3083,"w":40,"left":0,"adv":37.25},"l":{"x":3126,"w":20,"left":0,"adv":19.2},"m":{"x":3149,"w":59,"left":0,"adv":58.36},"n":{"x":3211,"w":41,"left":0,"adv":39.88},"o":{"x":3255,"w":39,"left":0,"adv":38.47},"p":{"x":3297,"w":41,"left":0,"adv":40.09},"q":{"x":3341,"w":41,"left":0,"adv":40.09},"r":{"x":3385,"w":29,"left":0,"adv":27.62},"s":{"x":3417,"w":34,"left":0,"adv":33.33},"t":{"x":3454,"w":28,"left":0,"adv":26.77},"u":{"x":3485,"w":41,"left":0,"adv":39.88},"v":{"x":3529,"w":38,"left":0,"adv":36.5},"w":{"x":3570,"w":53,"left":0,"adv":51.73},"x":{"x":3626,"w":37,"left":0,"adv":36.12},"y":{"x":3666,"w":38,"left":0,"adv":36.5},"z":{"x":3707,"w":34,"left":0,"adv":32.59},"{":{"x":3744,"w":41,"left":0,"adv":39.88},"|":{"x":3788,"w":21,"left":0,"adv":20.45},"}":{"x":3812,"w":41,"left":0,"adv":39.88},"~":{"x":3856,"w":48,"left":0,"adv":46.92}}}
assets/label-font-bold.pngadded
⋯ 1 unchanged line ⋯
assets/label-font-regular.jsonadded+1
@@ -0,0 +1 @@+{"size":56,"ascent":52,"height":66,"glyphs":{" ":{"x":3,"w":19,"left":0,"adv":17.8},"!":{"x":25,"w":23,"left":0,"adv":22.45},"\"":{"x":51,"w":27,"left":0,"adv":25.77},"#":{"x":81,"w":48,"left":0,"adv":46.92},"$":{"x":132,"w":37,"left":0,"adv":35.62},"%":{"x":172,"w":54,"left":0,"adv":53.22},"&":{"x":229,"w":45,"left":0,"adv":43.67},"'":{"x":277,"w":16,"left":0,"adv":15.39},"(":{"x":296,"w":23,"left":0,"adv":21.84},")":{"x":322,"w":23,"left":0,"adv":21.84},"*":{"x":348,"w":29,"left":0,"adv":28.0},"+":{"x":380,"w":48,"left":0,"adv":46.92},",":{"x":431,"w":19,"left":0,"adv":17.8},"-":{"x":453,"w":21,"left":0,"adv":20.2},".":{"x":477,"w":19,"left":0,"adv":17.8},"/":{"x":499,"w":20,"left":0,"adv":18.88},"0":{"x":522,"w":37,"left":0,"adv":35.62},"1":{"x":562,"w":37,"left":0,"adv":35.62},"2":{"x":602,"w":37,"left":0,"adv":35.62},"3":{"x":642,"w":37,"left":0,"adv":35.62},"4":{"x":682,"w":37,"left":0,"adv":35.62},"5":{"x":722,"w":37,"left":0,"adv":35.62},"6":{"x":762,"w":37,"left":0,"adv":35.62},"7":{"x":802,"w":37,"left":0,"adv":35.62},"8":{"x":842,"w":37,"left":0,"adv":35.62},"9":{"x":882,"w":37,"left":0,"adv":35.62},":":{"x":922,"w":20,"left":0,"adv":18.88},";":{"x":945,"w":20,"left":0,"adv":18.88},"<":{"x":968,"w":48,"left":0,"adv":46.92},"=":{"x":1019,"w":48,"left":0,"adv":46.92},">":{"x":1070,"w":48,"left":0,"adv":46.92},"?":{"x":1121,"w":31,"left":0,"adv":29.72},"@":{"x":1155,"w":57,"left":0,"adv":56.0},"A":{"x":1215,"w":39,"left":0,"adv":38.31},"B":{"x":1257,"w":39,"left":0,"adv":38.42},"C":{"x":1299,"w":40,"left":0,"adv":39.11},"D":{"x":1342,"w":44,"left":0,"adv":43.12},"E":{"x":1389,"w":36,"left":0,"adv":35.39},"F":{"x":1428,"w":33,"left":0,"adv":32.22},"G":{"x":1464,"w":44,"left":0,"adv":43.39},"H":{"x":1511,"w":43,"left":0,"adv":42.11},"I":{"x":1557,"w":18,"left":0,"adv":16.52},"J":{"x":1578,"w":21,"left":-3,"adv":16.52},"K":{"x":1602,"w":39,"left":0,"adv":36.72},"L":{"x":1644,"w":32,"left":0,"adv":31.2},"M":{"x":1679,"w":49,"left":0,"adv":48.31},"N":{"x":1731,"w":43,"left":0,"adv":41.89},"O":{"x":1777,"w":45,"left":0,"adv":44.08},"P":{"x":1825,"w":35,"left":0,"adv":33.77},"Q":{"x":1863,"w":45,"left":0,"adv":44.08},"R":{"x":1911,"w":40,"left":0,"adv":38.91},"S":{"x":1954,"w":37,"left":0,"adv":35.55},"T":{"x":1994,"w":37,"left":-1,"adv":34.2},"U":{"x":2034,"w":42,"left":0,"adv":40.98},"V":{"x":2079,"w":39,"left":0,"adv":38.31},"W":{"x":2121,"w":56,"left":0,"adv":55.38},"X":{"x":2180,"w":39,"left":0,"adv":38.36},"Y":{"x":2222,"w":37,"left":-1,"adv":34.2},"Z":{"x":2262,"w":39,"left":0,"adv":38.36},"[":{"x":2304,"w":23,"left":0,"adv":21.84},"\\":{"x":2330,"w":20,"left":0,"adv":18.88},"]":{"x":2353,"w":23,"left":0,"adv":21.84},"^":{"x":2379,"w":48,"left":0,"adv":46.92},"_":{"x":2430,"w":31,"left":-1,"adv":28.0},"`":{"x":2464,"w":29,"left":0,"adv":28.0},"a":{"x":2496,"w":35,"left":0,"adv":34.31},"b":{"x":2534,"w":37,"left":0,"adv":35.55},"c":{"x":2574,"w":32,"left":0,"adv":30.8},"d":{"x":2609,"w":37,"left":0,"adv":35.55},"e":{"x":2649,"w":35,"left":0,"adv":34.45},"f":{"x":2687,"w":22,"left":0,"adv":19.72},"g":{"x":2712,"w":37,"left":0,"adv":35.55},"h":{"x":2752,"w":37,"left":0,"adv":35.5},"i":{"x":2792,"w":17,"left":0,"adv":15.56},"j":{"x":2812,"w":19,"left":-2,"adv":15.56},"k":{"x":2834,"w":34,"left":0,"adv":32.44},"l":{"x":2871,"w":17,"left":0,"adv":15.56},"m":{"x":2891,"w":56,"left":0,"adv":54.55},"n":{"x":2950,"w":37,"left":0,"adv":35.5},"o":{"x":2990,"w":35,"left":0,"adv":34.27},"p":{"x":3028,"w":37,"left":0,"adv":35.55},"q":{"x":3068,"w":37,"left":0,"adv":35.55},"r":{"x":3108,"w":25,"left":0,"adv":23.03},"s":{"x":3136,"w":30,"left":0,"adv":29.17},"t":{"x":3169,"w":23,"left":0,"adv":21.95},"u":{"x":3195,"w":37,"left":0,"adv":35.5},"v":{"x":3235,"w":34,"left":0,"adv":33.14},"w":{"x":3272,"w":47,"left":0,"adv":45.8},"x":{"x":3322,"w":34,"left":0,"adv":33.14},"y":{"x":3359,"w":34,"left":0,"adv":33.14},"z":{"x":3396,"w":30,"left":0,"adv":29.39},"{":{"x":3429,"w":37,"left":0,"adv":35.62},"|":{"x":3469,"w":20,"left":0,"adv":18.88},"}":{"x":3492,"w":37,"left":0,"adv":35.62},"~":{"x":3532,"w":48,"left":0,"adv":46.92}}}
assets/label-font-regular.pngadded
⋯ 1 unchanged line ⋯
assets/nets-view.htmladded+186
@@ -0,0 +1,186 @@+<!doctype html>+<html lang="en">+<head>+<meta charset="utf-8">+<meta name="viewport" content="width=device-width, initial-scale=1">+<title>Nets</title>+<style>+:root{--bg:#0d1117;--panel:#161b22;--line:#30363d;--text:#e6edf3;--dim:#8b949e;--accent:#2f81f7}+*{box-sizing:border-box}+html,body{margin:0;height:100%;background:var(--bg);color:var(--text);font:14px/1.45 Inter,system-ui,-apple-system,"Segoe UI",sans-serif}+main{display:grid;grid-template-columns:300px 1fr;height:100%}+aside{border-right:1px solid var(--line);display:flex;flex-direction:column;min-height:0}+.top{padding:10px;border-bottom:1px solid var(--line);display:flex;flex-direction:column;gap:8px}+.modes{display:flex;gap:6px}+.modes button{flex:1;min-height:44px;border:1px solid var(--line);background:none;color:var(--text);border-radius:6px;cursor:pointer;font:inherit}+.modes button.sel{border-color:var(--accent);background:#1f2a3a}+input{min-height:36px;border:1px solid var(--line);background:var(--panel);color:var(--text);border-radius:6px;padding:0 10px;font:inherit}+#list{overflow:auto;flex:1;padding:6px}+h2{font-size:11px;letter-spacing:.06em;text-transform:uppercase;color:var(--dim);margin:12px 6px 4px}+.net{display:flex;align-items:center;gap:8px;padding:5px 8px;border-radius:6px;cursor:pointer;min-height:32px}+.net:hover{background:#1f2630}.net.sel{background:#1f2a3a;outline:1px solid var(--accent)}+.net i{width:14px;height:14px;border-radius:3px;flex:none;border:1px solid #ffffff55}+.net b{font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}+.net span{margin-left:auto;color:var(--dim);font-size:12px;white-space:nowrap}+section{display:flex;flex-direction:column;min-width:0;min-height:0}+#info{padding:10px 14px;border-bottom:1px solid var(--line);min-height:64px}+#info .name{font-size:20px;font-weight:700;margin-right:10px}+#info .lv{font-weight:700;padding:2px 8px;border-radius:5px;background:#2c3036}+#info .why{color:var(--dim);font-size:13px;margin-top:2px}+#faces{flex:1;display:flex;gap:10px;padding:10px;min-height:0}+.face{flex:1;min-width:0;display:flex;flex-direction:column}+.face .cap{font-size:11px;letter-spacing:.05em;color:var(--dim);margin-bottom:4px;text-transform:uppercase}+.stack{position:relative;flex:1;min-height:0}+.stack img,.stack canvas{position:absolute;left:0;top:0;width:100%;height:100%;object-fit:contain}+.stack img{filter:grayscale(.72) brightness(.6)}+.stack canvas{cursor:crosshair;image-rendering:auto}+#legend{display:flex;flex-wrap:wrap;gap:14px;padding:8px 14px;border-top:1px solid var(--line);font-size:12px;color:var(--dim)}+#legend div{display:flex;align-items:center;gap:6px}+#legend canvas{width:18px;height:18px;border:1px solid #ffffff55}+</style>+</head>+<body>+<main>+  <aside>+    <div class="top">+      <div class="modes"><button id="m-conf" class="sel">By confidence</button><button id="m-net">By net</button></div>+      <input id="q" placeholder="Find a net" autocomplete="off">+    </div>+    <div id="list"></div>+  </aside>+  <section>+    <div id="info"></div>+    <div id="faces"></div>+    <div id="legend"></div>+  </section>+</main>+<script>+const D = /*DATA*/null/*END*/;+const MAPS = /*MAPS*/{}/*END*/;+const $ = id => document.getElementById(id);+const esc = s => String(s ?? "").replace(/[&<>"]/g, c => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[c]));+const RUNG = D.rungColors, HUES = D.netHues, LADDER = D.ladder;+const WORDS = {copper: "every pin seen joined by continuous copper", via: "pins joined, the weakest join through verified vias", inferred: "at least one pin or join rests on inference", group: "unnamed copper joined to other unnamed copper", none: "isolated copper: no pad, no valid via", conflict: "two nets meet and nothing settles it"};+let mode = "conf", sel = null, faces = [];+const rank = l => Math.max(0, LADDER.indexOf(l));+const byName = Object.fromEntries(D.nets.map(n => [n.name, n]));+// the level a piece is drawn at: the weaker of its own rung and its net's level+const idLevel = D.ids.map(e => e.u !== undefined ? e.u : rank((byName[e.n] || {}).level || "none"));+const idNet = D.ids.map(e => e.n || null);+const hueOf = {}; D.nets.forEach((n, i) => hueOf[n.name] = HUES[i % HUES.length]);++function pattern(k, x, y) {+  const per = 10, m = (v, n) => ((v % n) + n) % n;+  switch (k) {+    case 0: return 1;+    case 1: return m(y, per) < 6 ? 1 : 0.38;+    case 2: return m(x + y, per) < 5 ? 1 : 0.28;+    case 3: return (m(x, per) < 3 || m(y, per) < 3) ? 1 : 0.22;+    case 4: return (m(x, per) < 4 && m(y, per) < 4) ? 1 : 0.3;+    default: return m(x - y, 15) < 7 ? 1 : -1;+  }+}++function loadFace(side) {+  return new Promise(res => {+    if (!MAPS[side]) return res(null);+    const im = new Image();+    im.onload = () => {+      const c = document.createElement("canvas"); c.width = im.width; c.height = im.height;+      const g = c.getContext("2d", {willReadFrequently: true}); g.drawImage(im, 0, 0);+      res({side, w: im.width, h: im.height, map: g.getImageData(0, 0, im.width, im.height).data});+    };+    im.src = MAPS[side];+  });+}++function paint(f) {+  const g = f.canvas.getContext("2d"), out = g.createImageData(f.w, f.h), o = out.data, m = f.map;+  const selId = sel ? D.ids.findIndex(e => e.n === sel) : -1;+  for (let y = 0, i = 0; y < f.h; y++) for (let x = 0; x < f.w; x++, i += 4) {+    const b = m[i + 2]; if (!b) continue;+    const id = m[i] + (m[i + 1] << 8) - 1, rung = b - 1;+    let c, a;+    if (sel) {+      if (id !== selId) continue;+      if (mode === "conf") { const k = pattern(rung, x, y); c = k < 0 ? [12, 14, 18] : RUNG[rung]; a = 0.92 * Math.abs(k); } else { c = [255, 221, 60]; a = 0.92; }+    } else if (mode === "conf") {+      const lv = Math.max(rung, idLevel[id]), k = pattern(lv, x, y); c = k < 0 ? [12, 14, 18] : RUNG[lv]; a = 0.86 * Math.abs(k);+    } else {+      const n = idNet[id]; if (n && byName[n]) { c = hueOf[n]; a = 0.82; } else { c = [150, 150, 150]; a = 0.3; }+    }+    o[i] = c[0]; o[i + 1] = c[1]; o[i + 2] = c[2]; o[i + 3] = Math.round(255 * a);+  }+  g.putImageData(out, 0, 0);+  if (!sel) return;+  // the selected net's pins: filled when seen joined, hollow when on the net by inference only; the weakest link ringed+  const n = byName[sel], s = D.res, bit = f.side === "top" ? 1 : 2, px = (x, y) => [(x - 100) * s, (y - 100) * s];+  for (const p of (D.pins[sel] || [])) {+    if (!(p[2] & bit)) continue;+    const [x, y] = px(p[0], p[1]), r = 5;+    g.lineWidth = 4; g.strokeStyle = "#0c0e12"; g.strokeRect(x - r, y - r, 2 * r, 2 * r);+    if (p[3]) { g.fillStyle = "#fff"; g.fillRect(x - r, y - r, 2 * r, 2 * r); } else { g.lineWidth = 2; g.strokeStyle = "#fff"; g.strokeRect(x - r, y - r, 2 * r, 2 * r); }+  }+  if (n && n.at && n.level !== "copper" && (!n.side || n.side === f.side)) {+    const [x, y] = px(n.at[0], n.at[1]);+    g.beginPath(); g.arc(x, y, 16, 0, 6.3); g.lineWidth = 6; g.strokeStyle = "#0c0e12"; g.stroke(); g.lineWidth = 3; g.strokeStyle = "#ffeb5a"; g.stroke();+  }+}++function list() {+  const q = $("q").value.trim().toLowerCase();+  const show = n => !q || n.name.toLowerCase().includes(q);+  const row = n => `<div class="net ${sel === n.name ? "sel" : ""}" data-n="${esc(n.name)}" title="${esc(n.weakest || "")}"><i style="background:rgb(${(mode === "conf" ? RUNG[rank(n.level)] : hueOf[n.name]).join(",")})"></i><b>${esc(n.name)}</b><span>${mode === "conf" ? `${n.seen} of ${n.pins} pins` : n.level}</span></div>`;+  let html = "";+  if (mode === "conf") {+    for (const l of ["copper", "via", "inferred", "none"]) {+      const v = D.nets.filter(n => n.level === l); if (!v.length) continue;+      const w = v.filter(show); if (!w.length) continue;+      html += `<h2>${l}: ${v.length} net${v.length === 1 ? "" : "s"}</h2>` + w.map(row).join("");+    }+    html += `<h2>No net</h2><div class="net" style="cursor:default"><span style="margin:0">${D.unnamed.islands} isolated islands, ${D.unnamed.groups} groups, ${D.unnamed.conflicts} in conflict: drawn, not listed</span></div>`;+  } else {+    for (const s of D.sections) { const w = s.nets.map(x => byName[x]).filter(n => n && show(n)); if (w.length) html += `<h2>${esc(s.title)}</h2>` + w.map(row).join(""); }+  }+  $("list").innerHTML = html;+  document.querySelectorAll(".net[data-n]").forEach(el => el.onclick = () => pick(el.dataset.n === sel ? null : el.dataset.n));+}++function info() {+  const n = sel && byName[sel];+  if (!n) { $("info").innerHTML = mode === "conf" ? `<span class="name">Net confidence</span><div class="why">Every piece of copper in the colour and pattern of how well its net is proven: a net is as proven as the weakest link joining its pins. Click a net in the list or on the board.</div>` : `<span class="name">Nets</span><div class="why">Each named net in its own hue, copper with no net in grey. Click a net in the list or on the board to light it on both faces.</div>`; return; }+  const c = RUNG[rank(n.level)].join(",");+  $("info").innerHTML = `<span class="name">${esc(n.name)}</span><span class="lv" style="color:rgb(${c})">${n.level}</span> &nbsp; ${n.pins} pin${n.pins === 1 ? "" : "s"}, ${n.seen} seen joined to its copper${n.disputes ? `, meets another net at ${n.disputes} place${n.disputes === 1 ? "" : "s"}` : ""} &nbsp; <a style="color:var(--accent)" href="../nets/net/${encodeURIComponent(n.file)}.jpg" target="_blank">picture</a><div class="why">Weakest link: ${esc(n.weakest || "none")}</div>`;+}++function legend() {+  $("legend").innerHTML = (mode === "conf" || sel ? LADDER.map((l, k) => `<div><canvas width="18" height="18" data-k="${k}"></canvas>${l}: ${WORDS[l]}</div>`).join("") : `<div>each named net has its own hue; grey is copper with no net</div>`) + `<div>filled square: pin seen joined; hollow square: pin by inference only; yellow ring: weakest link; the bottom face is turned over to line up with the top</div>`;+  document.querySelectorAll("#legend canvas").forEach(c => { const g = c.getContext("2d"), k = +c.dataset.k; g.fillStyle = "#2c3036"; g.fillRect(0, 0, 18, 18); for (let y = 0; y < 18; y++) for (let x = 0; x < 18; x++) { const p = pattern(k, x, y); g.fillStyle = p < 0 ? "#0c0e12" : `rgba(${RUNG[k].join(",")},${p})`; g.fillRect(x, y, 1, 1); } });+}++function pick(name) { sel = name; list(); info(); legend(); faces.forEach(paint); const el = document.querySelector(".net.sel"); if (el) el.scrollIntoView({block: "nearest"}); }+function setMode(m) { mode = m; $("m-conf").classList.toggle("sel", m === "conf"); $("m-net").classList.toggle("sel", m === "net"); pick(sel); }++(async () => {+  faces = (await Promise.all(["top", "bottom"].map(loadFace))).filter(Boolean);+  $("faces").innerHTML = faces.map(f => `<div class="face"><div class="cap">${f.side === "top" ? "Top face" : "Bottom face, turned over to line up with the top"}</div><div class="stack"><img src="../board/${f.side}.jpg" alt=""><canvas id="c-${f.side}" width="${f.w}" height="${f.h}"></canvas></div></div>`).join("");+  for (const f of faces) {+    f.canvas = $("c-" + f.side);+    f.canvas.onclick = ev => {+      // the canvas is letterboxed in its box: map the click to a map pixel, then to the net under it+      const r = f.canvas.getBoundingClientRect(), k = Math.min(r.width / f.w, r.height / f.h);+      const x = Math.floor((ev.clientX - r.left - (r.width - f.w * k) / 2) / k), y = Math.floor((ev.clientY - r.top - (r.height - f.h * k) / 2) / k);+      if (x < 0 || y < 0 || x >= f.w || y >= f.h) return pick(null);+      const i = 4 * (y * f.w + x), id = f.map[i + 2] ? f.map[i] + (f.map[i + 1] << 8) - 1 : -1, n = id >= 0 ? idNet[id] : null;+      pick(n && byName[n] && n !== sel ? n : null);+    };+  }+  $("m-conf").onclick = () => setMode("conf"); $("m-net").onclick = () => setMode("net");+  $("q").oninput = list;+  document.addEventListener("keydown", e => { if (e.key === "Escape") pick(null); });+  const h = new URLSearchParams(location.hash.slice(1)); if (h.get("mode") === "net") mode = "net";+  setMode(mode); if (h.get("net") && byName[h.get("net")]) pick(h.get("net"));+})();+</script>+</body>+</html>
bin/adom-aiflow-reverse
⋯ 1 unchanged line ⋯
docs/method.md+384−3
@@ -28,6 +28,90 @@ Tried on the Raspberry Pi Compute Module 5 from public photos, which differ from  **Still from one board.** `rounded_board` assumes a 2.6 mm corner. `PIT_*` bars are from everypin tile set A. Exposure factors are a straight line through one bright board. `parts` package sizes assume bodies read as moulding or tan, and bleached ceramics on a bright mask do not; closing flecked metal on a bright mask also turns large print into candidates. +## Finding and sizing parts on a photo the reader has not seen++The chip passive reader carried the CM5 photos' colour numbers, and found 15 of the Driver's 90 chip parts and 7 of 40 on the left half of board 10. Three things changed (2026-11-10).++**Each photo's own levels.** `tone.rs` finds the board surface as the photo's dominant hue (within 12 OpenCV hue units, above half of its own 10th-percentile saturation, no brighter than 1.1 times its own 98th-percentile value) and the bright level as the median of the brighter group of neutral pixels off the board. `passives::calibrate` then reads tan bodies strictly (clear ends on both sides, a size close to a package) at six saturation floors with a wide hue window. Each distinct body that passes gives its own hue, saturation and value and those of the solder beyond its ends. With five or more, the photo is read at levels set from them: the hue window is theirs with 6 units to spare, the saturation floor lies 0.85 of the way from the ends' median to the bodies' lower quartile, a body is at least 0.77 of the median body's value, an end 0.6 of the median end's, and a dark body is whatever is darker than a tan one at the bodies' own hue. With fewer, the CM5's numbers are used, scaled by the photo's bright level, and the candidates say so (`levels`: `scaled`, a fifth less sure). A second reading at the scaled numbers runs behind the first and only adds where the first found nothing. The factors were chosen on the CM5, the Driver and the left half of board 10; nothing else was tried on the right half before it was scored.++What this does not reach: a resistor whose body is neutral dark with pale ends (the Driver's, board 10's and board 4's). Its body has no hue to calibrate on, a sharp photo shows it speckled, and its white substrate shows along all four sides, so the end test finds ends across it as well as along it. Five other readers were tried and dropped the same evening: a threshold sweep (found more, invented more than it found on the CM5), a fit of each package's outline to bright-dark-bright (drowned in 0201 matches), islands of not-board split by Otsu's threshold on whiteness (the solder fringe joins the body), islands with a whiteness profile (sizes one class large), and a run-length measure through a given centre (71 percent right on board 10, 33 percent on the CM5).++**Every candidate is rated.** `candconf.rs` gives each candidate a `confidence` by rule: what kind of body it is, whether it fits a package, how large it is, and whether it sits on a plated hole's pad, at the board edge or inside a body five times its size. The rules were laid out on the left half of board 10 and are not probabilities. `parts init` takes those rated 0.5 or more.++**A chip's size follows its body.** `parts snap` used to move a chip part to the next size down as soon as the smaller pads fitted the photo's metal as well, and a smaller pair of pads fits inside any larger patch of solder. The working image is always 40 px/mm, so the sharper photo did not reach the fit as pixels; it reached it as wider, cleaner solder. Now the part's own body, read on the photo by the same reader, settles the size, provided the pads of that size fit no worse by 0.08; where no body reads, pads alone change a size only on a fit 0.15 better, either way. Each chip part's `sizeCheck` says what was read.++## A third board: levels from edges, print, and any mask colour++Tried on a small USB hub board (two whole-board close-ups, 76.8 px/mm, green mask, parts on the top only) and on close-up tiles of a red and a blue board. The baseline read the hub's top as one island holding 192 of 228 pins joined on that face, and `check copper` passed. The reader was developed on the hub's top; its bottom and the other boards were looked at afterwards.++**Why one island.** The gaps were never the problem. The top is mostly laminate with thin tracks, under a light that casts shadows beside every part. The two-class fit split shadow from laminate, so the laminate took the copper level (118 where the tracks read 180) and every track, pad and pour joined through it.++**Levels checked against step edges (`edgelevels.rs`).** A copper edge is a sharp step whatever share of the face each class has. Every sharp edge clear of parts and print is measured: the brightness 2 to 7 px out on each side, upper quartile on the bright side and lower on the dark. Edges must be sharp (half the step within 4 px of the crest, which drops the penumbra of cast shadows) and run on for 0.5 mm (which drops specks). The step that occurs most often is copper over laminate, and edges within 35 percent of it are kept. `agreement` counts how many of them have both sides in one class under the fitted levels: 0.77 in the copper class on the hub top and 0.58 on the CM5 top, against a larger share of 0.19 and 0.29 on the Driver top and bottom and 0.20 on the CM5 bottom. Over half, and the levels are replaced by the edges' own two sides, averaged over the finest of 12, 6 and 3 mm windows that holds 60 samples. Under half, nothing changes, so the Driver and the CM5 bottom keep their levels. The CM5 top's are replaced: it goes from 102 kept islands to 299, tracks beside the processor that were one pour come apart, and its copper score moves from 0.702 to 0.670 (precision 0.79 to 0.92, recall 0.85 to 0.72) on a scorer that shares its threshold with the old levels. Both figures are written per face to `detect/copper-island-accounting.json` (`levelsAgainstEdges`, `levelsFromEdges`, `copperShare`).++**Print (`trace.rs`, `printgaps.rs`).** Two errors. A printed line's blurred rim is brighter than laminate and not pale, so it read as copper and every line of print on laminate became a track. What `photomat` calls silk is now never read, with a 2 px rim, and never counts as the lit edge of copper. And a gap that runs under print closed there, because the pixels under the print are nearer the copper on each side: `gaps_under` walks each gap mouth straight on under the print, and where the walk comes out on laminate within 2 mm the walked pixels are gap. A gap that bends under print, or runs under print wider than 2 mm, is still lost.++**Which faces carry parts.** `photomat::carries_parts` counts dark and tan blobs. Brass standoffs, their shadows and a strip of table passed it on the hub's bare bottom (2.4 percent against the 1 percent line), so the frame of its printed table read as solder. `face_carries_parts` asks `parts.json` once the run has placed parts and the photo only before that.++**Mask colour (`maskcolour.rs`).** The peak of the hue histogram of coloured pixels is the mask's hue. In the green bracket (65 to 200 degrees) everything is as it was, to the letter. Otherwise copper is read in the brightest channel whose median over the mask is at most 160: the mask's own channel sits where the camera's tone curve is flat (blue board: step 0.06 to 0.11 in blue against 0.24 to 0.44 in green; red board: red clipped over 16 to 18 percent of the mask). "Red over green", which meant gold, tan or a part, becomes a warm hue at least 20 degrees off the mask's. `photomat`'s G - R plane is rebuilt as 100 times saturation at the mask's hue, -60 for a warm colour off it and 0 otherwise, which puts mask, gold and neutral where the Driver photos had them. This was looked at on four red and four blue tiles and is not scored: no red or blue board has been run through the flow.++**Whole faces of the red and the blue board (`maskread.rs`, 2026-11-10).** Boards 7 (red) and 8 (blue) were taken from their photos through `prep`, `scale` by rulers and `copper trace`, with no parts placed. Three things changed, none of which touches a green mask:++- *The channel is measured.* The rule above put the red board on green or blue, whose median over the mask is 30 to 50 of 255: colour noise there is as large as the step from laminate to copper. For each channel the reader now takes the structure the plain mask shows (5th to 95th percentile of log brightness between 2 and 12 px of smoothing) over that channel's grain, and moves off the rule's channel only when another scores 1.5 times better. Board 7 reads in red (1.73 and 1.45 against 0.95 and 0.58 for the rule's channel); board 8 stays in green. Island fragments before joining fell from 17,384 to 4,580 on the red top and from 13,260 to 2,654 on its bottom.+- *A colour that cannot be the mask's is a part.* A black chip under a blue-white light reads dark teal, saturation 0.5, and passed the test for a dark body ("value under 0.30 and saturation under 0.42"), so it was traced as copper. On a mask that is not green, a coloured pixel 60 degrees or more from the mask's hue is not board.+- *Levels far wider than the edges step.* A face that is nearly all pour gives the two-class fit almost no laminate. When the fitted levels lie more than 1.6 times as far apart as the face's own copper edges step, they are taken from the edges (`edgelevels`), on masks that are not green only.++Judged by eye on places chosen before the reader's answer was looked at (15 random places on plain mask per face, and 10 on thin dark lines per face; the red top was the development face): the red bottom agrees at 12 of 12 random places (8 of 12 before) and 7 of 9 dark-line places (6 of 9 before); the blue bottom at 11 of 13 and 9 of 9, the blue top at 8 of 13 and 1 of 4, both unchanged. Places where the eye could not tell are left out of each count. The sample is small and nearly all copper, because both boards are flooded with pour.++Still wrong: on the red top, the fine tracks under the brightest light are not separated (the red channel sits on the camera's shoulder there and shows a clearance as a step of 0.06 in log brightness); the blue top breaks its pour into laminate patches at 5 of 13 places, under glare and a speckled mask; where the mosaic was filled from the overview nothing can be read. `scale` decides which way the board was turned over from through features when no hole agrees (`flipdecide.rs`); on these two boards the features do not correlate either, and `--flip` has to be given.++**`check copper` (`copperchecks.rs`)** now fails when one island holds over two thirds of a face's joined pins (hub baseline 0.84; largest honest share so far 0.51, the Driver bottom's ground pour), when one island joins over two thirds of the pins of a part with 16 or more, when over 95 percent of the visible face reads as copper, or when the levels miss the photo's step edges after replacement. An island with a third of the pins on three or more named nets gets a hint.++**Still wrong on the hub board.** Its bottom right, laminate with a few thin tracks, still reads as one ragged pour: the levels there pass the edge test over the face as a whole and are wrong locally. Rings of header holes come out as several small islands each. Print under a part's shadow is not found as print. On the red tile grain and scratches fragment the pours.++## Pads join their copper, and a track goes on in line++After the levels were fixed the hub board's tracks came apart, and so did every pad from its track: 63 of 258 top pads joined anything. The reader sees mask-covered copper. A pad is exposed metal under a part's end, with a fillet, a glint and often the part's shadow, none of which is read, so the copper stopped 4 to 11 px short of each pad and a join depended on a ring test round the pad.++**The sample.** 40 top pads of the hub board, the pad nearest each cell of an 8 by 5 grid, shown on photo crops only (`scratch/b10/sample/`), and for each the neighbouring pad, via or track its copper visibly reaches: 31 joins on 28 pads, the other 12 showing nothing that could be followed. Even-numbered pads were used to develop, odd ones to report. A join counts when `copper trace` puts the pad's pin on the island found within 0.2 mm of the target.++**`padjoin.rs`.** Each pad is a seed: the footprint's pad as placed, or a blob of exposed metal on a face with no parts placed yet. A seed grows through whatever the photo cannot show and stops at seen laminate or a gap line, 0.3 mm at most (`REACH_PX`), each pixel going to the nearest seed. The islands it meets are the pad's copper: they become one island with the pad's own area and a 3 px path. Shadow does not stop the growth: a pixel 0.3 of the separation below the laminate level is in shadow, not laminate. Two rules came from the first try, which shorted 32 two-pad parts and four pins of the hub chip through strips of copper that pass both pads: when two pads of one part reach the same island, the nearer keeps it if the other is 0.15 mm further off, and otherwise neither does, except two pins of a chip that both lie on it. `islands::pad_island` asks first which island covers the pad.++**`trackbridge.rs`.** Two track pieces are joined when one lies straight ahead of the other's end within 0.5 mm (`MAX_BRIDGE_PX`, shorter than the smallest two-pad part, so a bridge cannot span a part), has the same width, both run straight for 0.75 mm, both stand 0.4 of the level separation above both flanks, no gap line lies between, and the break does not read as laminate for 0.1 mm. Without the flank and length tests 21 of 24 bridges drawn at random joined hole rings, print rims and streaks. With them the hub board's top gets 2 bridges: its tracks were broken at pads, not along their length, so this step does little there.++No chain of pads may put the two ends of a two-pad part on one island either: each island carries the pads joined to it, and a join that would bring a pad and its mate together is refused.++**Figures.** Hub board top, joins reproduced: 6 of 17 before and 13 of 17 after on the development half, 3 of 14 before and 13 of 14 after on the reporting half. Top pads joined to anything 63 to 248 of 258, largest island's share of them 0.08 to 0.03, two-pad parts with both ends on one island 0. Driver and CM5 copper scores moved by under 0.01.++**A held-out board, and what it showed.** Board 04 (a stitched face at about 40 px/mm, green, no parts placed, so the seeds were blobs of metal) was looked at only after the above. Its pours are separated by gaps half a millimetre wide that the reader finds in most places and loses in some, so one island already held 30 percent of the crop before this round and holds 34 after, with 17 of 40 sampled pads on it. The 8 joins labelled there score 2 before and 4 after, and neither figure means anything, because both go through that island. `check copper` cannot see it either: with no parts placed there are no pins to count. Forcing the levels from the edges on that face (its fitted levels put 0.24 of its copper edges wholly in copper, under the one half that triggers) brings the island to 16 percent, and doing the same to the Driver bottom costs it 0.10 of its copper score and 227 vias, so the trigger stays where it is. Whole stitched faces of the red and the blue board do not read at all: the red board's chip reads as copper, and 42 percent of the blue face reads as pale.++**Not done.** Pours in the shadow of tall parts are still lost: both levels fall together there and the copper reads as laminate. Only pads reach across shadow. Seeds from metal blobs, used when no parts are placed, have no part to check a join against.++## Stitched faces: grain, levels place by place, and a face that is not read++Three stitched faces failed on 2026-11-10 after whole-board photos read: test board 1's bottom (one island with 117 of 148 joined pins), board 4's top (one "GND" holding both pads of two diodes) and board 1's top. What was found, face by face, and what changed (`locallevels.rs`, `faceread.rs`, 2026-11-11).++**Board 1 bottom: the edge test was blind.** The left half is laminate with thin tracks, the right half pours. The fit put the copper level on the laminate over the left half, as on the hub board. The edge test should have caught it and did not: the dim, soft ends of the tiles carry sensor grain whose crests join into one network, the run test keeps them, and they outnumber the copper edges, so the commonest step was 0.06 where copper over laminate steps 0.15 to 0.35. Now the commonest step is also taken among edges that go on in line (the crest is found again 6 px along, both ways, facing the same way), and when the two differ by more than one bin the straight edges are the face's edges (`without_grain`). A face where the two agree keeps every edge as before. Board 1's bottom then reads modal step 0.163 with 77 percent of its edges wholly in copper, and its levels are replaced. Board 4's bottom moved from 0.087 to 0.163 the same way.++**Each place against the edges near it (`correct`).** A face can pass as a whole and be wrong over part of itself. The shares of `edgelevels::agreement` are also counted in a 6 mm window round every place, and where a majority of the edges near a place lie wholly in one class the levels there come from the edges, blended over half a window. On the faces run so far this fired only over a fraction of a percent of the hub board's bottom.++**Under a moving light, by the side of the nearest edge (`levels_by_side`).** With the levels from window means of the edges, the bare laminate under the light's hot spot on board 1's bottom, 5 mm from any edge, read brighter than the copper level and joined three pours. Every place lies on the bright or the dark side of its nearest step edge, so its own brightness, moved half the edges' step towards the middle, is a sample of the mid level where it is; those samples are averaged over 1.5 mm. One mid level, not a level per side: with a level per side a false edge on bare laminate gave its few "bright side" places a copper level of their own and split the grain in two (first try, median separation 0.08). Edges within 0.5 mm of holes, parts and metal are left out (shadow rims), and so are places that lie along an edge's line past its end. Used only where the light moves (`FLAT_LIGHT`) and where at least 0.75 of the edges straddle their own window means (`SIDES_NEED`): on board 4's top, where 0.56 do, it turned 4 of 13 labelled places of pour into laminate.++**The scale of the line thresholds** is the 99th percentile of the response on plain board, 0.4 mm clear of parts and print, not of the whole face: fabric tape on board 1's bottom put it at 14.0 where the board reads 4.1.++**A face that cannot be read (`faceread.rs`).** Before anything is written, a face is taken out when the trace itself shows the failure: one island holds over two thirds of its joined pins, over 95 percent of it reads as copper, or ten or more and over half of its two-pad parts have both pads on one island. The copper is measured again without that face, `detect/copper-island-accounting.json` carries `unread` with the reason, `copper trace` and `check copper` say so, and the check passes: the pins on that face join nothing, which is a missing net and not a wrong one. For one build a fourth reason stood beside these: levels taken from the face's edges that under two thirds of those edges straddle. It came from board 4's top (0.56) and emptied two faces that read: board 9's bottom (0.59, islands right at 24 of 25 labelled places) and board 5's top (0.66). An edge figure cannot tell a face that reads from one that does not, so it is now a `warning` on the face in the accounting and a hint, and the reading is kept.++**Most pins of a large part on one island** (over two thirds of a part with 16 or more, the line `check copper` fails a run on) join nothing either, and are listed as `partPinsRefused`; the rest of the face stands.++**Two pads of one two-pad part on one island** join nothing (`pairs_on_one_island`), and are listed as `padPairsRefused`. The hub board's top had 18 such parts.++**Drills.** `layers trace` moved a through pad's anchor, and so its drill, to a point inside the pad's photographed outline whenever no photographed hole was matched. The anchor now stays at the hole, or at the library's place when no hole was found, and the outline is taken only if it holds that place.++**Measured** on by-eye labels made by a reader that saw photo crops only: 30 random places on plain mask and 24 random pairs per face, with "cannot tell" left out, so the counts are small. Places right, before and after: board 1 bottom 12 of 17 and 15 of 17 (bare board 3 of 8 and 8 of 8); board 1 top 17 of 21 and 20 of 21; board 4 bottom 12 of 14 and 13 of 14, pairs 10 of 13 and 12 of 13; board 4 top 15 of 18 before, not read after. Largest island's share of joined pins on board 1's bottom: 0.79 to 0.10.++**Held out** (looked at only after the above was fixed). Board 2's top, where the unchanged reader put 286 of 558 joined pins on one island (58 of the 80 pins of the processor) and 40 two-pad parts on one island: the grain test moved its modal step from 0.11 to 0.24, its levels were corrected place by place, and the largest island holds 46 of 526 pins (0.09), with the tracks beside the processor read one by one; the processor's top row of pins is still one island with the pour above it. No labels were made for board 2. Board 9's top: 11 of 20 labelled places right before and after, so nothing gained there. Board 9's bottom: see the misfire above.++**Still wrong.** Board 1's bottom: two of its three large pours are one island, because the lane between them is lost for 2 mm under the hot spot, and patches of bare laminate inside the ring of header pins and along the top edge read as copper. Board 4's top is not read at all: its gap lines are visible to the eye and the line filter finds only pieces of them. The hub board's top reads more laminate as copper beside its chip than before (copper share 0.31 to 0.41). The Driver and CM5 runs were not re-measured with these changes.+ ## The visible layers come from the photos, and how they are scored  `layers trace` writes `photo-layers.json`; `build` draws from it when it exists. `check fidelity` plots the board through KiCad's service, draws its gerbers and drill file at 40 px/mm (`gerber.rs`) and compares them with what each photo shows.@@ -127,6 +211,19 @@ Position and size then come from what each via shows. A dark circle 0.3 to 0.5 m  **Via pads.** A dome's width is the dimple over the hole and a ring's is the clearance round the pad, so neither is the pad. A via whose surroundings are laminate shows the pad itself: `domes::size_vias` takes the median brightness at each radius round the via, so a track leaving covers only a few sectors, and the half height of the fall is the pad's edge. These read as one size whatever the via was read as, and every via takes their median, to the nearest 0.05 mm because the photo cannot tell 0.48 from 0.50. The readings are in `detect/via-sizes.json`. This rests on the vias that show a fall, about a tenth of them: vias in pours are assumed to be the same. The drill cannot be seen, so `drill_mm` stays the rule's, cut down to leave a 0.1 mm ring. +**Vias as holes, spot by spot (`viasig.rs`).** Domes need light from one side. Under even light what is left of a via is its hole: a small round dark centre where the mask sinks in or the drill is open, with a pale or light pad round it where the via stands in laminate and nothing round it where it stands in a pour. On a third board (a USB hub, board 10 of the test set) the dome reader and the pit reader both refused whole faces and kept no via at all, because grain passed the pit test by the thousand and the faces were 0.3 mm off each other, so nothing lined up within 0.1 mm. `viasig` decides each spot on its own evidence instead:++- **Every dark centre is measured** (`viasig::measure`): how far the centre stands below its rim as a share of the rim (`share`), the core at half depth (size, long over short axis, whether it closes), what surrounds it by colour against the mask colour of that photo (`Tone`: the hue most coloured pixels share, so any mask colour works; pixels are mask, pale, black or another colour), whether mask follows 0.4 to 0.5 mm out, and the ring round it (a pad's edge or a clearance circle, with the share of the circle that shows it). All of it goes to `detect/via-spots.json` with the verdict and its reason, kept or dropped.+- **The faces are laid on each other through the spots themselves** (`viasig::lay`). The deepest round spots of each face vote for a shift of the bottom within 1 mm; a shift counts when it gathers four times the votes a mirrored bottom gathers. Pairs then fit a quadratic map once 40 hold it. Board 10: 62 votes against 7, 129 pairs, the bottom 0.30 mm off. The `scale` step uses the same pairs to draw the bottom again (`viasig::relay`), which leaves the faces 0.02 mm apart at the vias; before, only 13 of 127 top vias had their bottom partner within 0.1 mm and 77 within the 0.3 mm that `islands` pairs at.+- **The depth a via has is read from the pair of photos**, not set. Depth share is cut into twenty steps. A step belongs to the via band when its spots pair with the other face at five times the rate chance gives (the density of round spots on the other face times the pairing disc) and so many pairs are under 1 percent likely by chance. The band is the run of such steps with the most pairs: 0.60 and up on board 10, 0.25 to 0.65 and 0.20 to 0.60 on the CM5, whose pits are shallower and have a few deeper non-vias above them. Within the band, a spot may stand on one face alone only at steps where four in five of the spots the other face can see are paired.+- **Kept:** a spot paired with a spot on the other face, both in their bands; a spot deep enough to stand alone whose place on the other face is covered (a part, print, a pad) or shows a faint spot; and where the other face shows bare mask and no hole, only a spot with a ring of its own. A faint spot is kept when a spot that stands alone lies on the other face at the same place.+- **Dropped, each with its reason:** a core that runs on (a gap, a part's edge), not round, a speck, too wide for a via's hole, dark all round (inside a slot or a part), metal or print all round with no mask further out (the pad of a part's pin, hardware, a label), in print with no ring, no ring among parts, too shallow, and a bare other face that shows nothing.+- **Which reader counts where:** a face with one light keeps its domes as before, and holes are added where no dome stands. A flat face takes its vias from the holes. Where the faces cannot be laid on each other through their spots (the Driver: its top shows no vias), nothing is kept this way and the pit reader's whole-face test stands as before.++Measured on board 10 by eye (8 tiles of 6 mm drawn at random from the right half, both faces, 45 vias labelled from clean crops before the reader's answer was seen; rules were developed on the left half): 42 of 44 kept are labelled vias and 42 of 45 labelled vias are kept. One of the two others is a via half under a connector that the labels missed, the other sat inside a slot, which led to the dark-all-round rule; with that and two later changes (each depth step judged on its own, the stand-alone depths step by step) the same tiles give 41 of 42 and 41 of 45, which is no longer a held-out figure. The misses: a via whose hole the mask has closed (a ring with no dark centre), one at a chip's corner whose core merges with the shadow, and its other end under print. On the CM5 audit sample in the run copy, 39 of 43 kept vias the auditor confirmed are still kept, all 14 places they called wrong (8 false vias and 6 planted) are dropped, and the kept list falls from 1155 to 946. The Driver list is unchanged, 1398. Of the 42 vias only the new reader keeps on the CM5, a look at the first version's 54 found about a third to be shadows and gaps among parts: the stand-alone rule without a ring is the weak one on a crowded board.++Not read: vias tented flat that show only a ring or a small glint in the middle. The red and the blue board of the test set (7 and 8) show theirs this way, and the reader finds no shift between their faces and keeps nothing. Tried on whole faces (2026-11-10): small bright specks on plain mask, 3,381 and 1,140 on the faces of board 7 and 4,230 and 5,363 on board 8, do not line up between the faces beyond chance at any shift (50 pairs at the best shift against 42 at the 99th percentile of the others; 221 against 196), because dust, print edges and grain outnumber the glints. By eye, of 30 such candidates per board, 3 on board 7 and 2 on board 8 are vias, all on the bottom faces where a via in a track has a clearance ring round it; 7 and 4 cannot be told. No tented via is kept on either board. Thresholds say where they came from at their definitions in `viasig.rs`.+ Thresholds come from a labelled tuning sample and from overlays of the Driver photos, and say so where they are defined. Vias keep the distance the photo shows: a rule's hole spacing removed real vias in the dense fields. A pad blocks a via only on the face the via shows on, because the fields under U1, U2 and the MOSFETs run under top pads, and the old filter deleted nearly all of U1's and U2's. An open hole has no solder fillet, so it gets a smaller margin than a soldered pin.  ## Attributing every piece of copper@@ -153,13 +250,175 @@ Connectivity comes first and names second. The order is stitch, pads, vias, then  A resistor's or capacitor's end votes little, because nothing on the photo says which end is pin 1. A two-pin part with both ends on one conductor is a sliver or leak, so those contacts are dropped. `swapSuspects` lists two-pin parts whose copper disagrees with their pins. -**Order of steps.** `connect attribute` writes nets into copper.json. `check copper` and `check fidelity` read shapes, so they now record `attrib::geometry_hash`, which leaves out the fields attribution writes. Attribution no longer makes them stale, and a changed shape still does. `check mimic` and the snapshot do depend on nets, so they stay stale until rebuilt.+**Order of steps.** `connect attribute` writes nets into copper.json. `check copper` and `check fidelity` read shapes, so they now record `attrib::geometry_hash`, which leaves out the fields attribution writes. Attribution no longer makes them stale, and a changed shape still does. `check mimic` and the snapshot do depend on nets, so they stay stale until rebuilt. The sequence therefore runs attribution in the copper step, before anything is built on the copper. When it has to run again later, because pin names were added to nets.json, `auto` goes back by itself (`build`, `check mimic`, the snapshot, then on), and `next` names each of those commands in turn (`auto.rs`, `plan`).++**Nets a document names.** A net the AI supplied with tag `DATASHEET` and a `source` keeps both while the copper does not contradict it: not every pin being seen joined is no reason to call a document an inference. The copper contradicts a net when one of its pins sits on copper named for another net, or when the copper moved a pin the map had off it. Then the net becomes `INFERRED`, with a test, and `contradiction` in nets.json holds the finding: the counts, the pins moved and what the net was stated as (`statedTag`, which attribution keeps so a later run can give the tag back when the contradiction is gone). `connect attribute` lists the contradicted nets. A net tagged `INFERRED` always names a test, so `check nets` cannot fail for the want of one. With no nets.json at all, `connect attribute` starts one empty and names every net from copper.  **Checking.** `tools/connect_sample.py` draws pad-to-pad connections with a seed from the graph's hash, stratified by size of conductor and graded `solid` or `unsettled`, and renders a card per connection with no net names on it. The two ends of a resistor offered as one connection are controls. +## Hidden connections are left unrouted++The default flow no longer routes what nobody can see. Routing took most of a run (35 of 50 minutes on the CM5), added hundreds of DRC errors, and drew one way to make the connections, not what is inside the board; KiCad's count of open connections barely moved with it (499 before, about 440 after, on the CM5). So the copy stops at what the photos show.++`unrouted.rs` lists what is left. A net's pins fall into pieces: each group of pins that seen copper joins (`copperGroups`, written by `connect attribute`) and each pin no copper is seen to reach, alone. A net of g pieces has g - 1 hidden connections, each from its largest piece to one of the others. Each carries the tag of what says its ends belong together: `DATASHEET` when the net cites a document, `INFERRED` otherwise, `MEASURED` once a bench probe found it joined. `build` writes them to `unrouted.json` and leaves each as a ratsnest line in the KiCad copy. The KiCad project gets one net class per kind of evidence (`SEEN`, `HIDDEN_DATASHEET`, `HIDDEN_INFERRED`, `BENCH_MEASURED`), each with its own colour and Default's sizes, so a ratsnest line shows in KiCad what it rests on and no rule changes. KiCad 10's DRC gave the same result with and without the classes on the CM5 copy, within its own run to run variation (3413 to 3416 violations, 499 unconnected each time).++KiCad's own ratsnest count is larger than the list: it counts every piece of copper of a net that the drawn shapes do not join, with or without a pin. On the CM5 copy all 499 are pieces of GND, which meet on inner planes no photo shows; `unrouted.json` holds the 57 between groups of pins.++**The layer count** is stated by `layers state` (`layerstate.rs`), not found by routing. A count comes from outside evidence only: a document (`--source`, tag `DATASHEET`) or a measurement on the board itself (`--evidence <file> --kind edge-micrograph|cross-section|xray|ct`, tag `MEASURED`, the file held by hash). Otherwise the count is `UNKNOWN` with test T-layers. Beside it the file states the minimum the photos give:++- vias ringed on both faces with no copper leaving on either (`kind: inner`): at least 4, `MEASURED`;+- at least 10 vias that show on one face while the other face is in view there and shows no via candidate of any kind within 0.3 mm, on a board where at least 10 other vias show on both faces: more than 2 layers, `INFERRED`. It is not proof: a via plugged or tented flat on one face reads the same. The three numbers are policy, not fitted to a board;+- copper read on both faces: 2, `SEEN`.++A stated count below a proven minimum is refused. `check layers` recomputes the photos' evidence and fails if the file's differs, so the minimum cannot be typed. `connect route` still works when asked and records its stackups in layers.json; a count stated from outside evidence stays as stated, and no step requires routing.++## A net's confidence is its weakest link++Attribution grades pieces of copper. A person asks about nets: is this net proven? `netconf.rs` answers from the same files (`detect/connect-graph.json`, `detect/attribution.json`, `nets.json`, `copper.json`), reads no photo and measures nothing new. `nets draw` writes the answer to `net-confidence.json` and draws it (`netdraw.rs`).++The share of a net's copper that was seen is the wrong measure. A ground net can have 95 percent of its copper on the `copper` rung and still have pins that nothing seen joins to it. So the level is defined on the connection graph, for each net:++- **Vertices.** The net's pins, and the conductors that carry its name. A conductor is copper already stitched into one piece, so it is one vertex.+- **Links**, strongest first. `copper`: a pad joined to a conductor of the net with no gap (a solid contact; among these a bolted lug counts above a pour, a pour above a track end, a track end above vias through the pad). `via`: two conductors of the net joined through verified vias (a link of tier 2). `inferred`, in this order: one via track to track (tier 3); a pad whose edge does not settle what it touches (a weak contact); one unconfirmed via onto a pour together with the pin map (tier 4, which proves nothing by itself); the pin map alone.+- **The path set.** Links are added strongest first, and a link is kept only if it joins two things not yet joined, until every pin is in one piece. That is a spanning tree that prefers the strongest links, so between any two pins it holds the path whose weakest link is as strong as it can be. Whatever the seen links leave apart, the pin map joins: every piece but the largest hangs on one pin-map link. Links to copper with no pin beyond it are dropped, because they join no pins.+- **The level** is the rung of the weakest link in the path set. That link is reported with its place (the pad, or the via), and with how many links like it the set holds.++So a pin with no solid contact to the net's copper is a member by inference only, and one such pin makes the net `inferred`. `copper` means every pin sits on one conductor. `via` means every pin is seen joined and at least one join is a verified via. The levels agree with attribution's own tag: a net of two or more pins is `copper` or `via` exactly when nets.json tags it `MEASURED` (21 of 192 on the CM5 run, none of 77 on the Driver run, checked on both).++A net of one pin has nothing to join. It is graded by how that pin meets its copper and marked `singlePin`, and the counts keep these apart: on the CM5 run 31 of the 52 `copper` nets are single connector pins with their track.++Places where a net appears to meet another (`shorts` in net-evidence.json) are not links between its own pins. They are listed per net as `disputes`, drawn as a cross, and do not lower the level. The copper in dispute carries no name and is drawn as `conflict`.++**Drawing.** A piece of copper is drawn at the weaker of its own rung and its net's level, so copper named by inference stays `inferred` on a net whose pins are all seen. Copper with no net is drawn by its class: `group` (`UNK_G`), `none` (`UNK_ISO`, stubs and vias that join nothing), `conflict`. Each level has a hue from the Okabe-Ito set and a pattern (solid, level bands, diagonal stripes, grid, dots, dark stripes), so the levels can be told apart without colour; tracks are too thin for a pattern, so the inferred, group and conflict ones are dashed. The pattern period is ten pixels at 1080p, coarse enough to survive video compression. `attribution.jpg` shows each piece by its own rung, for comparison. A per-net picture colours the net's copper by its own rung, marks pins seen joined (filled) and pins by inference (hollow), and rings the weakest link.++**Sections** (`netsections.rs`). Ground and supply rails are told by name, a rail from a signal about a rail by its control words (`VBUS_EN` is not a rail). Signals group by a shared stem: the first part of the name, with its index kept when three or more nets share it (`HDMI0`, `HDMI1`), or a stem whose members differ by one letter of a known set (`GHA`, `GHB`, `GHC`). A net whose name places it nowhere goes to the block in blocks.json that holds most of its pins.++## Datasheet pins: a second witness for nets++The copper says what is joined. A chip's datasheet says what each pin is. Until now the AI typed nets from datasheets by hand and nothing held the two against each other. `dspins.rs` gives the datasheet a file of its own and lets the program use it.++**The file.** For each identified chip the AI fetches the public datasheet during the run and writes `reverse-flow/datasheets/<ref>.pins.json`. It cites the document by name, revision and the page of the pin table, and lists every pad of the footprint, the exposed pad too:++| Field | Meaning |+|---|---|+| `part` | the reference in parts.json (`refs`: more references, for identical parts) |+| `mpn` | the orderable part the marking and package settle |+| `document` | `name`, `revision` and `page` (of the pin table) are required; `number`, `table`, `url`, `file` when there are any |+| `pins[].pad` | the pad number on the footprint |+| `pins[].name` | the pin name as the datasheet prints it |+| `pins[].type` | `power_in`, `power_out`, `ground`, `input`, `output`, `bidirectional`, `open_drain`, `passive` or `no_connect` |+| `pins[].net` | optional: the board net the datasheet's own application circuit names for this pin, when the default name would mislead |+| `pins[].expects` | optional: what the typical application shows at the pin, as a list of `{"kind", "pin", "note"}` |++The kinds of `expects`: `capacitor_to_ground`, `capacitor_to_pin` (a bootstrap or flying capacitor; `pin` names the other pin of the same part), `tied_to_pin`, `tied_to_ground`, `resistor_to_pin` (a shunt, a divider), `resistor_to_ground`, `pull_up` (a resistor to a supply the datasheet does not name), `crystal`, `inductor`, `not_connected`, and `other` with a `note` for what none of these says. `"orTied": true` on a `capacitor_to_pin` or `resistor_to_pin` says the datasheet also allows the two pins tied together (a shunt input tied to its return when the amplifier is unused).++A worked example, the gate driver U2 of the Driver run. Its marking reads DRV8323S, and the 6 x 6 mm 40-pin package with an exposed pad is the RTA package. The pin table is Table 6-3 of TI's SLVSDJ3 revision D (March 2022), pages 8 to 10, column DRV8323S. A few of its 41 entries:++```json+{"part": "U2", "mpn": "DRV8323S in the RTA package (DRV8323SRTAR or DRV8323SRTAT, same top marking)",+ "document": {"name": "TI DRV832x 6 to 60-V Three-Phase Smart Gate Driver", "number": "SLVSDJ3", "revision": "D, March 2022", "page": 8,+              "table": "Table 6-3, Pin Functions, 40-Pin DRV8323 Devices, column DRV8323S (pages 8 to 10)", "file": "datasheets/ti-drv8323-slvsdj3.pdf"},+ "pins": [+  {"pad": "1", "name": "CPL", "type": "passive", "expects": [{"kind": "capacitor_to_pin", "pin": "CPH", "note": "47 nF, VM-rated, between CPH and CPL"}]},+  {"pad": "3", "name": "VCP", "type": "power_out", "expects": [{"kind": "capacitor_to_pin", "pin": "VM", "note": "1 uF, 25 V, between VCP and VM"}]},+  {"pad": "4", "name": "VM", "type": "power_in", "expects": [{"kind": "capacitor_to_pin", "pin": "PGND", "note": "0.1 uF and at least 10 uF local"}, {"kind": "tied_to_pin", "pin": "VDRAIN"}]},+  {"pad": "6", "name": "GHA", "type": "output"},+  {"pad": "9", "name": "SPA", "type": "input", "expects": [{"kind": "resistor_to_pin", "pin": "SNA", "note": "current shunt"}]},+  {"pad": "25", "name": "nFAULT", "type": "open_drain", "expects": [{"kind": "pull_up"}]},+  {"pad": "32", "name": "AGND", "type": "ground", "expects": [{"kind": "tied_to_pin", "pin": "PGND"}]},+  {"pad": "33", "name": "DVDD", "type": "power_out", "expects": [{"kind": "capacitor_to_pin", "pin": "AGND", "note": "1 uF, 6.3 V"}]},+  {"pad": "41", "name": "PAD", "type": "ground", "expects": [{"kind": "tied_to_ground", "note": "thermal pad, must be connected to ground"}]}+ ]}+```++**No guessing.** A chip counts as identified when parts.json tags its value (or part number) `SEEN`, `MEASURED` or `DATASHEET`: its marking was read. A pins file for any other part is refused, so a chip nobody could read gets no datasheet witness. When there is no public datasheet, or the marking fits several parts or packages, the answer is `{"part": "U5", "available": false, "reason": "..."}` in the same file, and the flow goes on without a witness for that chip. A file that cites no document, uses a type outside the list, names a pad the footprint lacks or leaves a footprint pad out is refused with the reason. The last two usually mean the footprint is not the package the datasheet draws, which is worth knowing.++**The stop.** `auto` stops once with `AUTO NEEDS AI: datasheet pins`, in the parts step after the footprints are snapped and before `check parts`, naming each identified IC or transistor of three pads or more that has neither answer. A chip whose footprint is a bare body outline with no pads (a package nobody could match, as on the CM5 run) is not asked for: there is nothing to name. A run that passed the parts step before this existed is asked in the connect step, before the schematic is drawn. `datasheet status` prints the same list at any time.++**Into the schematic.** `datasheet apply` (run by `auto`) writes each sheet into its part in parts.json: `pins`, pad to `{"name", "type"}` with the type as KiCad's (`ground` becomes `power_in`, `open_drain` `open_collector`, `no_connect` `passive`), and `pinsSource`, the citation. The schematic generator already draws a part with `pins` as a box grouped by function with its real pin names. Pins typed by hand, with no `pinsSource`, are replaced only when a sheet exists for the part; pins a sheet gave are taken back when the sheet is withdrawn.++**Onto the nets the copper proves.** Attribution names every conductor that no net of the pin map reached. Before, such a conductor took `CU_<first pin>` when two or more pins sat on it, and stayed unnamed with one. Now a conductor that a datasheet-named pin sits on takes that pin's net name, with one pin on it or many: `net` when the sheet states one, `GND` for a `ground` pin, otherwise `<ref>_<pin name>` (`U2_GHA`), so two chips never share a name unless a sheet says so. With several named pins on one conductor the name comes from ground first, then `power_out`, `power_in`, `output`, `open_drain`, `bidirectional`, `input`, `passive`. A `no_connect` pin names nothing. After attribution, `dspins::stamp` marks such a net: `nameSource` and `source` cite the document, `nameTag` is `DATASHEET`, and the net's `tag` stays `MEASURED` when every pin is seen joined in one piece and is `DATASHEET` otherwise. Nothing is joined by this: the pins on the net are the ones the copper puts there (`copperEvidence`, `copperGroups`), and two conductors that share the name `GND` are still two pieces with a hidden connection between them. Every net, named by a sheet or by somebody, also lists the datasheet pins on it in `datasheetPins`, in nets.json and in net-evidence.json. A net somebody stated keeps its name and tag. `auto` runs attribution again whenever the names the sheets give change.++### The witness: `connect witness`++`witness.rs` holds every pin of every chip that has a pins file against the copper graph and records `agree`, `disagree` or `not visible`. It runs at the end of every `connect attribute` and by itself as `connect witness`. It changes neither copper nor nets.++**What the copper shows.** A pin sits on a conductor when its pad has a solid contact with it (attribution's tier 1). Two conductors are one when verified vias join them (a link of tier 2). An unsettled pad edge, one unconfirmed via and the pin map are not seen, so they can neither agree nor disagree. What says a conductor is ground or a supply is always another pin on it, never the pin under test: a `ground` or supply pin of any pins file, or a pin with no datasheet type that somebody put on a net with a ground or supply name.++**The checks**, for a pin with a solid contact:++| What the datasheet says | Agree | Disagree |+|---|---|---|+| type `ground` | another ground pin on the same conductor | a supply pin or an output on it |+| type `power_in`, `power_out` | a capacitor from the conductor to ground copper | a ground pin on it; for `power_out`, another driving pin on it |+| type `output`, `open_drain` | the copper reaches other pins and no ground, supply or second output | ground, a supply (for `output`) or another output on it |+| type `no_connect`, `not_connected` | nothing else on the conductor | any other pin on it |+| pads of one name on one part (VDD, VSS) | on one conductor | |+| `capacitor_to_ground`, `resistor_to_ground` | such a part from the conductor to ground copper | |+| `capacitor_to_pin`, `resistor_to_pin` | such a part from the conductor to the other pin's conductor (any ground copper when that pin is a ground pin) | both pins on one conductor, unless `orTied` |+| `tied_to_pin`, `tied_to_ground` | on one conductor | `tied_to_ground` on supply copper |+| `pull_up` | a resistor to copper a supply pin sits on | the only resistor there goes to ground copper |+| `crystal`, `inductor` | such a part on the conductor | |++An `input`, `bidirectional` or `passive` pin with no `expects` has nothing copper can show, and is `not visible`. A pin's verdict is `disagree` if any check disagrees, `agree` if one agrees, else `not visible`.++**Absence is not disagreement.** An inner layer may hold the capacitor's other end or the tie the datasheet asks for, so a part or a join that is not seen is `not visible`. `disagree` is kept for what the copper positively shows wrong.++**The net list as a second pass.** The same checks run on nets.json alone: a pin "sits" on its net, a net is one conductor, and a net's name stands for its pins (unless a datasheet pin gave the net that name, which would let a pin prove itself). Here the list is a whole claim, so two pins the datasheet ties that the list keeps on two nets do disagree. This verdict is recorded apart as `netList` (`agree`, `disagree`, `not stated`). It is circular wherever somebody typed the nets from the same datasheet, so it never counts as the copper's agreement. A third pass runs on the list as it was stated before the copper moved pins (`inferredPins`): where an expectation held as stated and no longer holds because the copper moved a pin of the part involved, that is a `netList` disagreement naming the move. Either the copper there is misread, or the part somebody took for the datasheet's capacitor is not it.++**Findings.** Each disagreement is a finding in `detect/witness.json` with a picture under `evidence/witness/` and a test. The picture has two panels: the board photo close up at the pin (red), and the whole face with the pin, every pin it disagrees with (yellow) and the vias that join them (cyan). The finding says how the copper joins the pin to what it disagrees with: the same copper, or which verified vias. `evidence/witness/findings.md` lists them with every pin's verdict and reason. The test (`T-witness-<pin>`) is named on the net in nets.json, and `tests generate` writes it with the pins to probe; it goes when the finding clears. A finding never rewrites copper or nets.++**Unresolved copper.** When one piece of copper touches three or more pins of a chip without settling any, and the datasheet says pins among them cannot share a net (ground and a supply, a supply and an output, two outputs), the witness says so with a picture (`unresolved-<ref>.jpg`). That is the reader's limit at fine-pitch pads, not a claim about the board, and it is why those pins are `not visible`.++**Per net.** Each net's row in net-evidence.json gains `datasheet`: `witness` (`agree`, `disagree` or `none`), the counts, `netList`, the datasheet pins on it and its findings; nets.json gains `datasheetWitness`. `nets draw` copies the witness into net-confidence.json and into the Datasheet column of nets/index.md. It stands beside a net's level and never changes it.++**What it found.** Driver run (the board this was built on), U2 DRV8323S, 41 pins: 4 pins have a solid contact, 0 agree, 1 disagrees, 40 are not visible; 1 of 77 nets has a witness, 35 carry a datasheet pin. The disagreement: the exposed pad's copper (the bottom pour, reached through a via in the pad) is joined through 17 verified vias to a top pour holding C19.1 and D4.1, which the net list calls VIN and VIN_LOGIC, and the same bottom pour holds Q5.5, called VIN, through a via in that pad. The photo does not settle whether the vias, the bottom pour or the typed names are wrong; the test does. Against the net list: 21 agree, 2 disagree, 18 not stated; the two are CPL and CPH, whose flying capacitor the net list took to be C37 until the copper moved C37.1 onto that same pour, and the picture shows two capacitors beside those pins that are likelier candidates. Board 10 pilot run (not used while building this), U1 USB2532, 37 pins: no pin has a solid contact, all 37 are not visible, and the witness names why: one piece of copper touches 34 of the pins. So the witness is only as good as the copper at the chip's pads, and at 0.5 mm pitch that copper is mostly unresolved today.++### Nets from the parts first: `connect pinout`++The owner's order (2026-11-11): when identifying nets, identify the part, then its orientation, then its pinout. Until then the nets started from copper alone, and a pin got a net only when the copper at its pad was resolved, which at fine pitch it mostly is not. `pinnets.rs` builds the nets from what the parts are, before attribution, and attribution then holds them against the copper like any stated net.++**The chain**, per part, in `detect/pinout.json`:++| Link | Values |+|---|---|+| identity | marking read; package only |+| orientation | pin 1 mark seen; polarity mark seen; not needed (two pads and no polarity, one pad, or a USB Type-C receptacle, whose ground, VBUS and data read the same turned half round); not determinable; not settled |+| pinout | the datasheet pin table (`datasheets/<ref>.pins.json`); the standard pinout of a connector whose type is seen (USB Type-C, by the footprint's pad names, as KiCad's `Connector:USB_C_Receptacle_USB2.0_16P` names them); names typed into the part's `pins` (silkscreen beside a header's pads); package family only (a three-lead transistor, a polarised two-pad part), which gives pin names and no nets; none |++A pinout on a chip turned the wrong way is worse than none. A chip with a pin table whose `pin1.tag` is not SEEN, MEASURED or DATASHEET gives no nets, and `auto` stops once with `AUTO NEEDS AI: orientation of chips with a pinout`, naming each. The answer is the mark that was seen, or `{"mark": "<what was looked for>", "tag": "UNKNOWN"}`: not determinable, which is an answer and still gives no nets. `check pinout` fails on the same list.++**The rules.** Every pin with a known function gets a net, and its record in the net's `pinout.pins` names the rule, the source and the tag:++| Rule | What it does | Tag of the pin |+|---|---|---|+| `ground` | every ground pin of every pinout joins GND | DATASHEET from a pin table or a connector standard, INFERRED from a typed name |+| `pin function` | the pin is on a net named for it, `<ref>_<pin name>`, with the pads of the same name on the same part (the four VBUS pads of a receptacle, both D+ pads). A supply name typed beside a header pad is the rail's own name, shared by every pad that carries it | DATASHEET; INFERRED for typed names and for CC and SBU on a receptacle whose orientation is not settled |+| `datasheet tie` | `tied_to_pin` and `tied_to_ground`: the pin joins the other pin's net | DATASHEET |+| `claimed part` | a pin with `capacitor_to_ground`, `capacitor_to_pin`, `resistor_to_ground` or `resistor_to_pin` takes the nearest free capacitor or resistor within 3.5 mm of its pad, near pad on the pin's net, far pad on ground or the other pin's net. A pin with `crystal` takes the nearest crystal within 8 mm: terminals to the crystal pins by nearness, the case pads of a four-pad crystal to ground, and each terminal's nearest free capacitor as its load. Nearest claims are served first, a part serves one expectation | INFERRED |+| no connect | a `no_connect` pin goes to nets.json `noConnect` | |++Rails of different parts are not merged by rule: `U1_VDD33` and `J3_VBUS` stay apart until copper, or a name both carry, joins them. The two reaches are policy, not fitted to a board.++**Merged on copper.** Once attribution has read the copper, `auto` builds the pinout nets once more: nets with pins on one conductor (solid contacts, conductors joined by verified vias) become one net, under the name that says most (GND, then a rail name typed from silkscreen, then a supply, then the net of a driving pin). The merge is recorded in `detect/pinout.json` `mergedOnCopper` with the pins that show it. Three merges are refused and left for attribution to raise as a short, in `mergesRefused`: ground with a supply or an output; a supply with an output, or two outputs; and two differently named pins of one part, unless one is a datasheet input strapped to its own chip's ground or supply. Neighbouring pads read as joined are far more common than a real tie.++A net is DATASHEET when every pin on it is there because a document states it, and INFERRED, with a test, as soon as one pin is there by place or by a typed name. Nothing built here is SEEN or MEASURED. Pins somebody stated a net for by hand are left to them. The nets carry `fromPinout`, and `pinoutKey` in nets.json says what they were built from; `auto` builds them again when the pin tables, the orientations or the places of the parts change.++**Copper confirms or contradicts.** Attribution runs next, unchanged: a pinout net every pin of which is seen joined in one piece becomes MEASURED; pins the copper joins to it are added; a pin on copper named for another net, or a pin the copper moves, makes the net INFERRED with `contradiction` and a test. Where fine-pitch pads sit on one unresolved piece of copper, the pads stay unsettled, the pinout decides which pin is which net, and the witness lists those pins per net as `copperCouldNotSeparate`.++**Consistent with the schematic.** Two things the first runs on other boards showed. A pin the datasheet leaves unconnected that the copper joins to a net is on that net in nets.json, so after attribution its `noConnect` entry (one the pinout wrote) is removed and the net names it in `unconnectedPinsOnCopper`; before, the schematic drew a no-connect flag on a pin nets.json had on a net, and `check schematic` failed. And several pads of one name that drive (two VCAP pins of one regulator) are one output inside the chip: `datasheet apply` gives the first the driving pin type and the rest `passive`, as KiCad's own symbols do, so the ERC does not call the chip's own tie a conflict.++**Wherever the run stands.** `auto` builds the pinout nets not only on the way to attribution: a run at the review, the tests or the report whose pin tables, orientations or copper are newer than its pinout nets, or that never had any, gets `connect pinout` first, and the flow then goes back by itself. The orientation stop names each chip, its package and place, what mark to look for on that kind of package, and the crops that show it; a `pin1` that records a mark with no tag is asked for its tag.++**Shown apart.** net-confidence.json gives each net `knownFrom` (`pinout only`, `pinout, confirmed by copper`, `stated`, `copper`); nets/index.md has a Known from column; the report tables gain "Nets by how they are known"; the schematic draws what is not seen in copper dashed, as before; `bench plan` puts the probes of INFERRED pinout nets first.++**Board 10** (a USB hub; copy of the finished run): 10 nets and 22 pins on a net before; 55 nets and 119 pins after `connect pinout` and attribution. Of the 50 pinout nets, 31 are DATASHEET and 19 INFERRED; of their 102 pins, 68 are there by a document and 34 by inference (11 parts claimed by place, the CC and SBU pads). Copper added one pin to GND and one to U1_VDD33. One disagreement: the copper joins C9.2, claimed as the VDDCR12 end of that pin's capacitor, to the exposed ground pad, so either the claim took the capacitor the wrong way round or the copper there is misread; the merge is refused and listed. The 13 pin headers have no pinout: their silkscreen names were not typed into `pins` in that run.++**Board 4** (five MOSFET channels, an LM2596S, a processor module, a USB-C port; copy of a run stopped at `check mimic`, so only `connect pinout` and `connect attribute` were run): 4 nets and 53 pins before, all copper with no names; 24 nets and 87 pins after, 7 DATASHEET and 17 INFERRED (the MOSFET pins are typed from a sibling part's sheet, so INFERRED). The copper joined three source nets and the regulator's ground pin into GND (55 pins, 46 of them seen joined in one piece), and two merges were refused and raised as shorts: Q503's gate with its source pads, and the receptacle's CC2 with its ground pad.+ ## The router -`route.rs` works on a 0.05 mm grid per copper layer. Every piece of copper stamps a keep-out for the centre line of the narrowest track: the copper's own shape, plus the clearance, plus half a track. A cell holds a count, a sum and a sum of squares of the net ids stamped on it. All three agree only when every stamp is the same net, and subtracting a stamp restores the cell exactly. A wider track or a via tests a ring around the cell. This is exact, which is why the router's own copper produces almost no DRC errors.+`connect route` is optional (see above). `route.rs` works on a 0.05 mm grid per copper layer. Every piece of copper stamps a keep-out for the centre line of the narrowest track: the copper's own shape, plus the clearance, plus half a track. A cell holds a count, a sum and a sum of squares of the net ids stamped on it. All three agree only when every stamp is the same net, and subtracting a stamp restores the cell exactly. A wider track or a via tests a ring around the cell. This is exact, which is why the router's own copper produces almost no DRC errors.  Only what cannot move blocks: pads, holes, vias seen on the board and the board edge. Seen copper of another net costs extra to cross, six times its length if its net is unknown and twelve if known. Treating pours as walls left 235 of 245 connections open, because the copy tiles nearly all of both outer faces. A real via passes through a pour and the pour clears around it, and the fill here is computed after routing, so it clears too. The length that still crosses seen copper is reported, because it measures how far each stackup departs from the photo. @@ -197,7 +456,63 @@ Copper samples points rather than kept items, the claim says what the photo show  **Layers against the photos.** The owner's goal is that what the KiCad layers draw agrees with the photos wherever a photo shows that layer. `fidelity.json`, written by a separate program, limits copper, vias and placement. It is read by what its keys say and unknown fields are ignored: a figure (`score`, `agreement`, `fidelity`, `match`, `value`, `percent`, `pct`, `overall`) under a key named for the category counts, copper also takes any `*.Cu` key and placement `parts` or `footprints`; failing those, a top-level figure. The lowest figure found is the ceiling, on a 0 to 100 scale, or a fraction if every figure found is at most 1. With no file, no figure for the category, or a file older than what it scored (by the hashes in its `inputs`, `files` or `hashes`, otherwise by file time), the input is reported missing and the category is capped at 40. -**Overall is the lowest category.** An average lets 100 on placement hide 20 on connectivity, and a product falls with the number of categories instead of with what is wrong. A copy is as trustworthy as its weakest part, and the lowest category names what to fix next.+**Overall is the lowest category.** An average lets 100 on placement hide 20 on connectivity, and a product falls with the number of categories instead of with what is wrong. A copy is as trustworthy as its weakest part, and the lowest category names what to fix next. It is now reported as two figures; see the next section.++### Two scores++One overall figure was always 0: connectivity, layers and the values of unmarked parts cannot be earned from photos, so every board ended STALLED at 0 and the number showed no progress. Nothing about the formulas was wrong, so none of them changed. What changed is the grouping (`twoscores.rs`). Every ceiling is one of two kinds, and each kind has its own overall figure:++- The **photo score** is the lowest ceiling photos can earn. The review loop's target (90) applies to it.+- The **bench score** is the lowest ceiling that needs the real board or an outside document. It is printed beside the photo score with what would raise it, and the loop never waits for it.++A category's score is still the lowest of all its ceilings, so the lower of the two scores is exactly the lowest category, which is what the single overall figure was. A test holds this on every state of the fixture (`regrouped`).++| Category | Photo ceilings | Bench ceilings |+|---|---|---|+| identity | audit; identities tagged as seen | none |+| values | audit of marked parts; marked parts with a value tagged as seen | audit of unmarked parts; unmarked parts with a value tagged as seen |+| blocks | audit; parts in a block seen on the board | none |+| placement | audit; parts located by a measurement; layers against the photos | none |+| copper | audit; copper read that reached the copy; layers against the photos | none |+| vias | audit; vias not on pale pixels; layers against the photos | none |+| connectivity | audit of the joins a crop shows (islands with their share that show a join, pins as claimed); pins that agree with their copper | pins shown joined, by a crop or a bench probe; connections the photos show or outside evidence proves; copper with a net, by area and by length; connections KiCad finds closed (only on a routed copy) |+| layers | none | all of them |++A ceiling not listed as bench is a photo ceiling, so one added later counts towards the target until someone decides otherwise.++**How the two mixed categories were taken apart.**++*Values.* A photo shows a marking and nothing else of a value. The population has two strata, `marked` (the part has a marking that was read) and `unmarked`, and the old ceiling "values tagged as seen" is computed per stratum. The audit samples and bounds each stratum on its own, as it does for copper and vias. The marked stratum is the photo figure and the unmarked one the bench figure. Neither is easier than the pooled figure was: the pooled share lies between the two.++*Connectivity.* The audit's pin figure was `100 x L x u x (pins with a join / pins with a net)`. The first three factors say whether the joins put forward are right, which a crop can show. The last says how many pins show no join at all: connector pins named by a pinout, pads under their part, pads joined through a via. Those need the board. So the photo ceiling is the lower of the island figure, unchanged with its own share, and `100 x L x u` for pins. The bench ceiling is the old pin figure in full, plus the pins a recorded bench probe found joined: `100 x (L x u x pins with a join + pins probed) / pins with a net`. With no probe recorded the lower of the two is the old figure. With no finished audit the two caps are `min(40, 100 x islands with a join / islands)` and `min(40, 100 x pins with a join / pins with a net) + 100 x pins probed / pins with a net`, whose lower value is the old cap. An island stays a photo matter because it is copper the photo shows: one that joins nothing was misread or its via was missed.++Copper with a net is a bench ceiling because a name reaches a piece of copper only through a pin it is seen joined to. Copper that reaches no pin on the outer faces, a track between two vias for instance, gets its net from a probe or an X-ray. "Connections KiCad finds closed" judges a routed copy and exists only when `connect route` was run.++**A run that never routed.** "Connections the photos show" is `1 - hidden / (pins - nets)`, with the hidden connections counted from nets.json (see "Hidden connections are left unrouted"), plus those a recorded bench probe found joined. On a routed copy the router's own count stands in for `hidden`, capped at `pins - nets`: the router also counts copper islands that reach no pin, and uncapped that printed a negative number of seen connections. For layers, an `UNKNOWN` count scores 0, because nothing is stated to be confident in, and a count stated from outside evidence sits under the cap of 30 until an independent verdict with that evidence attached raises it, as before.++**What the loop does with them.** TARGET MET, STALLED and the rise of 0.1 are all read on photo figures. A pass recorded before the two scores holds no photo figure, so the first pass after it counts as a rise and the next one can stall. A category waits for an audit only when an audit is what holds its photo figure, and a category with nothing to audit (a board with no vias) waits for none. `confidence`, `review pass`, `check review` and `report` print both scores; report.md carries the line with both, and may not claim a confidence above the higher of them.++**What the photo score still cannot reach.** It is the lowest of seven categories, and each has a ceiling an honest run can leave low. Blocks reach the target only when silkscreen or a marked part with its datasheet backs each block, since a function is otherwise an inference. Identity needs every part read from its crop, and the default is `UNKNOWN`. The copper and connectivity audits count `cannot_tell` against the score, and under parts, silk and glare a crop often cannot tell. Vias and placement are capped at 40 when `fidelity.json` has no figure for them. So a careful run on good photos can raise the photo score, and a thin one leaves it at 0, as the CM5 run does (blocks 0.0, identity 4.6): the number now moves with the work, but it does not say the copy is right inside the board. That is the bench score.++### The bench passback++`bench plan` and `bench ingest` (`bench.rs`, docs/bench.md) are optional and come after the run is complete. The plan orders one continuity probe per hidden connection by how many inferred or unknown items a `joined` answer settles: the connection, every pin of the far piece, and the net itself when it is that net's last hidden connection. Each probe names two pads, the easiest to touch of each piece (a plated hole or test point before a connector pin before a small pad; never a pad under its own part), and has a picture: for each pad a close view with a ring on it and the whole face with a cross, the bottom face turned back to how the person holding the board sees it. After the probes come the unmarked parts whose value is not known, the layer count, and the other measurements tests.json calls for.++`bench ingest --file <results.json>` records each answer in `bench/measured.json` with who measured, when, with what, and the hash of the file it came in, which is kept in `bench/ingested/`. A probe found `joined` makes its connection `MEASURED`; a net whose every hidden connection is joined is tagged `MEASURED` in nets.json. A measured value is written to the part with `fieldTags.value: MEASURED` and where it came from. A layer count with its method becomes the layer statement. Nothing else is touched, and the scores move because `confidence` computes them again: the two pads of a joined probe count as pins shown joined, the connection counts as proven, the part counts as a value tagged as seen. An `open` answer is recorded and flagged, and upgrades nothing. An answer for a probe that is not in the plan, or whose connection is no longer in nets.json as planned, is refused with the reason. `connect attribute` writes net tags again from the copper, so it stamps the recorded answers back afterwards. Ingest changes artifacts, so the checks that read them go stale; `auto` runs them again.++### `auto` and `next`++`auto` runs every program-only step in order, each as the flow's own command, and ends with one of three lines: `AUTO NEEDS AI: <input>` (exit 2) with hints that name the file or command wanted, `AUTO COMPLETE` (exit 0), or `AUTO FAILED` (exit 1). `next` prints the same next action without running it. Both come from one function (`auto::plan`) that reads the files as they stand. The AI's inputs it stops for are: the scale (`scale --body` or `scale --holes`), parts.json after `parts init`, blocks.json, whatever a failed check lists, the independent audit verdicts a review pass waits for (each sheet, sample and the exact command), and report.md with its line. Pin names in nets.json are welcome at any time and never waited for. `layers trace` and `check fidelity` feed the review and are not gates: if they fail the flow goes on and the review counts the missing figure against the score. A command that passed and is still next ends the run with AUTO FAILED instead of looping. `tests generate` drops a generated test whose items are all gone, so `check tests` cannot fail on a test nobody wrote.++### The run video++`video build` (run by `auto` once every step is done) makes a video of about two minutes from the run folder. It is not a step of the flow file and gates nothing: when it fails, `auto` records the reason, ends with `AUTO COMPLETE: every step is done; the video was NOT built: <reason>`, and does not try again until the report passes on changed files.++- **Who computes what.** The program writes `reverse-flow/video/work/facts.json`: both scores as the files stand with the ceiling that limits each category, the review's verdict, each step's state, the count of hidden connections, every part's outline from the footprint it was built from, and whether `fidelity.json` still matches the copper and parts it scored. The tool (`tools/video/`, Python) reads everything else straight from the run's files. Each figure shown or spoken goes through one function that records its source; `video.json` lists them per scene.+- **The scenes** are fixed (`scenes.py`, `SEQUENCE`): title, photos, outline and scale, parts, blocks, copper and vias, the KiCad copy, nets, schematic, scores, tests, end card. Each scene checks its own inputs and is left out with a stated reason when one is missing. A scene that raises an error is left out the same way; the build goes on.+- **Pictures.** The camera moves over the run's own pictures: the photos, `board/top.jpg` and `bottom.jpg`, `evidence/outline-*.jpg`, `evidence/fidelity-F_Cu.jpg`, `nets/confidence-<face>.jpg` (cut to its board panel by the layout `nets draw` uses, and checked against the picture's size), `nets/net/<name>.jpg`, `schematic/*.png`. Boxes on parts, block outlines, copper, dimension lines and the test list are drawn at build time from `parts.json`, `blocks.json`, `copper.json`, `scale.json` and `tests.json`. KiCad's picture of the copy is `kicad-cli pcb render` of the board file, cut to the board and laid over the photo by its outline; when the two outlines differ in proportion by more than 2 percent (a part overhangs the edge) it is not used. With no `nets/` folder the copper is coloured by `netEvidence.rung` from `copper.json`.+- **Time.** Each narration line is one take of the voice, so a caption starts where its line starts. Lines marked as required are always spoken; the others carry an importance and are added, most important first, while the total stays under 125 seconds. If the total is under 118 seconds the scenes are held longer in proportion to their weight, none more than doubled. A video outside 110 to 130 seconds is still written and `video.json` says why.+- **Frames** are a pure function of time, made by two processes side by side, each feeding its own ffmpeg (H.264, rate capped at 3.5 Mbit/s); the two parts are joined without encoding again and the voice is added as AAC.  ### The audit @@ -276,6 +591,72 @@ A pass is full when no category below the target is held by an audit that is mis  The snapshot step records `copper.json` and `parts.json` by hash, and goes stale when either changes, which holds the later steps back in `steps`, `next` and the report. Run again, it keeps the old copy as `<name>.old<n>`. +## The schematic++The first schematic was one box per part on an A0 sheet, a label on every pin, no wires, and a no-connect flag on every pin with no net. Nobody could read it, and the flags were false: a no-connect says a pin is unused, when the truth was that its net was not known.++`sch.rs` now draws from the same four files as the board (`parts.json`, `blocks.json`, `nets.json`, `net-evidence.json`), so the two cannot disagree. How it lays a block out:++- **Anchors and satellites.** A part with three or more pins, or a connector, is an anchor. A two-pin part with a standard shape, or a one-pin part, is a satellite. Each satellite is given to one anchor pin on a net it shares: a signal net before a rail, an IC before a connector, then the nearest pad on the board. So a decoupling capacitor lands on the supply pin it sits beside, which is the owner's rule.+- **Strips.** Each anchor pin starts a strip straight out from the pin. Satellites to a rail hang from it (down to ground, up to a supply), one satellite in series sits in line and the strip goes on along its far net, so a resistor, an LED and the rail read as one run. A net with pins elsewhere ends in a label at the end of its wire with the text running back along it; a net that leaves the sheet gets a hierarchical label and a pin on the root sheet.+- **Pitch.** A box opens its rows until every strip has room. A library symbol keeps its own pitch, so a strip with parts on it starts beyond the labels of the pins around it; when that would take it too far the pin keeps a label and its parts are drawn beside the symbol from the same label. Supply pins on the top edge of a library symbol share one rail wire above it with their capacitors in a row.+- **What is left.** Satellites no anchor took are drawn as runs from a label or a rail symbol, and single parts with nothing known in rows, each end showing its rail or the `?` marker.+- **Sheets.** Groups are packed onto the smallest paper that takes the block (A4, then A3, then more A3 sheets).++Evidence comes from the copper attribution: a wire is solid when `net-evidence.json` says the pad it leads to joins that net's copper, dashed otherwise. A `?` marker is a small symbol whose reference starts with `#`: KiCad leaves it out of the netlist and the BOM, and the pin it sits on is not reported as open.++Three things check it. At every build the generator works out the connections of the sheets it drew from their geometry and compares them with `nets.json`. `check schematic` runs KiCad's ERC and compares the netlist KiCad itself exports with `nets.json` pin by pin. And the sheet rules in `schcheck.rs` are written apart from the generator, from the owner's design process (labels on wires, titled blocks, no decoupling-only block, text sizes, nothing over a pin or a body).++The schematic has its own place in the sequence `next` and `auto` follow: in the connect step, after the nets are attributed and checked and the layer statement is checked, `auto` draws the sheets whenever parts.json, blocks.json, nets.json or net-evidence.json are newer than them, then runs `check schematic`. The check records those files and every sheet by hash, so a later edit makes it stale and the flow comes back to it before the report. A failed check stops `auto` with the list of what is wrong. `report` then lists the sheets in the tables and writes one PDF and a picture of each sheet under `reverse-flow/schematic/`, so the report and the video show them without KiCad.++Two KiCad behaviours cost time and are worth knowing. A project file with no `schematic.drawing` section makes every wire and pin a hairline, so `rules.rs` writes one and the wires carry their own width. And the ERC calls a PWR_FLAG wired only to a power symbol an open pin when both are power symbols, so the flag here is an ordinary symbol with a power output pin.+ ## Why not AI Flow's router  The first version of this flow called `adom-aiflow route`. It routes signals only on the outer two layers, so more layers could not close anything. It also does not treat seen pours as occupied: on the Driver board, 495 of 505 new DRC errors at 4 layers involved a zone, with vias placed straight through phase pours. The timeline page is still AI Flow's.++## Photo intake: sorting an unlabelled set and stitching each face++`init --photos` takes every photo of a board with no labels. `stitch` (run by `prep`) turns them into one image per face. The work is in `tools/stitch.py` (Python with OpenCV, shipped inside the binary and run by it; `src/intake.rs` records the call in the ledger and hands the mosaics on as the run's photos). Its thresholds were set on the eleven test boards of 2026-11-10 and say so where they are defined.++**Which photos belong together.** Every photo is matched against every other with SIFT features at three sizes (long edge 1600, 800 and 400 px), so that a close-up shrunk to 400 meets a phone overview at 1600. A similarity is fitted by RANSAC. Four kinds of false fit turned up on the test photos, and each has its own guard:++- The table under two photos matches itself when the camera did not move. A fit that leaves the frame where it was is set aside and the rest fitted again.+- A fit with a scale outside 0.1 to 10 is not a pair of photos (the false ones were 0.05 to 0.07).+- Rows of holes and repeated parts look the same from both faces, so a tile can match a tile of the other face with 20 to 30 features. Two overviews that do not match each other are two faces, and no pair may join their groups. Two overviews count as matching only when they match each other about as well as tiles match them: on board 6, where terminal blocks hang over the edges and show from both faces, the two overviews matched with 42 features and each matched its own tiles with 100 to 470.+- A pair with few features counts only when a strong pair through one of its photos has the same scale and the same turn.++Photos that match show one face; within a face, a photo more than 1.5 times coarser than the finest is an overview.++**Tiles the matching loses.** A bare bottom face, or a board with the same circuit six times over, defeats the ratio test: every feature has twins. A tile that matched its overview weakly or not at all is placed by a vote instead. The pixel size and turn are known from the face's other tiles, so only the shift is open, and every feature votes for it with its six nearest look-alikes. The true place collects the twins that fit plus everything that exists once (a corner, a hole, a label), so it is the highest peak; it is taken when it leads the next best place by a quarter. On board 5 this moved a tile that RANSAC had put one period of the port circuit away.++**Which face is the top.** The face with more of its mask-coloured area under part bodies, measured on the finished mosaics. A connector hanging over the edge is seen from both faces and does not count. `stitch.json` gives both figures and says when they are close; then a person or the AI looks, and `stitch --top <photo>` names it. Of the eight test boards with two full faces the rule was clear on five, close and right on two (boards 6 and 7), and close and wrong on one (board 2, whose bottom carries many small parts).++**When the photos do not say which face.** Two groups of photos that share nothing are taken as the two faces, and `stitch.json` says that this is an assumption. With three or more such groups a group may be another part of the same face: then only the largest is built, as the top, and the rest are listed as unplaced.++**Registration.** Tiles are registered to each other, not to the overview. For each pair of tiles the overview (or their own coarse match) says where they overlap; features are then matched at full resolution inside that area only, with each feature's candidates limited to within about 60 px of the predicted place. When a pair's own match and the overview disagree by more than 150 px the overview is followed: that is a repeated circuit matched one period off. Features are taken on mask-coloured surface first (the colour is measured in each photo: the peak of the hue histogram among saturated pixels near the centre of the frame), because the top of a part stands above the board and moves with the camera.++A pair of tiles is linked only when ten or more of its features agree within 2 px, and five times more than chance would line up inside the search radius. With hundreds of candidates a loose fit always finds a dozen: on board 5's bottom "24 of 1239 agree" held 6 at 2 px and implied a scale of 1.09.++One similarity per tile is then solved for all tiles at once by least squares, with matches more than 2.5 px from the joint solution dropped. Added to that:++- *A camera correction shared by the tiles of a face*: a radial term and a tilt, found by pattern search on the capped mean residual, kept only when it gains 5 percent. On board 4 the tilt took the tiles' fitted scales from 0.986 to 1.000 down to 0.997 to 1.001 and the median feature residual from 3.6 to 2.2 px. The radial term alone did nothing there. The camera was not looking exactly straight down, and what looked like tiles of different sizes was that.+- *The overview as a weak prior*: each tile's place according to the overview enters with a tenth of the weight of a feature. It decides only what the features leave open.+- *The overview as a referee*: a tile that fewer than 40 tight features put more than 80 px from where the overview has it loses its links.+- *Tiles with no link* are placed where the overview has them, as rigid copies at scale 1, and the overview's homography is fitted again with them in, six times over. That is what brings out the overview's perspective when only one tile carried the first fit: on board 8's bottom, where no pair of tiles could be linked, the first fit gave the other tiles scales of 1.04 and the repeated fit 1.000.++After that the placement is solved once more with cells of surface from the overlaps added. The first pass also takes cells with a weaker peak and a larger shift when the cells of a pair agree with each other on it, which pulls in a tile the overview placed to within 10 or 20 px. A pass is kept only if the cells then agree better.++**The overlap figure, and two traps.** How far the tiles disagree is measured by phase correlation of 96 px cells where both tiles show board surface. Three things matter. The cells are blurred by 1.5 px for the measurement: unblurred, pixel noise decides the peak and the same cells read 2 to 6 px instead of 0.7. Only cells with a clear peak count (response 0.3 or more), since below that the shift found grows as the response falls, which is the measurement failing. And a cell must have detail in two directions: a cell of parallel tracks can slide along them freely, and on board 3's top such cells read a median of 4.9 px on joins that are clean by eye.++The first trap: OpenCV 5.0.0's `phaseCorrelate` writes its window into the arrays it is given. With overlapping cells cut from one array, later cells are read from data already multiplied by the same window in both images, and they agree near zero shift whatever the tiles do. Every cell is passed as a copy, and the cells do not overlap. The second: the placement is fitted in these same overlaps, so every other cell is held out of the fit and `stitch.json` gives the held-out figures separately. On a made-up board whose tiles are exact similarities (the test fixture) the figure reads 0.08 px.++**The mosaic.** The frame is the reference tile's own pixels, so nothing is scaled. Each tile is resampled once, through one map that combines the camera correction and its similarity (Lanczos). Nothing is sharpened. One source is chosen for every place: the start is the tile whose centre is nearest, and a graph cut (at 1/8 size, on colour difference) then moves each join to where the two tiles look the same, which is board surface, so joins run round parts rather than through them. The join is feathered over about 12 px.++**Gain.** Each tile gets a smooth gain per channel toward the overview's tone, measured on board surface over about 3 mm; without an overview, one gain per tile so that neighbours agree. That is not enough at the joins: a tile's own light (the ring light's glare, the falloff to its edge) changes faster than a smooth gain follows, and what is left is a step in tone along the join that reads like the edge of a pour. So at each join both tiles are also brought to their common tone on board surface (their ratio smoothed over 32 px, fading out about 200 px from the join). The tiles are registered to a fraction of a pixel, so that ratio carries no detail from one tile into the other.++**What is not covered.** Where no tile shows the board, the overview fills in, at its own much lower resolution and with one gain for all channels; `stitch.json` lists those areas as low resolution. The overview is only used inside the board outline, which is cut out of the overview by GrabCut seeded with the surface the tiles show. Its table is left out because a phone's table can have nearly the mask's hue, and `prep` would take it for board. Where nothing covers the canvas it stays black and is listed as not seen. The overview is mapped by one homography; where it fills in, a join between tile and overview can step by the "overview fit" figure in `stitch.json` (8 to 30 px on the test boards).++**Thin sets.** A face with one photo is that photo, copied byte for byte, with a note of every side on which the board runs out of the frame. Photos that match nothing are listed under `unplaced` and left out. Nothing is drawn that no photo shows.++**Boards that are not green.** The stitcher measures the mask colour per photo and works on red and blue boards. `prep` looks for a green board; when it finds none it takes the board box that `stitch.json` records for the mosaic and says so in `prep.json` (`outlineFrom`). What `prep` and the later steps read inside that outline still assumes a green mask.
docs/status.md+321−26
@@ -1,5 +1,7 @@ # Status +Verified on one board: the Driver motor controller (Driver 1 to 4 photos, 2026-11-04), rules `kicad-default`. A second board, the Compute Module 5, was run as far as `check fidelity` from public photos, in its own section below. Nothing here has been checked against a real netlist, stackup or X-ray.+ ## 0.4.0 (2026-11-10)  Second board: a Raspberry Pi Compute Module 5 from ten photos, run to the end of the flow ([runs/cm5-run6](../runs/cm5-run6/README.md)). Overall confidence 0.0 of 100, STALLED. What changed in the program is listed in the README. The Driver sections below describe 0.3.0; the Driver has not been run to completion or audited on 0.4.0.@@ -22,20 +24,19 @@ Verified on one board only: the Driver motor controller (Driver 1 to 4 photos), | Measure | Result | |---|---| | Parts placed | 137, 103 by their own pads on the photo |-| Copper read | 828 pours and 2,588 tracks on both faces, every island accounted for in `detect/copper-island-accounting.json` |-| Vias kept | 1,329, all read on the bottom photo |-| Pieces into conductors | 1,782 pieces into 1,418 conductors, by 461 stitches |-| Copper on a net | No net: 7.6% of area, 46.5% of track length, 1,261 of 1,782 pieces |-| Tests | 119, covering 2,538 open items |+| Copper traced | Read pixel by pixel on both faces, every island accounted for in `detect/copper-island-accounting.json` |+| Vias kept | 1317, all read on the bottom photo. Held-out sample of 220 labelled by eye: precision 0.97, recall 0.92 (before: 0.40, 0.38). A fresh 150 of the kept list: 141 real, 4 false, 5 cannot tell |+| Copy's own DRC, unrouted | 146 errors: 62 courtyard overlaps, 59 clearance, 30 edge, 3 solder mask bridge, 1 hole in a courtyard | -| Layers | Open | New DRC errors | Missing per KiCad | Vias added | Track mm | Crossing seen copper, mm |+| Layers | Open of 249 | New DRC errors | Missing per KiCad | Vias added | Track mm | Crossing seen copper, mm | |---|---|---|---|---|---|---|-| 2 | 47 of 267 | 718 | 358 | 239 | 1676 | 454 |-| 4 | 43 of 253 | 475 | 340 | 229 | 1593 | 384 |-| 6 (proposed) | 45 of 253 | 594 | 441 | 150 | 989 | 11 |-| 8 | 45 of 253 | 602 | 452 | 139 | 945 | 8 |+| 4 | 54 | 17 | 84 | 210 | 1002 | 327 |+| 6 (proposed) | 34 | 6 | 76 | 230 | 1165 | 24 |+| 8 | 29 | 9 | 69 | 215 | 1187 | 30 |++Routing the same stackup again varies: 6 layers left 25 to 34 open across runs. -Crossing of seen copper is what picks 6. Open counts do not separate the stackups. Finer copper made routing worse than version 0.2 by DRC and KiCad's count: at 6 layers 0.2 had 34 of 249 open, 6 new DRC errors and 76 missing, because it had far less copper to clear and join.+With the JLC 4-layer capabilities (0.127 mm track and clearance, 0.45/0.2 mm via), the same copy leaves 30 open at 4 layers and 2 at 6, crossing 385 mm and 39 mm. The open connections at default rules are almost all QFN fan-outs, so they are mainly a rules limit. KiCad's shared service checks only its default rules, so that run's DRC counts do not apply: its gate needs a native `kicad-cli`.  ## Checked against the real design @@ -92,20 +93,20 @@ Measured by `connect attribute` at commit `cdc9d4f`, against the loop 2 start. A  ## Confidence on that board -Computed by `review pass` from audits and measurements, target 90. Overall is the lowest category: **0.0, STALLED**. Five auditors who did not produce the run judged random crops, with false claims planted in each sample.+Computed by `confidence` from the 0.2.0 files, target 90. Overall is the lowest category: **21.4**. No target met: `check review` fails.  | Category | Score | What limits it | |---|---|---|-| identity | 93.9 | Audit: all 42 of 42 correct, 5 planted claims rejected |-| vias | 62.9 | Audit: 48 of 52 kept vias and 26 of 35 dropped candidates judged correct |-| blocks | 61.5 | Audit: 31 of 42 correct. The first sample was voided when a planted claim was called correct, and a new auditor redid it |-| placement | 55.6 | Layers against the photos. The audit gave 40 of 42 |-| values | 34.3 | 47 of 137 stated values tagged as seen; unmarked ceramics cannot be read from a photo |-| layers | 30.0 | Capped without outside evidence such as an edge view, X-ray or the stackup |-| copper | 9.4 | Audit: the auditor could not tell copper from bare board through smooth mask on 79 of 116 crops |-| connectivity | 0.0 | KiCad finds 441 missing against 338 expected connections at the proposed stackup |+| identity | 40.0 | no audit done |+| values | 34.3 | 47 of 137 stated values tagged as seen |+| blocks | 40.0 | no audit done |+| placement | 40.0 | no audit done; no `fidelity.json` |+| copper | 40.0 | no audit done; no `fidelity.json` |+| vias | 40.0 | no `fidelity.json`; audit bound 41.7 |+| connectivity | 21.4 | 68 of 317 connections joined by seen copper |+| layers | 30.0 | photos never show an inner layer | -The copper score mostly measures what a crop can show, and it disagrees with the answer key's copper match above. The audit method needs changing before that score means much either way. Photos alone cannot reach 90: values, layers and nets need measurements on the real board.+The via audit is one auditor judging 114 crops by eye (seed 179b5abec06c3269), with all 11 planted claims rejected: 28 of 51 kept vias real, 22 false, 1 unclear, and 7 of 52 dropped candidates real, 2 unclear.  ## The visible layers against the photos @@ -157,13 +158,177 @@ KiCad's plot against the copper as read, over visible area: F.Cu 0.970 to 0.972,  **Not yet right.** A through pad has one outline for both faces, so the face it was not taken from is off by about 0.1 mm. QFN pins smaller than the metal test stay library shapes. Photographed pads reach traced copper that the library shapes cleared, which adds DRC errors. The X1, X2 and X3 labels read as metal and one LED reads as silk: a test that freed the labels turned more part ends into silk, so it was reverted. Silk on the bare bottom still takes a few copper-edge glints. +## The same code on the Driver board++Rerun from `copper prep` through `check fidelity` on a fresh copy of the loop 2 run, against that run's `copper.json` and `fidelity.json`. A rerun with the unchanged binary matched them exactly, so these differences are the code.++| Measure | Before | After | Change |+|---|---|---|---|+| Vias | 1329 | 1329 | 0 |+| Pours | 828 | 834 | +0.72% |+| Tracks | 2588 | 2546 | -1.62% |+| F.Cu held-out IoU | 0.823 | 0.825 | +0.24% |+| B.Cu held-out IoU | 0.873 | 0.873 | 0 |+| Silk, metal, mask and holes, both faces, held-out IoU | | | 0 |++All of the track change is on F.Cu (1754 to 1712; B.Cu is unchanged at 834). Pale neutral things wider than 1.2 mm are now opaque on a face with parts, so copper no longer runs through them: 53 of the 96 tracks that went and 43 of the 54 that came touch one of those bodies. Everything else takes its Driver value: the mask tone factors are 1, the light spread is over the line on both faces, the sharpening test finds none, and 684 top pits are rejected because 3 line up with the bottom against 9 by chance.++## A second board: Raspberry Pi Compute Module 5, from public photos++Run on the everypin pair only, 24 px/mm, framed through the four mounting holes (55 x 40 mm, 48 x 33 mm spacing, from the datasheet). No schematic, layout or test-point table was read, and nothing has been checked against one. See `method.md` for what changed and why.++| Stage | What it produced | Believable? |+|---|---|---|+| prep | every hole found once gold-only, flip undecided by holes | Yes |+| scale | 23.93 x 23.64 px/mm top, bottom on top at 0.021 mm RMS (0.046 leaving each of 21 tiles out) | Yes, by eye: outline and holes land on the frame |+| parts detect | 135 candidates, 58 match a size | By eye: ICs, the lid, the can, inductors and connectors are found. Many candidates are print, and most small passives are missed |+| copper prep, domes | 358 top and 755 bottom pits, 232 on both faces | Yes for pits in plain mask, see below |+| copper regions | 100 and 142 regions | Evidence only. The older region reader was not tuned for this photo |+| copper trace | 684 pours, 1254 tracks, 1229 mm, 767 vias; 3814 of 4759 islands not kept, mostly crumbs | Pours and their gaps yes. Tracks on clear mask yes, but in pieces: only 13 percent are over 2 mm, holding 687 mm of it. Under large print, and near parts, mostly no |+| layers trace | 204 and 427 silk polygons, 4 holes, 368 pads shaped | Silk and holes yes. Pads less so |+| build | 648 pads, 684 zones, 2 layers, bottom parts flipped | Written; only 58 parts, unnamed |+| check copper | passes with `--skip-earlier`, 1 inner via | Shapes valid. The one inner via proves nothing about layer count |+| check fidelity | below 0.8 on copper, metal and mask | See below |++| Layer | IoU, held-out tiles | Boundary median / p95, mm | Excluded mm2 of 2200 |+|---|---|---|---|+| F.Cu | 0.718 | 0.067 / 0.93 | 1355 |+| B.Cu | 0.763 | 0.033 / 0.90 | 956 |+| F.SilkS | 0.985 | 0 / 0.025 | 834 |+| B.SilkS | 0.983 | 0 / 0.025 | 286 |+| F exposed metal | 0.686 | 0.083 / 10.7 | 815 |+| B exposed metal | 0.673 | 0.025 / 2.2 | 267 |+| F.Mask opening | 0.711 | 0.20 / 10.8 | 815 |+| B.Mask opening | 0.690 | 0.025 / 2.2 | 267 |+| F holes | 0.821 | 0.025 / 9.8 | 895 |+| B holes | 0.904 | 0.025 / 8.95 | 329 |++**Those copper figures are the weakest kind.** The scorer's split comes from the same two green modes as the reader's, so it cannot show a wrong level, and it blurs the thinnest tracks away, scoring them as drawn but not seen. There is no second photo of this unit. Silk is self-agreement as on the Driver. The one independent check is the other photo set, read by the same program: everypin against CNX on held-out tiles gives 0.49 on F.Cu and 0.50 on B.Cu, and most of that gap is CNX, which reads badly (below). It neither confirms nor refutes the 0.72 and 0.76.++**Vias, held out.** Bars were set on tile set A. On tile set B, 128 of 191 top pits have a bottom pit within 0.1 mm, against 0 and 0 with the bottom mirrored either way. By eye on 6 tiles drawn after the method was frozen: 34 of 39 marked are real (0.87, Wilson lower 95 percent 0.73), and 34 of 37 real ones are marked (0.92, lower 0.79). The 5 false ones lie beside print, a part's end or a test pad's rim. An earlier draw of 6 tiles, 32 of 42, is what led to the pale-ring bar, so it is not counted. Pit widths form one population, 0.14 to 0.20 mm. Of 17 spots under 0.11 mm none lies on both faces, so if there are microvias these photos do not show them. A via with a part or heavy print round it is not read, and one under a part cannot be seen on that face.++**What these photos cannot give.** Connectivity, at the SoC, the BGAs and the fine-pitch fan-outs. No named net, no layer count. Inner layers are never seen, and the SoC's lid hides its whole neighbourhood.++**Smallest features at this resolution**, measured across lines on the original pixels. Everypin: thin tracks are 0.065 to 0.09 mm wide at half height, 1.6 to 2.1 photo pixels, which is the blur and not the track, so their width cannot be read. They stand 30 to 49 grey levels above laminate on a grain of 2.4. Parallel lines read reliably down to a 0.25 mm pitch (height 20 to 34), and at 0.15 to 0.2 mm (height 15 to 17) they read in places. Under 0.15 mm the height is grain (10). A gap between pours reads at 0.185 mm, 4.4 pixels, 17 percent deep. Print edges are 1.2 pixels wide. CNX at 31 px/mm: edges 3.1 pixels (0.10 mm), tracks 0.10 to 0.13 mm at half height and only 6 to 11 grey levels high, and lines thin out under a 0.2 mm pitch. CNX reads F.Cu 0.41 and B.Cu 0.33, and a 0.25 mm shift does not lower B.Cu, so copper on that set is a failure, left as it is.++**What it would take.** The Driver reads well with tracks and gaps at 5 to 9 pixels. For features of about 0.09 mm that is 55 to 100 px/mm with real optical resolution: an estimate from these two boards, not a test. 100 px/mm is 5500 pixels across this board. No sharpening: it digs false gaps.++## A third board: parts on board 10, and the same reader on the Driver and the CM5++Board 10 is a small green USB hub board, 38 x 39 mm, photographed at 76.8 px/mm. Its top face was labelled by eye from 3x tiles with a 0.5 mm grid: 97 parts, 67 of them chip parts. The left half (57 parts) was used to build the method, the right half (40 parts, 27 chips) was labelled after the method was frozen and scored once. A candidate locates a part when its centre lies within the larger of 0.45 mm and 0.3 of the part's short side. Sizes were read from 10x crops by the chip's own width: every "0805" of the first run on this board is an 0603 with wide fillets.++| Right half of board 10 (held out) | Before | After, all candidates | After, rated 0.5 or more |+|---|---|---|---|+| Candidates | 138 | 142 | 48 |+| Candidates that locate a real part | 45 (33%) | 47 (33%) | 30 (63%) |+| Real parts with a candidate, of 40 | 29 (73%) | 32 (80%) | 23 (58%) |+| Chips located, of 27 | 18 | 21 | 15 |+| Chips located with the right size | 2 | 7 | 7 |++Only the capacitors are sized. None of the 14 resistors on that half is read body first; they are located by fragments of the older detector.++`parts snap` on the same half, 26 capacitors and resistors given at the labelled place: given their true size, 20 kept it before (6 shrunk) and 26 now; given one size too large, 23 came out right before and 23 now; given one size too small, 8 before and 9 now.++| Chip passives read body first | Before | After |+|---|---|---|+| Driver top, 90 chips in the run's parts list | 19 found, 15 on a chip | 51 found, 41 on a chip, 34 at the list's size. By eye on the contact sheet about 9 are invented, 7 of them rated under 0.5 |+| Driver, all candidates | 668, 45 chips located, 20 right size | 640, 56 located, 37 right size. Resistors: 4 of 32 both times |+| CM5 top, 24 chips | 29 found, 24 located, 22 at the list's size | 36 found, 24 located, 15 at the list's size |+| CM5 bottom, 193 chips | 195 found, 193 located, 145 at the list's size | 203 found, 193 located, 145 at the list's size |++The CM5's parts list was built from the old reader's output, so its "before" is 100 percent by construction, and a different size on the top face is a disagreement with that list, not a measured error. Rerunning `parts snap` from the CM5 run's pre-snap state ends 212 of 217 chips at the run's final size (142 with the old rule), and 32 of 40 audited-ok placements unchanged (27); on the Driver 80 of 85 (79) and 23 of 37 (23).++A region of board 4 (five MOSFET channels, stitched close-ups, resampled to about 40 px/mm by eye) shows no capacitor and about 20 dark resistors: nothing is read body first there, the levels fall back to the scaled numbers, and the resistors are found only as unsized dark bodies.++## The flow's shape since 0.5.0++Routing is optional, there are two scores, `auto` runs the program's steps, and a bench passback exists (docs/method.md: "Hidden connections are left unrouted", "Two scores", "The bench passback", "`auto` and `next`").++Tested on copies of two finished runs, not on a new board end to end:++- **CM5 copy.** From `connect measure` through `report` without routing: `auto` took 69 seconds from the snapshot to the report and stopped once, for report.md. Attribution run again gave a byte-identical copper.json, so every audit stayed valid. Before: overall 0.0. After: photo score 0.0 (blocks 0.0 and identity 4.6, because that run tagged every block INFERRED and read 12 of 258 identities), bench score 0.0 (none of 217 unmarked values measured). By category the photo figures are 4.6, 16.2, 0.0, 54.2, 39.1, 40.0 and 37.5; connectivity as a whole went from 0.0 to 18.6 and layers from 28.8 to 30.0. 57 hidden connections are listed; the count of 10 layers stays as stated by its maker and the photos alone infer at least 4.+- **Driver copy** (made with an older version). Before: overall 0.0. After `layers state`: photo score 40.0, held by three audits that are stale or were never drawn; bench score 0.0, because without routing the layer count is UNKNOWN.+- **Tracing the copper again** on the CM5 copy does not reproduce the audited copper (857 vias against 883), so a pass from `copper trace` needs new independent audits; `auto` drew them and stopped for the verdicts.++Not done or still wrong:++- No board has been run from `init` with `auto`. The stops for the scale, parts.json and blocks.json are covered by unit tests of the plan only.+- KiCad shows more ratsnest lines than `unrouted.json` lists: 499 on the CM5 copy against 57. KiCad counts every piece of a net's copper that the drawn shapes do not join, and all 499 are pieces of GND that meet on inner planes. The list counts groups of pins. Nothing reconciles the two yet.+- The bench plan picks pads by a fixed order of preference, not by anything measured about access, and was never tried on a real board. Its made-up results file was ingested on a scratch copy only.+- A bench value is taken as typed: the program cannot tell an in-circuit reading from a true one.+- The photo score on a run with thin AI inputs is still 0.++## A run that finishes when a reader fails (2026-11-11)++Seven boards went through `auto` on 2026-11-10 and four stopped for good at `check copper`, with no review, tests, report, bench plan or video. The check was right each time, and nothing a runner may type changes a reader's result. The flow now carries such a failure instead of stopping on it (`src/notread.rs`):++- A face whose copper cannot be read is NOT READ. The reader says so itself at trace time where it can (`faceread`: the face's islands are taken out, `detect/copper-island-accounting.json` carries `unread` with why); what only `check copper` finds (one island with most of a large part's pins) is recorded by the check. Both end in the same record, `checks/copper.ok` `summary.notRead`, and the check answers that it recorded a failure, not that it passed.+- A face no photo shows is NOT PHOTOGRAPHED: `connect measure` and `connect attribute` work on the face that exists.+- What follows is the same for all three: attribution takes no join from that face, so no net is claimed through it; the copper and connectivity scores carry a ceiling "faces whose copper was read" (one of two faces: 50) and no audit sample is drawn on that face; the review lists a finding; `tests generate` writes `T-copper-not-read-<side>`; `report` refuses a report.md without the sentence; the net pictures leave that copper out and print a band over the face; the schematic's root sheet and the video say it; every stop of `auto` repeats it under "Carried as failed, not waiting for you".+- Proven on a copy of test board 1 (bottom: one island with 102 of 130 pins): `auto` went from `check copper` through build, the mimic check, the snapshot, nets, layers and the schematic check to the first review pass and stopped at "AUTO NEEDS AI: independent audit verdicts".++Not done: with one face only, `layers trace`, `check fidelity` and the copper audit still need both board images, so they are skipped or capped and say so; the KiCad copy still holds whatever the reader drew on a face the check failed.++Two more stops of the same family, handled the same way:++- `check mimic` "zones with no fill". The program fills read pours itself, and exposed metal no footprint explains (PHOTO1.M) was subtracted as the box round its outline: on test board 4 that box wiped an 8 mm2 island whole. A read pour is no longer cut by it. A pour that other pads still cover whole (board 6: two islands of 4 and 5 mm2 inside one pad and its clearance) is recorded by `build` in `build-left-out.json` (`noFill`, with area and what covers it); `check mimic` passes and lists it as a finding, and the review lists it. `build` also keeps one fill mask per layer, not one per pour: 1201 pours on board 11 had taken 6 GB.+- `check schematic` ERC errors. An error of type pin_to_pin or pin_not_driven whose pins the copper as read joined on one net is a finding (`src/ercfind.rs`: pins, net, copper pieces, a continuity test `T-erc-<pins>`), carried by a connectivity ceiling "nets the electrical rules accept". Any other ERC error, and one on a net somebody stated, still fails. Board 6: 7 such errors on 5 of 29 nets, ceiling 82.7.++Proven by runs on copies, each to "AUTO NEEDS AI: independent audit verdicts": board 1 (bottom NOT READ), board 4, board 6, board 11 (one face, the other NOT PHOTOGRAPHED).++Other faults of the unprompted sequence fixed the same day: `parts snap` runs once after the AI completes parts.json even when `check parts` was run by hand; the joins of pads to copper are read again when a part moves, turns or changes footprint; `review pass` waits for earlier steps as every check does; a restitch (`stitch --top`) sends the plan back to `prep`; `build` names a part whose footprint did not load (`build-left-out.json`) and a new footprint file under `reverse-flow/lib/fp/` makes the build run again; a position with no part fitted is not asked for datasheet pins; keep-out rule areas are not "zones with no fill"; an auditor may correct their own verdict, with a note, while the sample is not complete; a size is stated as the photographed window's when a side of the outline is the photo's edge (`src/window.rs`); and `auto` gives a reason when a step was killed or died on an allocation.++## The datasheet witness (2026-11-10)++`datasheets/<ref>.pins.json`, the stop in `auto`, pin names into the schematic and onto proven copper, and `connect witness` are described in `method.md`, "Datasheet pins". What it gave on the two runs it was tried on:++| Run | Chip, pins | Pins with a solid contact | Agree | Disagree | Not visible | Nets with a witness | Against the net list |+|---|---|---|---|---|---|---|---|+| Driver (built on this run) | U2 DRV8323S, 41 | 4 | 0 | 1 | 40 | 1 of 77 (35 carry a datasheet pin) | 21 agree, 2 disagree, 18 not stated |+| Driver, nets.json removed, so nets from copper alone | U2, 41 | 4 | 0 | 0 | 41 | 0 of 18; 4 nets took their name from a U2 pin | 0, 0, 41 |+| Board 10 pilot (held out) | U1 USB2532, 37 | 0 | 0 | 0 | 37 | 0 of 3 | 0, 0, 37 |++- **It is only as good as the copper at the chip's pads.** At 0.5 mm pitch the reader joins the tracks leaving a QFN into one piece, attribution leaves those pads unsettled, and the witness has nothing to stand on. It says so with a picture (`unresolved-<ref>.jpg`): 34 of the USB2532's pins on one piece on board 10.+- **The STM32G4 on the Driver has no witness.** Its marking was not read (the family is from silkscreen), so no pins file is accepted for it.+- **The three Driver findings were looked at in the pictures, not settled.** The exposed pad's pour joined to pins the net list calls VIN: either the 17 vias, the bottom pour or the typed net names are wrong, and the photo does not say which. CPL and CPH: the capacitor the net list took for the flying capacitor (C37) sits 7 mm away on a pour, and two capacitors sit beside the pins, so the typed net is the likelier error.+- **`expects` checks parts, not values.** A 47 nF capacitor and a 10 uF one are the same to it.++## The run video++`video build` makes the two minute video described in the README, and `auto` runs it after the report. Tried on copies of two finished runs and on scratch runs that stop after the photos, not on a board run from `init` to the end:++| Run | Video | Scenes | Voice | Build |+|---|---|---|---|---|+| CM5 copy, through `auto` | 123.8 s, 1920x1080 at 30 fps, H.264 and AAC, 44.5 MB | 12 of 12 | adom-tts, 21 lines spoken, 19 left out for time | 70 s |+| Driver copy (older run: no `nets/` folder, no schematic pictures, two steps not done) | 123.8 s, 44.9 MB | 11 of 12: no schematic scene; the nets scene coloured the copper by rung itself; the end card says which steps are not done | adom-tts, 21 lines spoken, 11 left out | 81 s |+| Scratch run made by `init` alone on a stitched photo pair | 118 s | 4 of 12 (title, photos with the join maps, scores, end) | none: built with captions only, with adom-tts pointed at a missing program | 74 s |+| Scratch run of board 4 after `init --photos` and `stitch` | 118 s | 4 of 12, the photo scene with all ten photos and both join maps | none (`--no-voice`) | about 80 s |++What was checked: `ffprobe` on each file, one frame from the middle of every shot of both copies read by eye, the captions, and `video.json` against the files. `auto` with the Python interpreter pointed at a missing program ended `AUTO COMPLETE: every step is done; the video was NOT built: ...` with exit 0, `steps` showed `fail  video`, and a second `auto` did not try again.++Not done or still wrong:++- Nobody has watched the videos play. The frames, timings and sound files were checked; the motion and the voice against the picture were not.+- The voice fits about 25 short lines in two minutes. On the CM5 copy 19 template lines were left out for time, among them the scale's source and the bench plan; they are on the panels and in `video.json`.+- KiCad's own picture of the copy is lined up with the photo by the board's outline (the rows and columns that are at least 60 percent solid). That holds for a rectangular board, also with a connector hanging over an edge (the Driver). A board that is not a rectangle fails the proportion check and the scene falls back to the program's overlay.+- The board panel of `nets/confidence-<face>.jpg` is cut out by the layout `nets draw` uses today (margins of 12 and 108 units). The tool checks the picture's size against that layout and draws the copper by rung itself when it does not match, so a change there costs the program's own picture, not the scene.+- The photo scene for a run started with `init --photos` (the photos as they came, then the join maps) was tried on board 4 after `init --photos` and `stitch` only: ten photos shown, both faces with their source maps, 0.41 and 0.39 pixels of overlap disagreement read from `stitch.json`. No run that was started that way has reached the report yet, so the full video on such a run is untried.+- A scene's wording is fixed English templates. The only free text is `video-notes.json`, which is spoken as written and not checked against the run.+- The fonts are the system's DejaVu Sans when present, else Pillow's built-in font; the look with the built-in font was not checked.+ ## Known gaps +- **Schematic: nets between two ICs or connectors of one block go by label.** A strip runs from a pin to its passives; it does not run to another IC or connector. Transistors that share a node are wired as a column (a half-bridge reads as one), but three connectors on one bus each carry the bus names, and an IC and the header beside it meet by label. `check schematic` counts these as label hops (a warning).+- **Schematic: no notes on the sheet.** The owner's rules ask for the reason for each value beside the part. The run holds a `function` and a `why` for most parts and nets; they are in the Description field, not drawn.+- **Schematic: a block sheet may be mostly empty.** One sheet per block, and a block that is a little too tall for A4 takes a whole A3.+- **Nets by confidence (`nets draw`).** On the Driver run all 77 nets are `inferred` by their weakest link, though 74 percent of the copper area is on the `copper` rung: 336 of 415 pins have no solid contact to their net's copper. On the CM5 run 52 of 192 nets are `copper` (21 of two or more pins, 31 of one pin) and 140 `inferred`. No net is `via` on either run: where verified vias join two conductors of a net, some pin of that net is still unseen. The levels are only as good as attribution, and a level says nothing about whether the name is right. The page nets/view.html was checked by running its script against stubs of the browser, not in a browser.++- **No stackup closes every connection at default rules.**+- **Vias as holes (2026-11-10, `viasig.rs`).** On the USB hub (board 10) the readers kept no via; now 127 on the top and 152 on the bottom, each with its reason in `detect/via-spots.json`. Held-out by eye on 8 tiles: precision 42 of 44 (0.95, Wilson 0.85 to 0.99), recall 42 of 45 (0.93, 0.82 to 0.98). `scale` now lays the bottom on the top through 104 vias both faces show (leave-one-out 0.025 mm RMS; the hole fit left 0.30 mm). CM5: 1155 kept before, 946 now; of the audited places, 39 of 43 real vias still kept, 14 of 14 wrong ones dropped. Driver unchanged. Still wrong: vias whose hole the mask has closed are not read (ring or glint only: all of the red and the blue test boards, and single vias on board 10); a spot kept on one face alone with no ring is weak evidence among parts (CM5: roughly a third of the newly kept ones looked false); cross-face pairing assumes through vias, so on a board with blind vias a real via on a bare face with nothing on the other is dropped unless it has a ring; all sizes at 40 px/mm. - **Some false vias remain, and faint specks are unresolved.** About 3 percent of kept vias are false: mainly rims of soldered pins and track edges read as rings. Tiny faint specks are kept or dropped inconsistently and could not be told apart by eye. Counting every kept one as false lowers held-out precision to 0.87.-- **No stackup closes every connection at default rules**, and many errors at 6 layers come from the finer copper.-- **Seven pins were moved by the copper and not reviewed by hand:** C19.1, C26.2, C37.1, Q1.5, Q3.5, Q5.5 and R25.1. Treat `pinsMoved` as things to look at.-- **No inner vias.** The photos prove no minimum layer count, so routing starts from 2 layers.+- **No inner vias.** The photos prove no minimum layer count, so routing now starts from 2 layers. The figures in the layer table below predate the new vias. - **Scale has one source.** The body check only picks the pitch standard (41 px/mm against 46.6), so the number rests on the hole rows alone.+- **The published snapshot is out of date.** It predates the final copper. The flow now shows a snapshot stale when copper changes; the published run has not been re-snapshotted. - **Pad connections on the top face are still weak.** The bottom photo shows plated pins' rings well. Fine-pitch pins on the top, whose pads the body hides, settle little. - **Vias on a boundary.** 98 vias sit where two conductors are both reachable. They join neither and are listed in `viasOnBoundary`. - **Attribution still rests on inferred pin names for most pins.** Connections the copper does not support are in `unsupported` in net-evidence.json. 129 are listed, nearly all pads with a gap closed around them and no track seen leaving, which does not rule out a track hidden under the part.@@ -178,11 +343,141 @@ KiCad's plot against the copper as read, over visible area: F.Cu 0.970 to 0.972, - **Over-splitting.** The reader leans towards splitting, so pours in dome fields and tracks in grain are often several items. Each still gets its own net later; none of the splits is a short. - **Copper around top-face parts is the weakest reading.** Tall parts cast shadows that neither reader can see through, and two neighbouring pads can read as joined. Small holes on the top grew from 10.5 to 15.3 mm2 in the held-out tiles. +- **False vias.** Up to 338 of the 1028 kept vias land on pale pixels (a 0.35 mm patch more than a quarter pale), mostly silkscreen letters and lines. They feed net assignment and block the router. The via filter does not reject silkscreen yet.+- **Inner vias are not proof.** The 3 read as leading only inward look like ordinary domes in a stitching field, so the photos prove no minimum layer count.+- **The saved snapshot is out of date.** The last one predates the final copper.+- **Bottom's upper-left rails are largely missing** from the copy. The photo is darker there and the islands fragment.+- **Signal bundles on the bottom** come out mostly as gaps in the pour, not separate tracks. Tracks of unknown net shorter than 0.8 mm are dropped, 637 fragments in all.+- **Pad connections are weak evidence.** The ring test does not reliably tell a joined plated pad from an isolated one on these photos. 99 pins disagree with the copper they appear to touch, and 4 net pairs meet at vias.+- **827 mm² of pour and 94 mm of track have no net.** - **Routed copper is one way to make the connections**, not what is inside the board. Inner layers are never seen. - **Tracks do not yet claim copper of unknown net.** The router pays to cross it where it could be following it. - **KiCad refilling zones is untested.** Outlines now carry a pour's holes, but the service cannot refill. DRC counts barely change between the measured zone clearance and the rule's (510 against 506 clearance errors), which suggests the net class's 0.2 mm still applies, so a refill would probably cut narrow gaps back.-- **Only tested on one board.** Thresholds came from these photos.+- **Tested on two boards, read well on one.** Most thresholds came from the Driver photos; the CM5 section above says what moved and what is left.  ## Before making it public -Verify on a second board, change the copper audit so a crop can settle it, audit the scorer's pin handling, and decide the rules question above.+Done in 0.5.0: eleven more boards were run and seven of them graded against their real designs (last section). No X-ray was used. The rules question above is still open.++## Photo intake on the eleven test boards (2026-11-10)++`init --photos` and `stitch` were run on every photo of each test board with no labels. The figures are from `stitch.json` of each run; the mosaics, reports and evidence pictures are in `RE_Flow_Test/mosaics/board<NN>/`. The method was developed on these same boards, so the figures are not a held-out test of the method. What is held out is within each face: the overlap figures in the last column are on cells the placement was not fitted on. The joins of boards 1 to 8 were looked at as full-size crops (`<face>-seams.jpg`, twelve per face) on 12 of the 16 faces; not looked at in their final form: board 2 top, board 3 bottom, board 6 bottom, board 7 bottom.++| Board | Face | Tiles | Close-ups % | Overview fill % | Cells | Median px | 95th px | Held-out median / 95th px | `prep` outline |+|---|---|---|---|---|---|---|---|---|---|+| 1 | top | 3 | 87.4 | 12.6 | 45 | 0.37 | 2.38 | 0.34 / 2.39 | found |+| 1 | bottom | 3 | 85.2 | 14.8 | 10 | 1.60 | 13.67 | 1.12 / 1.64 | found |+| 2 | top | 5 | 82.3 | 17.8 | 543 | 0.49 | 2.93 | 0.47 / 3.10 | found |+| 2 | bottom | 4 | 76.4 | 23.6 | 49 | 0.42 | 1.98 | 0.44 / 1.16 | found |+| 3 | top | 4 | 97.2 | 2.8 | 60 | 1.36 | 7.09 | 1.31 / 7.22 | found, but it stops at the metal connectors |+| 3 | bottom | 4 | 98.8 | 1.3 | 657 | 0.43 | 1.23 | 0.43 / 1.22 | found |+| 4 | top | 4 | 97.6 | 2.4 | 285 | 0.41 | 3.69 | 0.42 / 3.65 | found |+| 4 | bottom | 4 | 92.0 | 8.0 | 577 | 0.40 | 2.65 | 0.38 / 2.69 | found |+| 5 | top | 4 | 93.9 | 6.1 | 356 | 0.46 | 1.76 | 0.52 / 2.02 | found, but it stops at the row of terminal blocks |+| 5 | bottom | 4 | 94.4 | 5.6 | 139 | 0.40 | 0.97 | 0.40 / 0.91 | found |+| 6 | top | 4 | 92.7 | 7.3 | 898 | 0.34 | 0.89 | 0.34 / 0.93 | found |+| 6 | bottom | 4 | 99.4 | 0.6 | 458 | 0.39 | 0.95 | 0.40 / 0.94 | found |+| 7 (red) | top | 4 | 92.0 | 8.0 | 88 | 0.38 | 2.89 | 0.38 / 1.28 | not by `prep`'s green test; taken from `stitch.json` |+| 7 (red) | bottom | 4 | 90.5 | 9.5 | 68 | 0.38 | 1.04 | 0.32 / 1.05 | not by `prep`'s green test; taken from `stitch.json` |+| 8 (blue) | top | 4 | 92.5 | 7.5 | 203 | 0.40 | 1.24 | 0.38 / 1.11 | not by `prep`'s green test; taken from `stitch.json` |+| 8 (blue) | bottom | 4 | 92.0 | 8.1 | 19 | 0.55 | 5.69 | 0.55 / 6.35 | not by `prep`'s green test; taken from `stitch.json` |+| 9 | top, bottom | 1 each | 100 of what the photo shows | 0 | 0 | none | none | none | found; the board runs out of both photos left and right |+| 10 | top, bottom | 1 each | 100 | 0 | 0 | none | none | none | found |+| 11 | top only | 3 phone photos | 99.6 | 0 | 70 | 1.38 | 4.62 | 1.58 / 9.10 | found |++Pixels per mm are not settled here. `prep` lists candidates near 52 to 53 px/mm on boards 1, 2 and 4, where 0.4 px is about 0.008 mm; `scale` decides.++**What the figures do and do not show.**++- Nothing is "not seen" inside the board outline on boards 1 to 8. The overview fill, 1 to 24 percent of a face, is real board at a quarter to a sixth of the tiles' resolution, and a track crossing from a tile into it can step sideways by the overview fit (8 to 20 px on these boards). Board 2 has the most: its close-ups leave the middle of both faces uncovered.+- Three faces rest on few cells: board 1 bottom (10), board 8 bottom (19) and board 2 bottom (49). Those faces are bare mask with little a feature can hold: no pair of tiles could be linked on board 8's bottom, and two of three on board 1's bottom, so the overview placed those tiles and surface cells in the overlaps then refined them. Their joins are good to a few pixels, not to the half pixel of the others.+- Board 3's top reads 1.4 px at the median on 60 cells: tall parts leave little board surface in the overlaps. The joins looked at are clean.+- Board 11 has no overview and five hand-held phone photos. Three overlap and make the top; the other two match nothing and are listed as unplaced, since the photos cannot say whether they are another part of the top or the other face. The tiles are placed by similarities although a hand-held phone gives perspective; the 1.4 px is what that costs.+- Boards 9 and 10 have one close-up per face and nothing to stitch. That the two photos are the two faces is an assumption, and `stitch.json` says so (`facesAssumed`).++**What the crops show that the figures do not.**++- Between two tiles, tracks, pads and print run across the joins without a step on every face looked at.+- Board 4 bottom has grainy, brighter bands 50 to 150 px wide along three joins on bare mask (`bottom-seams.jpg`, crops 5, 7 and 12). The geometry agrees there and the tone does not: a thin strip of one tile's edge lies inside another's area and the join gain did not bring it level. A copper reader could take such a band for the edge of a pour. Not fixed.+- Where the overview fills in, the patch is plainly softer and its tone is off (darker on boards 4 and 8, pinker on board 7, greyer on boards 2 and 5), with a hard edge against the tiles. On board 6 top a part at such an edge shows twice, a few pixels apart.+- Board 1 bottom and board 5 bottom keep a visible step in tone at one join each.++**The top.** Clear and right on boards 1, 3, 4, 5, 8, 9 and 10. Close and right on board 7. Close on board 6, where both faces are populated. Close and wrong on board 2: its bottom carries many small parts and scores 0.049 against the top's 0.043. The delivered board 2 was stitched again with `stitch --top Board2.11.jpeg` after looking at both mosaics; `stitch-automatic-top.json` next to it is the automatic report.++**Known gaps.**++- `prep` assumes a green mask. On the red and blue boards it takes the board box from `stitch.json` and says so; that box includes connectors that hang over the edge (board 8 top: 93.0 mm against 86.0 mm on the bottom), so the faces lie 1 to 2 mm off each other. Copper is read through a red or blue mask (`maskread.rs`, docs/method.md): good on the red bottom and the blue bottom, weak on the blue top, and fine tracks on the red top are not separated. Tented vias are not read: no via is kept on boards 7 and 8. Holes and bodies in `prep` and `detect` still use green thresholds.+- `prep`'s own outline stops where parts cover the board edge (board 3 top, board 5 top). `stitch.json` has a board box cut from the overview for each face (`boardBoxPx`), which includes parts hanging over the edge; nothing uses it on green boards yet.+- The overview is mapped by one homography. A fill region is therefore out by the overview fit at its border with the tiles.+- A face with no overview and tiles that do not overlap cannot be assembled: each group stays separate.+- The registration needs Python with OpenCV on the machine. The binary reports a clear error when it is missing.+- OpenCV 5.0.0's `phaseCorrelate` changes its inputs in place. The earlier one-board stitcher (`CM5_RE/tools/stitch.py`) cuts overlapping cells from one array, so its overlap figures were read from data already windowed and are too good; they should be measured again before they are quoted.++## Laying the faces on each other, the outline, and `reframe` (2026-11-11)++**What changed.** `prep` no longer assumes that the outlines of the two photos coincide (`src/facereg.rs`, `src/outline.rs`).++- The board mask is the photo's own: within 30 degrees of its hue peak, above 0.8 of the 2nd percentile of its own value, and only where 60 percent of the pixels round a pixel pass. The grain of a shadow under a board's edge and of a blue table no longer counts as board.+- Round pads, rings, test points and solder joints are found on the whole photo of each face. Both ways of turning the board over are tried, and the bottom-to-top map is the similarity most point pairs agree on, then an affine fit, then a homography, in photo pixels. No outline enters it. Six matches with two outline edges agreeing, or twelve, register the faces. Below that the large openings are tried, then the outlines themselves (an edge with solder mask beyond it is not used); the last is reported as WEAK and cannot say the flip.+- Both faces get one outline: the two masks are laid together through the fit and the outline is fitted to their union, because parts over an edge hide the board on one face only. Both photos are straightened through it. Sides that run along the photo's own edge are recorded as `notBoardEdge`.+- `scale` lays the bottom through that fit (the bottom photo's corners become where the top's lie in it) and records `alignment.faces`. `check prep` fails when neither holes nor vias hold the faces, unless the flip was stated, and then it warns.++**Measured on copies of the test runs** (matched points, residual of those points after the fit, and how far the same points lay apart with each face on its own outline):++| Board | Flip | Matched (other way) | Residual mm RMS / worst | On own outlines mm RMS / worst | Outline mm (before) |+|---|---|---|---|---|---|+| 4, stitched | y, found | 28 (8) | 0.137 / 0.195 | 0.243 / 0.400 | 101.92 x 69.46 (top 101.66 x 70.39, bottom box 2.3 mm too tall) |+| 5, stitched | not found; y stated | 0: through the outlines | none | none | 102.71 x 70.35 (102.56 x 53.07) |+| 9, cut left and right | x, found | 73 (24) | 0.027 / 0.098 | 2.023 / 4.474 | 44.16 x 26.14, left and right not the board's edge |+| 10, single photos | x, found | 37 (15) | 0.060 / 0.142 | 0.104 / 0.212 | 37.95 x 37.89 (38.12 x 39.04) |++Board 10's via relay then pairs 128 vias (104 before) at the same 0.025 mm on pairs left out of its fit.++**`reframe`** moves a run made before this onto the new frame without typing anything again: the scale, the flip, every part of parts.json, the candidate ids and the dismissals are carried through the photo each was read on, and what was derived from the old frame is set aside for `auto` to make again. `reframe --flip x|y` states the flip when the old run never settled it and the photos cannot say.++**Known gaps.**++- Thresholds in `facereg.rs` and `outline.rs` are in photo pixels read at 65 to 77 px/mm (the 2026-11-10 close-up sets), and say so where they are defined. A photo at a very different scale needs them scaled.+- A square board photographed with one face turned a quarter turn is not tried: only the two flips are.+- On board 5 nothing that goes through the board is found on both faces (its mounting holes touch the board's edge and the pads test wants a pad ringed by mask), so it rests on the outlines and a stated flip.+- The old rulers' end points were not recorded, so `reframe` carries the scale as the mean over the old straightened top and prints the range (0.5 percent on board 4, 1.9 percent on board 5, whose old picture was sheared). `scale` records the ends from now on.+- The via relay did not pair on board 4 after the new fit: it wants 40 via pairs for its quadratic and this board has about 21 vias in all. The faces there rest on the 28 matched pads; `copper prep` then reads 21 vias on the reframed copy where the run before read 7.+- Board 6 (both faces populated, terminal blocks over both long edges, a header over a third) is NOT registered and says so. Its pads test finds nothing shared but two mounting holes of one size; a third is read at half its size on the top, and two holes alone are accepted only when no other pair of openings fits another way, which is not the case here. The outlines disagree with the holes, so the outline fit is refused too (an earlier build claimed it, with a bottom-to-top scale of 1.04 and holes 1.5 to 3.6 mm apart). The run keeps its old frame, 98.65 x 61.44 mm, which takes the terminal blocks as board: their green is the mask's hue and value, and nothing here separates them.+- A fit through large openings must agree with the openings' own diameters within 8 percent, which ties its scale to something measured. A scale away from 1 is not refused by itself: the two mosaics of board 5 differ by 5 percent in pixels per mm and register rightly.++## The eleven test boards at the end of the build-and-test run (2026-11-11)++Every board went through `auto` to "AUTO COMPLETE" with its copy, schematic, net pictures, tests, report, bench plan and two minute video. No run reached the 90 percent target, and none is a usable copy of its board: the photo score is the lowest category, and connectivity, copper or blocks is near zero on every board. The runs are kept by the maintainer and are not part of this page: several of the boards are unpublished designs.++| Board | Real layers | Layers the run stated | Parts | Nets | Pads on a net | Vias read | Review | Graded against the real design (totals only) |+|---|---|---|---|---|---|---|---|---|+| 1 Control board, 160 x 45 mm | 4 | unknown, at least 2 | 83 | 51 | 124 | 0 | 0.0 STALLED | parts 66% found, 89% right, 0.24 mm; copper overlap 48% top, 54% bottom; pin pairs 23% right, 22% found; vias 0 of 134 |+| 2 Battery monitor evaluation board | not known | unknown, at least 4 (inferred) | 316 | 140 | 158 | 287 | 1.2 STOPPED | no board file found |+| 3 Raspberry Pi 5 | not published | unknown, at least 4 (inferred) | 98 | 34 | 65 | 577 | 0.0 STALLED | no public layout |+| 4 MOSFET driver board with a microcontroller module | 2 | unknown, at least 2 | 101 | 24 | 87 | 21 | 0.0 STOPPED | parts 95% found, 87% right, 0.23 mm; copper 36% top, 57% bottom; pin pairs 24% right, 13% found; vias 33% found, 90% right (the design file is one revision newer than the board) |+| 5 Automotive Ethernet switch | 4 | unknown, at least 2 | 242 | 177 | 317 | 0 | 0.0 STALLED | parts 96% found, 96% right, 0.27 mm; copper 16% top, 11% bottom; pin pairs 30% right, 4% found; vias 0 of 314 |+| 6 Sequent Microsystems 16 inputs HAT | not published | unknown, at least 2 | 354 | 130 | 309 | 0 | 0.0 STOPPED | no public layout found |+| 7 TI MSP-EXP430F5529LP (red mask) | 2 | unknown, at least 1 | 111 | 169 | 210 | 0 | 0.0 STALLED | TI's Eagle board through KiCad's import: parts 70% found, 78% right, 0.44 mm; copper 19% top, 3% bottom; pin pairs 39% right, 3% found; vias 0 of 444 |+| 8 Earth People Technology MAX V board (blue mask) | not published | unknown, at least 1 | 139 | 140 | 208 | 0 | 0.0 STALLED | no public layout found |+| 9 Battery charger board, two partial photos | 4 | unknown, at least 2 | 86 | 9 | 45 | 0 | 0.0 STALLED | placement list only: parts 67% found, 84% right, 0.12 mm |+| 10 USB 2.0 hub board | 4 | unknown, at least 2 | 97 | 10 | 22 | 154 | 4.1 STALLED | parts 75% found, 99% right, 0.08 mm; vias 90% found, 99% right; copper 29% top, 65% bottom; 20 of 279 pins on a net (119 with nets built from the pinouts, pin pairs 87% right, 7% found) |+| 11 Control board, one face, five partial photos | 6 | unknown, at least 1 | 158 | 238 | 350 | 0 | 0.6 STOPPED | parts 35% found, 67% right, 0.73 mm; copper 8% top; pin pairs 23% right, 1% found; vias 0 of 640 |++Layers: no run stated a layer count. Each stated a minimum from what the photos show, and no minimum was above the real count. Of the seven boards whose stackup is known (two of 2 layers, four of 4, one of 6), the minimum equals the real count on one, the 2-layer board 4. Inner layers leave nothing in a photo of either face unless a via ends inside the board, and no run saw one.++What the grading says: part positions are the strong result (0.08 to 0.44 mm on whole-board runs, and most parts found where the person running the flow read the board by eye). The nets are the weak one: at most a quarter of the real pin pairs are found, and of the pairs a run states, a quarter to two fifths are right, except on board 10 with nets built from the pinouts. Vias are read on green boards with open vias only. The layer count was never stated.++Changes made in the last hours, each from a board that could not finish:+- `check review` accepts a stop that carries its reason (`review pass --max-passes <n> --note "<why>"`), and `auto` keeps that stop when later steps change files. Before, a copper sample voided by a planted claim was drawn again, larger, without end (boards 3 and 4: five voided samples), and a scored sample was extended every time.+- An extension nobody judged no longer lifts an audited category to the unaudited cap: the judged part, being the first items of the same random order, is scored as the smaller sample it is (board 11: connectivity audited at 0.6 read 40).+- An ERC error among pins the copper joined inside a net built from the pinouts is a finding with a test, not a stop (board 6).++Open at the end:+- The copper audit cannot be judged on stitched photos: auditors who answer from the look of the mask accept planted claims, and an auditor who answers only what the crop settles returns "cannot tell" on 84 to 107 of 116 points. The crop needs the evidence that settles a point (edge map, both exposures, a wider view), or the category needs another measure.+- The part finder proposes few whole parts: on most boards the parts were entered by the person running the flow from the photos. The video still says "parts found".+- Copper is merged into large areas on stitched faces (one ground group of 145 pins on board 6), so nets from copper are few and many are wrong; mask-covered vias are not read; red and blue masks give no vias.+- The schematic is a pin list wherever the nets are weak: correct symbols and pin names, labels ending in "?", passives unconnected.+- Faces registered through the outline only on boards 5, 7 and 8, and not at all on board 6.+- The video's figures follow the files, but its wording overclaims in places ("joined by seen copper", "marking read" counts entries that say none).+- Native photo resolution is not built.+- `auto` on a run made by an older build rebuilds the pinout nets, which can stale its audit samples.
flows/flow.json+9−8
@@ -1,6 +1,6 @@ {   "name": "reverse",-  "description": "Reverse engineer a PCB from photos. The program measures (board outline, straightening, scale from hole pitches and part bodies, part bodies and packages, hole rows, every island of copper on both faces, vias, what each pad and via joins), generates the KiCad copy with the photographed pours and tracks, routes what cannot be seen on its own N-layer router, jumps the layer count until every connection closes and the DRC passes, and proposes the stackup. The AI names (part identities, markings, nets, functions). Every claim carries an evidence tag, and every stage records its interactive PCB or schematic in the flow window.",+  "description": "Reverse engineer a PCB from photos. The program measures (board outline, straightening, scale from hole pitches and part bodies, part bodies and packages, hole rows, every island of copper on both faces, vias, what each pad and via joins) and generates the KiCad copy with the photographed pours and tracks. Connections nobody can see are left unrouted, each with its evidence tag, and the layer count is stated from what is known: outside evidence when given, otherwise UNKNOWN with its test. Routing the hidden connections is an optional later stage. The AI names (part identities, markings, nets, functions). Every claim carries an evidence tag, and every stage records its interactive PCB or schematic in the flow window. `auto` runs every program-only step and stops when the AI's input is needed; `next` names the one next action.",   "steps": [     {       "name": "intake",@@ -8,7 +8,7 @@         "state.json",         "checks/prep.ok"       ],-      "do": "Run `init --top <photo...> --bottom <photo...>`, start the flow window (`serve` in the background, open its URL in a Hydrogen webview), then `prep`: it finds the board outline in every photo (connectors hanging over an edge are left out of the fit), straightens each photo, rates its sharpness, finds the plated holes and rows of holes, finds part bodies on the top, and works out how the bottom photo maps onto the top. Look at evidence/outline-*.jpg and evidence/prep-*.jpg. Run `check prep`. If a photo is soft or a marking is unreadable, ask the human for another shot now."+      "do": "Run `init --photos <folder or photo...>` with every photo of the board, unsorted (close-up tiles, whole-board overviews, both faces, no labels), start the flow window (`serve` in the background, open its URL in a Hydrogen webview), then `prep`. On an unsorted set `prep` first runs `stitch`: it works out which photos show the same face, which are close-up tiles and which are overviews, which face is the top, and builds one mosaic per face at the close-ups' own resolution (photos/top.jpg, photos/bottom.jpg). Read stitch.json and look at stitch/*-sources.jpg (what each part of the mosaic came from), stitch/*-seams.jpg (the joins at full size) and stitch/*-overlap.jpg before going on: areas filled from the overview are low resolution, areas nothing covers are black, and both are listed. (`init --top <photo...> --bottom <photo...>` still starts a run from one photo per face.) Then `prep` finds the board outline in every photo (connectors hanging over an edge are left out of the fit), straightens each photo, rates its sharpness, finds the plated holes and rows of holes, finds part bodies on the top, and works out how the bottom photo maps onto the top. Look at evidence/outline-*.jpg and evidence/prep-*.jpg. Run `check prep`. If a photo is soft or a marking is unreadable, ask the human for another shot now."     },     {       "name": "scale",@@ -23,7 +23,7 @@       "done": [         "checks/parts.ok"       ],-      "do": "STEP 1: identify as many parts as possible. `detect parts` measures every body and hole row on both faces in board mm, matches a package by size, proposes a reference and rotation, and writes crops (evidence/parts/) and contact sheets. Chip capacitors and resistors from 0201 to 1206 are read body first (candidates with `method`: `body`, drawn as outlines on evidence/parts-map.jpg and parts-map-bottom.jpg): the place is the body's centre and the package is from the body's own size, so check the package against the crop; detect/parts.json `passives.leftOut` lists those left out as lying inside a bigger part. `parts init` turns the candidates into parts.json. Then read every crop: set the reference, value (`decode <marking>` reads resistor codes), marking, MPN, function, footprint where the guess is wrong, and the pin-1 mark for every part that fits one way; tag each field by how it is known. Dismiss each candidate that is not a part (silkscreen, logo, reflection) in parts.json `dismissed` with the reason. Run `check parts`."+      "do": "STEP 1: identify as many parts as possible. `detect parts` measures every body and hole row on both faces in board mm, matches a package by size, proposes a reference and rotation, and writes crops (evidence/parts/) and contact sheets. Chip capacitors and resistors from 0201 to 1206 are read body first (candidates with `method`: `body`, drawn as outlines on evidence/parts-map.jpg and parts-map-bottom.jpg): the place is the body's centre and the package is from the body's own size, so check the package against the crop; detect/parts.json `passives.leftOut` lists those left out as lying inside a bigger part, and `passives.levels` the colour levels each face was read at: measured on that photo's own chip bodies, or, when it shows too few, another board's numbers scaled to it, which misses more. Dark resistor bodies with pale ends are often not read body first: look for them among the other candidates. Every candidate carries a `confidence` from 0 to 1 and, where something counts against it, a `doubt`; those under 0.5 are small grey rings on the map. `parts init` turns the candidates rated 0.5 or more into parts.json and lists the rest in `unassigned`, most sure first: real parts hide there too, so read down that list. `parts snap` measures each part's place from its pads; a chip part changes size only to what its own body measures on the photo, or, where no body reads, to pads that fit far better, and its `sizeCheck` says which. Then read every crop: set the reference, value (`decode <marking>` reads resistor codes), marking, MPN, function, footprint where the guess is wrong, and the pin-1 mark for every part that fits one way; tag each field by how it is known. Dismiss each candidate that is not a part (silkscreen, logo, reflection) in parts.json `dismissed` with the reason. Run `check parts`."     },     {       "name": "blocks",@@ -37,7 +37,7 @@       "done": [         "checks/copper.ok"       ],-      "do": "STEP 3: identify zones and routing. `copper prep` writes gap maps, gridded board images and the vias read from the photos (detect/domes.json, detect/vias.json, each with a measured position and diameter). `copper trace` reads every island of copper on both faces from the photos: copper is lighter than laminate relative to its own neighbourhood, and a dark-line filter adds the thin gaps. Each island becomes a pour (a zone with its exact copper) or a track (a centre line at its measured width). Pads and vias are joined to the islands they touch, screw lugs name their pours, and through vias carry nets to unnamed islands. It writes copper.json, detect/copper-measured.json (groups, disagreements with the nets so far, and whether both photos can show a via's ring at all) and evidence/copper-islands-*.jpg. Look at the evidence images against the photos, and at the disagreements. Run `check copper`."+      "do": "STEP 3: identify zones and routing. `copper prep` writes gap maps, gridded board images and the vias read from the photos (detect/domes.json, detect/vias.json, each with a measured position and diameter). `copper trace` reads every island of copper on both faces from the photos: copper is lighter than laminate relative to its own neighbourhood, and a dark-line filter adds the thin gaps. Each island becomes a pour (a zone with its exact copper) or a track (a centre line at its measured width). Pads and vias are joined to the islands they touch, screw lugs name their pours, and through vias carry nets to unnamed islands. It writes copper.json, detect/copper-measured.json (groups, disagreements with the nets so far, and whether both photos can show a via's ring at all) and evidence/copper-islands-*.jpg. Then `connect measure` and `connect attribute` give every pour, track and via a net with graded evidence. They run here, before anything is built, because attribution writes nets into copper.json: run later, it makes the build, `check mimic` and the snapshot stale, and they have to be done again (`auto` and `next` do send you back by themselves). `connect attribute` starts nets.json empty when there is none. Look at the evidence images against the photos, and at the disagreements. Run `check copper`."     },     {       "name": "mimic",@@ -57,16 +57,17 @@       "name": "connect",       "done": [         "checks/nets.ok",-        "checks/layers.ok"+        "checks/layers.ok",+        "checks/schematic.ok"       ],-      "do": "STEP 5b: make the final connections and propose the layer count. Add the connections nobody can see from the datasheets (tag DATASHEET) or the circuit (tag INFERRED, with a test) to nets.json. `connect route` starts at the fewest layers the evidence allows and routes every open connection on the program's own router, on every signal layer of the stackup, keeping what the photo already joins. Pads, holes, seen vias and the board edge block; seen copper of another net costs extra and the length crossed is reported. It runs KiCad's DRC on each result against the same board unrouted. A stackup that leaves connections open or fails the DRC is a reason to jump: it moves on two layers at a time, up to `--max-layers`, and records every result and the proposal in layers.json. `build --layers <n>` writes the routed copy. Run `check nets` and `check layers --pcb <board>`."+      "do": "STEP 5b: state what is known of the connections nobody can see, and of the layer count, without inventing either. Add pin names from datasheets (tag DATASHEET, with the source) or from the circuit (tag INFERRED, with a test) to nets.json; `connect attribute` then runs again, and a net the copper contradicts becomes a finding with a test in its `contradiction`, never a silent retag. `layers state` writes layers.json: a count from outside evidence when you have it (`--count <n> --source \"<document>\"`, or `--count <n> --evidence <file> --kind edge-micrograph|cross-section|xray|ct`), otherwise UNKNOWN with test T-layers and the minimum the photos prove or suggest. The hidden connections stay unrouted: `build` leaves each as a ratsnest line in a net class named for its evidence, and lists them in unrouted.json. Run `check nets` and `check layers`. The schematic follows the nets: `build` and `schematic` write a root sheet with the hierarchy and one sheet per block from parts.json, blocks.json, nets.json and net-evidence.json (solid wire: the pad was seen joined to the net's copper; dashed: inferred; a ? marker: net not known). For every IC you identified, name its KiCad library symbol in parts.json `symbol` (\"Library:Name\", used only when its pin numbers are the footprint's pads) or its datasheet pin names in `pins`, and list pins a datasheet says are unused in nets.json `noConnect`. Then `check schematic`: KiCad's ERC with zero errors, KiCad's netlist equal to nets.json pin by pin, and the sheet rules (it needs a kicad-cli: ADOM_AIFLOW_KICAD_CLI). The sheets are drawn and checked again whenever parts.json, blocks.json or nets.json change. Optional, later, and never required by `next`: `connect route [--layers 4,6]` routes the hidden connections on the program's own router and records each stackup in layers.json. Its routes are one way to make the connections, not what is inside the board, and they prove no layer count; a count stated from outside evidence stays as stated."     },     {       "name": "review",       "done": [         "checks/review.ok"       ],-      "do": "STEP 6: review in a measured loop until every category reaches the target (default 90). Run `review pass`. It re-measures, computes a score from 0 to 100 for identity, values, blocks, placement, copper, vias, connectivity and layers, draws the audits still needed and answers CONTINUE, TARGET MET, STALLED or STOPPED. No score is typed. On CONTINUE do what it names: have an auditor who did not produce this run judge every crop in audits/<category>/sheet.md and sheet-*.jpg with `audit verdict` (a sample counts only when complete, cannot_tell counts against it, and an ok on a planted false claim voids it), or fix the method where a measurement is the limit, then `review pass` again. A full pass that raises no score is STALLED: it names each category below the target, what limits it, the one thing most likely to raise it and any outside evidence photos cannot replace. `confidence` prints every score's inputs. `check review` passes only on TARGET MET or an honestly stated stall."+      "do": "STEP 6: review in a measured loop until the photo score reaches the target (default 90). Run `review pass`. It re-measures, computes a score from 0 to 100 per category, draws the audits still needed and answers CONTINUE, TARGET MET, STALLED or STOPPED. There are two overall figures. The photo score is the lowest of what photos can earn (identity, values of marked parts, blocks, placement, copper, vias, the joins a crop shows, the drawn layers against the photos): the target applies to it. The bench score is the lowest of what needs the real board or outside documents (values of unmarked parts, pins and connections no photo shows joined, the net of copper that reaches no pin, the layer count): it is reported beside the photo score with what would raise it, and the loop does not wait for it. No score is typed. On CONTINUE do what it names: have an auditor who did not produce this run judge every crop in audits/<category>/sheet.md and sheet-*.jpg with `audit verdict` (a sample counts only when complete, cannot_tell counts against it, and an ok on a planted false claim voids it), or fix the method where a measurement is the limit, then `review pass` again. A full pass that raises no photo figure is STALLED: it names each category below the target, what limits it and the one thing most likely to raise it. `confidence` prints every score's inputs. `check review` passes only on TARGET MET or an honestly stated stall."     },     {       "name": "tests",@@ -81,7 +82,7 @@         "report.md",         "checks/report.ok"       ],-      "do": "Run `report` for the generated tables, write report.md around them (what the board is, the blocks, the layer proposal and its evidence, confidence per category, copied versus inferred, the tests), with the computed confidence line word for word, and run `report` again: it refuses a report without that line or with a claim above the real number. Hand the KiCad project and the report to the human."+      "do": "Run `report` for the generated tables, write report.md around them (what the board is, the blocks, the layer statement and its evidence, both scores per category, copied versus inferred, the hidden connections left unrouted, the tests), with the computed confidence line word for word, and run `report` again: it refuses a report without that line or with a claim above the real numbers. `report --draft` writes a starting report.md from the tables when there is none. `auto` then builds the two minute run video (`video build`: reverse-flow/video/<project>-run.mp4 with captions and video.json; you may write reverse-flow/video-notes.json first, one line naming the board and one per main chip); a failed video build is reported and never holds the run up. Hand the KiCad project, the report and the video to the human. After that, optional and never waited for: `bench plan` writes ordered instructions for a person with the real board and a multimeter (reverse-flow/bench/plan.md), and `bench ingest --file <results.json>` records their answers as MEASURED, so the bench score moves by computation (docs/bench.md)."     }   ] }
package.json+5−4
@@ -1,9 +1,9 @@ {   "slug": "ai-flow-reverse-engineering",   "type": "app",-  "version": "0.4.0",+  "version": "0.5.0",   "title": "AI Flow: Reverse Engineering",-  "description": "Reverse engineer a PCB from photos: identify the parts and functional blocks, read every pour, track, via, silkscreen and mask opening from the pictures into a KiCad copy, give each piece of copper a net with a record of how, route the connections nobody can see on its own N-layer router, jump the layer count until they close and the DRC passes, review in a loop with a measured confidence score per category from independent audits, and list the tests that settle every unknown. Every claim carries an evidence tag (seen, measured, datasheet, inferred, unknown). Rust source on this page; build it with ./build.sh.",+  "description": "Reverse engineer a PCB from photos into a KiCad copy: sort and stitch the photos, identify the parts and functional blocks, read the copper and vias, build nets from the parts' pinouts and the copper seen, draw a schematic and net pictures, state what is known about the layers, measure two confidence scores with independent audits, list the tests that settle the rest, and make a two minute video of the run.",   "category": "Developer Tools",   "license": "MIT",   "bin": {@@ -23,7 +23,7 @@     "type": "image",     "path": "screenshots/hero.png"   },-  "brief": "Photos of a PCB to a tagged KiCad copy with its traced copper, a layer-count proposal and the tests that settle what the photos cannot show.",+  "brief": "Photos of a PCB to a tagged KiCad copy: parts placed, copper and nets with their evidence, a schematic, a measured confidence score, bench tests and a video of the run.",   "discovery_pitch": "Reach for this when someone has photos of a board and wants it identified, copied into KiCad, or wants to know how many layers it has and what is inside.",   "discovery_triggers": [     "reverse engineer this board",@@ -74,9 +74,10 @@     "src/**",     "flows/**",     "assets/**",+    "tools/video/**",     "skills/**",     "docs/method.md",     "docs/status.md",     "bin/**"   ]-}+}\ No newline at end of file
page.json+4−3
@@ -3,8 +3,8 @@   "slug": "ai-flow-reverse-engineering",   "type": "app",   "title": "AI Flow: Reverse Engineering",-  "brief": "Photos of a PCB to a tagged KiCad copy with its traced copper, a layer-count proposal and the tests that settle what the photos cannot show.",-  "version": "0.4.0",+  "brief": "Photos of a PCB to a tagged KiCad copy: parts placed, copper and nets with their evidence, a schematic, a measured confidence score, bench tests and a video of the run.",+  "version": "0.5.0",   "discovery_triggers": [     "reverse engineer this board",     "reverse engineer a pcb from photos",@@ -65,7 +65,7 @@   },   "skills": [],   "org": "adom",-  "description": "Reverse engineer a PCB from photos: identify the parts and functional blocks, read every pour, track, via, silkscreen and mask opening from the pictures into a KiCad copy, give each piece of copper a net with a record of how, route the connections nobody can see on its own N-layer router, jump the layer count until they close and the DRC passes, review in a loop with a measured confidence score per category from independent audits, and list the tests that settle every unknown. Every claim carries an evidence tag (seen, measured, datasheet, inferred, unknown). Rust source on this page; build it with ./build.sh.",+  "description": "Reverse engineer a PCB from photos into a KiCad copy: sort and stitch the photos, identify the parts and functional blocks, read the copper and vias, build nets from the parts' pinouts and the copper seen, draw a schematic and net pictures, state what is known about the layers, measure two confidence scores with independent audits, list the tests that settle the rest, and make a two minute video of the run.",   "category": "Developer Tools",   "bin": {     "adom-aiflow-reverse": "bin/adom-aiflow-reverse"@@ -92,6 +92,7 @@     "src/**",     "flows/**",     "assets/**",+    "tools/video/**",     "skills/**",     "docs/method.md",     "docs/status.md",
skills/adom-aiflow-reverse/SKILL.md+57−8
@@ -1,6 +1,6 @@ --- name: adom-aiflow-reverse-description: "Reverse engineer a PCB from photos into a KiCad copy: identify parts and functional blocks, trace every pour, track and via from the pictures, route the hidden connections, propose the layer count, review in a measured loop until 90 percent confidence, and list tests for what is unknown. Use when asked to reverse engineer this board, clone a PCB from pictures, copy a board into KiCad, identify the parts on a PCB, work out how many layers a board has, or infer its inner layers."+description: "Reverse engineer a PCB from photos into a KiCad copy: identify parts and functional blocks, trace every pour, track and via from the pictures, list the hidden connections with their evidence, state the layer count from what is known, review in a measured loop until the photo score reaches 90, and list tests and bench probes for what is unknown. Use when asked to reverse engineer this board, clone a PCB from pictures, copy a board into KiCad, identify the parts on a PCB, work out how many layers a board has, or infer its inner layers." user-invocable: true --- @@ -10,12 +10,27 @@ Run the `adom-aiflow-reverse` binary. It measures and generates; you name what t  ```bash mkdir my-board && cd my-board-adom-aiflow-reverse init --top a.jpg b.jpg --bottom c.jpg --rules kicad-default --ai-thread "<your thread>"+adom-aiflow-reverse init --photos <folder or photo...> --rules kicad-default --ai-thread "<your thread>" adom-aiflow-reverse serve --port 8891 & adom-aiflow-reverse next --ai-thread "<your thread>" ``` -Then repeat: do what `next` says, run its check, read the hints.+Then repeat: do what `next` says, run its check, read the hints. Or let the program do its own part: `auto` runs every program-only step and stops with `AUTO NEEDS AI: <input>` (exit 2) when it needs you, naming the exact file or command, or `AUTO COMPLETE` (exit 0). Give the input, run `auto` again, until it is complete.++## Photos: give them all, unsorted++Pass every photo of the board to `init --photos`, as a folder or a list: close-up tiles, whole-board overviews, both faces, in any order. Do not sort, rename, crop, rotate or stitch them yourself. The first `prep` runs `stitch`, which sorts them into faces, tiles and overviews, picks the top and builds one mosaic per face at the close-ups' resolution (`photos/top.jpg`, `photos/bottom.jpg`). The registration is Python with OpenCV (`tools/stitch.py`, shipped inside the binary and run by it; the call is in the run ledger), so the machine needs `python3` with numpy and OpenCV.++Before going on, read `stitch.json` and look at the pictures in `reverse-flow/stitch/`:++- `<face>-sources.jpg`: which photo each part of the mosaic came from. Grey is overview fill, which is low resolution: copper read there is not to the standard of the rest. Black is not seen.+- `<face>-seams.jpg` and `seams/`: the joins at full size. A track that steps sideways at a join is a registration fault: report it, do not work round it.+- `overlap` in `stitch.json`: how far the tiles disagree where they overlap, in pixels. Under one pixel at the median is normal for the test boards.+- `topDecision`: how the top was chosen. When it says the two faces are close, look at both mosaics. If the other face is the top, run `stitch --top <name of a photo that shows the top>` and say why in your report.+- `facesAssumed`: present when the photos alone do not prove which group is which face. Say so to the human.+- `unplaced`, `missing` and each face's `notes`: photos that fit nowhere, a face nobody photographed, a board that runs out of the frame. Tell the human what is missing and ask for those photos. Never describe an area the mosaic does not show.++`init --top <photo...> --bottom <photo...>` remains for a run that already has one finished photo per face.  ## Displaying this app (REQUIRED, read before showing anything) @@ -29,6 +44,9 @@ Surfaces: **wv**, a Hydrogen webview panel; **pup**, see the `pup` skill; the us - **Parts:** read markings from the crops, then identity and value, each with its evidence. - **Blocks:** names and functions for the groups it proposes. - **Nets:** pin names and functions from datasheets. Tag each one `DATASHEET` or `INFERRED`. An inference needs a test.+- **Datasheet pins:** when `auto` stops with `AUTO NEEDS AI: datasheet pins`, fetch the public datasheet of each chip it names and write `reverse-flow/datasheets/<ref>.pins.json`: `{"part": "U2", "mpn", "document": {"name", "number", "revision", "page"}, "pins": [{"pad": "4", "name": "VM", "type": "power_in", "expects": [{"kind": "capacitor_to_pin", "pin": "PGND"}, {"kind": "tied_to_pin", "pin": "VDRAIN"}]}, ...]}`. Every pad of the footprint takes an entry (the exposed pad too); `type` is one of `power_in`, `power_out`, `ground`, `input`, `output`, `bidirectional`, `open_drain`, `passive`, `no_connect`; `expects` holds what the typical application shows at the pin (`capacitor_to_ground`, `capacitor_to_pin`, `tied_to_pin`, `tied_to_ground`, `resistor_to_pin`, `resistor_to_ground`, `pull_up`, `crystal`, `inductor`, `not_connected`, `other`). Example for the DRV8323S: docs/method.md, "Datasheet pins". No public datasheet, or a marking that fits several parts: `{"part": "U5", "available": false, "reason": "..."}`. Never write pins for a chip whose marking you did not read: the program refuses them. The program then puts the pin names in parts.json (`datasheet apply`), draws the chip with them, and names the copper at each named pin (`U2_GHA`, name tagged `DATASHEET`, the join as seen). `datasheet status` shows what waits. After every `connect attribute` the datasheet witness reports how many pins agree, disagree or are not visible against the copper: read `reverse-flow/evidence/witness/findings.md` and look at each picture. A disagreement changes nothing by itself: decide from the picture whether the copper, the part's identity or orientation, or the net you typed is wrong, fix that, and keep the test it raised if you cannot tell. "Unresolved copper" pictures show where the reader merged a chip's fine-pitch pads: those pins have no witness.+- **Part, then orientation, then pinout:** nets are built from the parts first (`connect pinout`, run by `auto`): every pin of an identified chip with a pin table, of a USB Type-C receptacle, and of any part whose `pins` you typed gets a net by rule, tagged DATASHEET or INFERRED, and the copper then confirms or contradicts. So: read pin 1 of every chip from the photo (`pin1`: the mark and `tag` SEEN; when no mark can be seen say so with `tag` UNKNOWN, never guess: `auto` asks for exactly the chips with a pinout whose orientation is not settled); type the silkscreen names of header pads into the header's `pins` (`{"1": "GND", "2": "RX"}`) after checking which end pad 1 is; read `reverse-flow/detect/pinout.json` for the chain and for every part claimed by place, and look at each claim.+- **Schematic symbols:** for each IC or transistor you identified, its KiCad library symbol in parts.json `symbol` or its datasheet pin names in `pins` (pad to name, or to `{"name", "type"}`); pins a datasheet says are unused in nets.json `noConnect`. Without them an IC is a box in pad order and every unknown pin shows a `?`. `auto` draws the sheets and runs `check schematic` by itself in the connect step and stops on a failure; `report` writes a picture of every sheet under `reverse-flow/schematic/`: look at each block there. - **Audits:** an independent verdict on every crop the review draws. You do not supply confidence: the program computes it.  ## Tags are the contract@@ -39,9 +57,25 @@ Surfaces: **wv**, a Hydrogen webview panel; **pup**, see the `pup` skill; the us  - `evidence/copper-islands-*.jpg` against the photos. This is what the board was built from. - `detect/copper-measured.json`: `pinClashes` and `viaClashes` are where the copper disagrees with the nets. Each is either a wrong net or a misread island, and both need a test.-- `layers.json` candidates: `open`, `drcErrors`, `kicadOpen`, `seenCrossingMm`. The router's `open` and KiCad's `kicadOpen` should agree. If they do not, the copy's seen copper is not joined the way the measurement said.+- `unrouted.json`: the hidden connections and what each rests on. `nets.json` `contradiction`: where the copper disagrees with a net you named.+- `layers.json`: the count and its tag, and `minimum`, what the photos alone give. After an optional `connect route`, its candidates: `open`, `drcErrors`, `kicadOpen`, `seenCrossingMm`.++## The nets as pictures++`nets draw` (also run by `report`) grades every net and draws it under `reverse-flow/nets/`. It reads what `connect attribute` wrote and no photo, so run it again after attribution changes.++- **A net's level is its weakest link**, not the share of its copper that was seen: `copper` (every pin seen joined by continuous copper), `via` (joined, at the weakest point through verified vias) or `inferred` (at least one pin or join rests on inference). `net-confidence.json` gives each net's level, the weakest link and where it is, pins seen and copper by rung. Probe the weakest link first. Report the counts per level as they are: a board where every net is `inferred` is a correct result.+- `nets/confidence.jpg`: both faces, every piece of copper in the colour and pattern of its net's level, with the legend. `confidence-top.jpg` and `confidence-bottom.jpg` are the same at full size, `attribution.jpg` shows each piece by its own rung, `confidence-table-<n>.jpg` lists every net.+- `nets/net/<name>.jpg`: one picture per net, zoomed to the net when it is small. Filled marks are pins seen joined, hollow marks are pins on the net by inference only, the yellow ring is the weakest link, a cross is a place where the net meets another.+- `nets/sections/`: the nets in groups (ground, each supply rail, each bus or interface by name, then by block), up to eight to a sheet. `nets/index.md` links everything.+- `nets/view.html`: the same in the flow window (the Nets tab of the recorded view): a net list beside the board, a click on a net or on the board lights it on both faces, with a switch between by confidence and by net.+- Copper with no net (`UNK_ISO`, `UNK_G`, `UNK_CONFLICT`) gets no picture of its own: it is the `none`, `group` and `conflict` categories of the confidence picture, and index.md says how many there are. -## The review loop: 90 percent, measured+Look at the pictures at full size before showing them. `nets draw --net <name>` redraws one net.++## The review loop: 90 percent on the photo score, measured++There are two scores. The **photo score** is the lowest of what photos can earn, and the target applies to it. The **bench score** is the lowest of what needs the real board or outside documents (hidden connectivity, the layer count, values of unmarked parts): report it with what would raise it, and never wait for it. Report the line with both word for word.  Repeat `review pass` and do what it names until it says TARGET MET or STALLED, then `check review`. Scores come from measurements and audits: `confidence` prints each one's inputs and docs/method.md each formula. No typed level or edited score counts. @@ -53,8 +87,23 @@ Repeat `review pass` and do what it names until it says TARGET MET or STALLED, t  Low numbers are the correct output when that is what was earned. STALLED is an honest answer. Never lower `--target` to pass without telling the human; every line names it. Report the computed line word for word. -## Layer count+## Nets, hidden connections and the layer count++`connect measure` then `connect attribute [--vias <file>]` give every pour, track and via a net with graded evidence (copper, via, inferred, group, none, conflict) in copper.json, nets.json and net-evidence.json. Geometry stays as it is. The second command reads no photo, so run it again whenever the via list improves or you add pin names. A net you name from a datasheet keeps its `DATASHEET` tag and source unless the copper contradicts it; a contradiction is a finding in the net's `contradiction`, with a test. Read those findings.++The connections nobody can see are not routed. `build` leaves each as a ratsnest line in a net class named for its evidence and lists them in `unrouted.json`. `layers state` states the layer count: give `--count <n> --source "<document>"` or `--count <n> --evidence <file> --kind edge-micrograph|cross-section|xray|ct` when you have outside evidence, and nothing when you do not: UNKNOWN with its test is a good answer. Never state a count from a guess.++`connect route` is optional and no step needs it. Its routes are one way to make the connections, not what is inside the board. If you run it, report what it found, including when nothing closed.++## The run video++`auto` ends by building a two minute video of the run: `reverse-flow/video/<project>-run.mp4`, with `.vtt` and `.srt` captions beside it, a poster and `video.json`. `video build` builds it again by hand. It is made from the run's files alone, and every figure in it is read at build time, so it cannot claim more than the run earned.++- Before `auto` reaches the end, you may write `reverse-flow/video-notes.json`: `{"board": "<one line naming the board>", "chips": {"U1": "<one line about this chip>"}}`. Plain words, one short sentence each. They are spoken as written, so say only what the run's evidence supports.+- A failed build does not stop the run: `auto` still ends with `AUTO COMPLETE`, and its last line says `the video was NOT built: <reason>`. Tell the human, fix what it names (python3 with numpy, Pillow and OpenCV; ffmpeg), and run `video build`.+- With no voice service the video has captions only; `video.json` `voice.why` says so. Tell the human.+- Hand the video to the human with the report. `video.json` lists each scene, what was said, and which lines were left out for time. -`connect measure` then `connect attribute [--vias <file>]` give every pour, track and via a net with graded evidence (copper, via, inferred, group, none, conflict) in copper.json, nets.json and net-evidence.json. Geometry stays as it is. The second command reads no photo, so run it again whenever the via list improves.+## After the run: the bench passback -`connect route` starts at the fewest layers the evidence allows and jumps two at a time when connections stay open or the DRC fails. Report what it found, including when nothing closed. Never present a proposal without its candidates.+`bench plan` writes `reverse-flow/bench/plan.md` for a person with the real board and a multimeter: continuity probes between named pads, each with a picture, ordered by what each settles, then the unmarked parts to measure and the layer count. When their answers come back, `bench ingest --file <results.json>` records them as `MEASURED` and prints both scores before and after; then `auto` runs the stale checks again. docs/bench.md gives the file format. Offer the plan; do not wait for it.
src/attrib.rs+52−6
@@ -669,10 +669,27 @@ fn nets_carried(old: &[Value], by_name: &BTreeMap<String, Vec<String>>) -> (Vec< /// file when every pin is seen, the copper counts, and `nameSource` by `name_source`. Returns that name source. fn stamp_net(n: &mut Value, pins: &[String], inferred: &[String], seen_all: bool, proven: usize, clash: usize, groups: &BTreeMap<usize, Vec<String>>) -> String {     let ns = name_source(n);+    let from_copper = n["fromCopper"].as_bool().unwrap_or(false);+    // what whoever wrote the net said of it, kept across runs: attribution writes `tag` and would otherwise forget it+    let stated = n["statedTag"].as_str().map(str::to_string).unwrap_or_else(|| n["tag"].as_str().unwrap_or("").to_string());+    if !from_copper && n["statedTag"].is_null() && !stated.is_empty() { n["statedTag"] = json!(stated); }+    // the copper contradicts the net when a pin of it sits on copper named for another net, or when the copper moved a pin the map had off it+    let moved: Vec<&String> = inferred.iter().filter(|p| !pins.contains(p)).collect();+    let contradicted = clash > 0 || !moved.is_empty();     n["pins"] = json!(pins);-    if !n["fromCopper"].as_bool().unwrap_or(false) { n["inferredPins"] = json!(inferred); }-    n["tag"] = json!(if seen_all { "MEASURED" } else { "INFERRED" });+    if !from_copper { n["inferredPins"] = json!(inferred); }+    // a net named from a document keeps that tag and its source while the copper does not contradict it: not every pin being seen joined is no+    // reason to call the document an inference. A contradiction is a finding with a test, never a silent retag+    let documented = stated == "DATASHEET" && !n["source"].as_str().unwrap_or("").trim().is_empty();+    n["tag"] = json!(if seen_all { "MEASURED" } else if documented && !contradicted { "DATASHEET" } else { "INFERRED" });     if seen_all { n["evidence"] = json!(["net-evidence.json"]); }+    let name = n["name"].as_str().unwrap_or("").to_string();+    if contradicted {+        n["contradiction"] = json!({"pinClashes": clash, "pinsTheCopperMoved": moved, "stated": stated,+            "finding": format!("the copper read from the photos disagrees with this net as {}: {clash} pin(s) sit on copper named for another net, {} pin(s) were moved off it. Either the net is wrong or an island was misread: the test settles which", if documented { "its document names it" } else { "it was stated" }, moved.len())});+    } else if let Some(o) = n.as_object_mut() { o.remove("contradiction"); }+    // an inferred net always names the test that would settle it, so the nets check cannot fail for the want of one+    if n["tag"] == json!("INFERRED") && n["tests"].as_array().map(|a| a.is_empty()).unwrap_or(true) { n["tests"] = json!([format!("T-net-{name}")]); }     n["nameSource"] = json!(ns);     n["copperEvidence"] = json!({"pinsSeen": proven, "pins": pins.len(), "pinClashes": clash, "groupsOfSeenPins": groups.len()});     n["copperGroups"] = json!(groups.values().collect::<Vec<_>>());@@ -691,7 +708,10 @@ pub fn attribute(flow: &Path, via_file: Option<&Path>) -> Result<Value, String>     let mut copper = rd(&flow.join("copper.json"))?;     let mut nets_doc = rd(&flow.join("nets.json"))?;     let mut maps_p = HashMap::new(); let (mut w, mut h) = (0, 0);-    for side in ["top", "bottom"] { let (m, a, b) = read_rle(&dir.join(format!("pieces-{side}.rle")))?; w = a; h = b; maps_p.insert(side.to_string(), m); }+    // a face no photo shows (`notread::missing`) has no pieces: its map is empty, and everything below finds nothing there+    for side in ["top", "bottom"] { if !flow.join(format!("board/{side}.jpg")).is_file() { continue; } let (m, a, b) = read_rle(&dir.join(format!("pieces-{side}.rle")))?; w = a; h = b; maps_p.insert(side.to_string(), m); }+    if maps_p.is_empty() { return Err("no board/top.jpg or board/bottom.jpg: `scale` writes them".into()); }+    for side in ["top", "bottom"] { maps_p.entry(side.to_string()).or_insert_with(|| vec![0; w * h]); }     // pieces: global index by (side, id)     let ps = ms["pieces"].as_array().ok_or("measure.json has no pieces")?;     let n_p = ps.len();@@ -768,13 +788,15 @@ pub fn attribute(flow: &Path, via_file: Option<&Path>) -> Result<Value, String>         }     }     // 2. pads: what touches each pad, read at its own edge on the face it shows on+    // a face whose copper failed `check copper` gives no join: no pad and no via takes a conductor from it, so no net is claimed through it (`notread`)+    let not_read = crate::notread::faces(flow);     let mut contacts: Vec<Contact> = vec![]; let mut pad_states = vec![];     for pad in ms["pads"].as_array().into_iter().flatten() {         let pin = format!("{}.{}", pad["part"].as_str().unwrap_or(""), pad["num"].as_str().unwrap_or(""));         if pad["num"].as_str().unwrap_or("").is_empty() { continue; }         let net = pin_net.get(&pin).cloned();         for side in ["top", "bottom"] {-            if pad["faces"][side].is_null() { continue; }+            if pad["faces"][side].is_null() || crate::notread::skips(&not_read, side) { continue; }             let node = |id: u32| cond_at(side, id);             let nar = |c: usize| c_narrow[c];             // a track that leads somewhere from this pad, not one hugging it@@ -832,6 +854,7 @@ pub fn attribute(flow: &Path, via_file: Option<&Path>) -> Result<Value, String>         let mut attach = [None, None]; let mut ends = [false, false];         for (fi, side) in ["top", "bottom"].iter().enumerate() {             let Some(d) = d else { att[fi] = Attach::Hidden; continue };+            if crate::notread::skips(&not_read, side) { att[fi] = Attach::Hidden; continue; }             let node = |id: u32| cond_at(side, id);             let c_nar = |c: usize| c_track[c] || c_area[c] < 0.5;             att[fi] = via_attach(&d["faces"][*side], &node, &c_nar);@@ -904,7 +927,10 @@ pub fn attribute(flow: &Path, via_file: Option<&Path>) -> Result<Value, String>     let mut new_names: HashMap<usize, String> = HashMap::new();     { let mut on: BTreeMap<usize, BTreeSet<String>> = BTreeMap::new();       for c in contacts.iter().filter(|c| c.tier == Tier::Solid && c.net.is_none()) { on.entry(c.piece).or_default().insert(c.pin.clone()); }-      for (n, pins) in on { if pins.len() >= 2 && matches!(outcome.pieces[n].rung, Rung::Group | Rung::None) { new_names.insert(n, format!("CU_{}", pins.iter().next().unwrap().replace('.', "_"))); } } }+      // a pin a datasheet names gives its name to the copper it sits on, alone on it or not (`dspins`); the join is the copper's either way+      let ds = crate::dspins::Names::load(flow);+      for (n, pins) in on { if !matches!(outcome.pieces[n].rung, Rung::Group | Rung::None) { continue; }+          if let Some(name) = ds.name_for(&pins) { new_names.insert(n, name); } else if pins.len() >= 2 { new_names.insert(n, format!("CU_{}", pins.iter().next().unwrap().replace('.', "_"))); } } }     let mut named_nodes = outcome.pieces.clone();     for (n, name) in &new_names { named_nodes[*n] = Named { net: name.clone(), rung: Rung::Copper, why: "pins with no name in the pin map share this copper".into(), candidates: vec![] }; }     // groups: what is joined as far as the photos show. A shared name joins nothing@@ -1232,7 +1258,27 @@ mod tests {         let ns = stamp_net(&mut n, &["J1.24".to_string(), "R5.1".to_string()], &["J1.24".to_string()], false, 1, 0, &BTreeMap::new());         for k in ["source", "nameSource", "why", "tests", "note", "name"] { assert_eq!(n[k], before[k], "{k} is not attribution's to change"); }         assert_eq!(ns, cite);-        assert_eq!(n["pins"], json!(["J1.24", "R5.1"])); assert_eq!(n["inferredPins"], json!(["J1.24"])); assert_eq!(n["tag"], "INFERRED"); assert_eq!(n["copperEvidence"]["pinsSeen"], 1);+        // a pin the copper added does not contradict the document: the net keeps its tag and its source+        assert_eq!(n["pins"], json!(["J1.24", "R5.1"])); assert_eq!(n["inferredPins"], json!(["J1.24"])); assert_eq!(n["tag"], "DATASHEET"); assert_eq!(n["statedTag"], "DATASHEET"); assert_eq!(n["copperEvidence"]["pinsSeen"], 1);+        assert!(n["contradiction"].is_null());+        // the copper contradicts it (a pin of it sits on copper named for another net): a finding with a test, and the stated tag is remembered+        let mut c = json!({"name": "VBUS", "pins": ["J1.1", "C3.1"], "tag": "DATASHEET", "source": cite});+        stamp_net(&mut c, &["J1.1".to_string(), "C3.1".to_string()], &["J1.1".to_string(), "C3.1".to_string()], false, 1, 1, &BTreeMap::new());+        assert_eq!(c["tag"], "INFERRED"); assert_eq!(c["statedTag"], "DATASHEET"); assert_eq!(c["source"], cite);+        assert_eq!(c["tests"], json!(["T-net-VBUS"]));+        assert_eq!(c["contradiction"]["pinClashes"], 1);+        assert!(c["contradiction"]["finding"].as_str().unwrap().contains("test"));+        // and so does the copper moving one of its pins away+        let mut v = json!({"name": "EN", "pins": ["U1.3", "R2.1"], "tag": "DATASHEET", "source": cite});+        stamp_net(&mut v, &["U1.3".to_string()], &["U1.3".to_string(), "R2.1".to_string()], false, 0, 0, &BTreeMap::new());+        assert_eq!(v["tag"], "INFERRED"); assert_eq!(v["contradiction"]["pinsTheCopperMoved"], json!(["R2.1"]));+        // the contradiction gone on a later run (a better via list), the document's tag comes back+        stamp_net(&mut c, &["J1.1".to_string(), "C3.1".to_string()], &["J1.1".to_string(), "C3.1".to_string()], false, 1, 0, &BTreeMap::new());+        assert_eq!(c["tag"], "DATASHEET"); assert!(c["contradiction"].is_null());+        // a net nobody documented is inferred, and always names its test+        let mut u = json!({"name": "N7", "pins": ["R1.1", "R2.2"], "why": "looks like a divider"});+        stamp_net(&mut u, &["R1.1".to_string(), "R2.2".to_string()], &["R1.1".to_string(), "R2.2".to_string()], false, 0, 0, &BTreeMap::new());+        assert_eq!(u["tag"], "INFERRED"); assert_eq!(u["tests"], json!(["T-net-N7"]));         // a net that states nothing gets a class word, and a class word from an earlier run is rewritten, not taken for a statement         let mut m = json!({"name": "SW", "pins": ["L1.1"], "why": "silkscreen label beside the pad"});         assert_eq!(stamp_net(&mut m, &["L1.1".to_string()], &["L1.1".to_string()], false, 0, 0, &BTreeMap::new()), "silkscreen");
src/auto.rsadded+924
@@ -0,0 +1,924 @@+//! `auto`: run every program-only step in order, and stop with one clear line when the AI's judgement is needed (naming the+//! file or command it wants), when the run is complete, or when a command fails. A script alternates `auto` with the AI's+//! inputs until it says COMPLETE.+//!+//! `plan` decides the one next action from the files as they stand, so it gives the same answer whoever asks: `auto` runs it,+//! and `next` prints it. Nothing here measures or scores: each action is one of the flow's own commands, run as a child of+//! this program, so the ledger shows the same lines a hand-run flow would.+//!+//! The order fixes the known rework loop. `connect attribute` writes nets into copper.json, which makes `check mimic` and the+//! snapshot stale. Attribution therefore runs in the copper step, before anything is built on the copper, and whenever it has+//! to run again (the AI added pin names to nets.json) the plan goes back by itself: build, `check mimic`, snapshot, then on.+use crate::{attrib, checks, dspins, pinnets, flow, items, layerstate, photomat};+use serde_json::{json, Value};+use std::path::{Path, PathBuf};++/// The one next thing to do.+#[derive(Debug, Clone, PartialEq)]+pub enum Action {+    /// A program-only command (the arguments after the binary's name), and why it is next.+    Run(Vec<String>, String),+    /// The AI's input: its name, then lines that say exactly what file or command is wanted.+    NeedAi(String, Vec<String>),+    Complete,+}++const RECORD: &str = "auto.json";+const LOG: &str = "auto-log.txt";++fn rd(f: &Path, name: &str) -> Value { items::read_json(&f.join(name)).unwrap_or(Value::Null) }+fn has(f: &Path, name: &str) -> bool { f.join(name).exists() }+fn run(cmd: &[&str], why: &str) -> Action { Action::Run(cmd.iter().map(|s| s.to_string()).collect(), why.to_string()) }+fn need(name: &str, lines: Vec<String>) -> Action { Action::NeedAi(name.to_string(), lines) }+fn fresh(f: &Path, check: &str) -> bool { flow::exists(f, &format!("checks/{check}.ok")) }+fn record(f: &Path) -> Value { let v = rd(f, RECORD); if v.is_object() { v } else { json!({}) } }+fn newer(a: &Path, b: &Path) -> bool { match (std::fs::metadata(a).and_then(|m| m.modified()), std::fs::metadata(b).and_then(|m| m.modified())) { (Ok(x), Ok(y)) => x >= y, (Ok(_), Err(_)) => true, _ => false } }++/// Remember something about what `auto` did, so the plan does not repeat it: a file's hash right after a command wrote it.+fn remember(f: &Path, key: &str, v: Value) {+    let mut r = record(f); r[key] = v;+    let _ = std::fs::write(f.join(RECORD), serde_json::to_string_pretty(&r).unwrap_or_default() + "\n");+}++fn strictly_newer(a: &Path, b: &Path) -> bool { match (std::fs::metadata(a).and_then(|m| m.modified()), std::fs::metadata(b).and_then(|m| m.modified())) { (Ok(x), Ok(y)) => x > y, _ => false } }++/// Where every part sits and which way it is turned: what the joins of pads to copper were read against. A value, a marking or a+/// pin name does not move a pad, so it is not in here.+pub fn placement_key(f: &Path) -> String {+    let doc = rd(f, "parts.json");+    let mut lines: Vec<String> = doc["parts"].as_array().into_iter().flatten().filter(|p| p["onBoard"] != json!(false))+        .map(|p| format!("{}|{}|{}|{}|{}", p["ref"].as_str().unwrap_or(""), p["side"].as_str().unwrap_or(""), p["pos_mm"], p["rot"], p["footprint"].as_str().unwrap_or(""))).collect();+    lines.sort();+    crate::confidence::sha256_hex(lines.join("\n").as_bytes())+}++/// The pads are placed by their own metal once, after the AI has completed parts.json. This holds whichever step the run is at:+/// a `check parts` passed by hand must not let the run go on with parts that were never snapped. Only for a parts.json that+/// `auto` saw start from `parts init`, so an older run is not moved.+fn unsnapped(f: &Path) -> Option<Action> {+    if !has(f, "parts.json") { return None; }+    let r = record(f);+    let sha = crate::sha256(&f.join("parts.json"));+    let untouched = r["partsUntouched"].as_str()?;+    if untouched == sha || r["partsSnapped"].as_str() == Some(sha.as_str()) { return None; }+    let doc = rd(f, "parts.json");+    let list = doc["parts"].as_array()?;+    if list.is_empty() || list.iter().any(|p| p["snap"].is_object()) { return None; }+    Some(run(&["parts", "snap"], "each footprint is placed by its own pads in the photo"))+}++/// The footprint files the parts name that are newer than the board: a footprint written or corrected after the build.+fn newer_footprints(f: &Path, pcb: &Path) -> Vec<String> {+    let cache = crate::kicad_out::cache_dir(f);+    let mut ids: Vec<String> = rd(f, "parts.json")["parts"].as_array().into_iter().flatten().filter_map(|p| p["footprint"].as_str().map(str::to_string)).collect();+    ids.sort(); ids.dedup();+    ids.into_iter().filter(|id| id.split_once(':').is_some_and(|(lib, name)| { let p = cache.join(lib).join(format!("{name}.kicad_mod")); p.is_file() && strictly_newer(&p, pcb) })).collect()+}++/// What the last build could not place (`build-left-out.json`), as the lines the AI needs: the part, the footprint, the file wanted.+fn left_out(f: &Path, pcb: &Path) -> Option<Action> {+    let p = f.join(crate::kicad_out::LEFT_OUT);+    if !p.is_file() || strictly_newer(pcb, &p) { return None; }+    let rows = rd(f, crate::kicad_out::LEFT_OUT)["leftOut"].as_array().cloned().unwrap_or_default();+    if rows.is_empty() { return None; }+    let mut lines: Vec<String> = rows.iter().map(|r| format!("{}: left out of the board: {}", r["ref"].as_str().unwrap_or("?"), r["why"].as_str().unwrap_or(""))).collect();+    lines.push("Give each part a footprint that loads: a KiCad library name in parts.json `footprint` (Library:Name), or your own file at reverse-flow/lib/fp/<Library>/<Name>.kicad_mod in the layout KiCad 8 or later saves (one token per line, as the library files beside it). Then run `auto` again: a new or changed footprint file makes the build run again.".into());+    Some(need("a footprint the build can load", lines))+}++/// Attribution, when it has to run (again): the photos' reading first, then the nets. `None` when it is up to date.+fn attribution(f: &Path) -> Option<Action> {+    if !has(f, "copper.json") || rd(f, "copper.json")["from"] != json!("islands") { return None; }+    // a part moved or turned since the copper was read: its pads are joined again where they are now+    { let (r, pk) = (record(f), placement_key(f));+      if r["placementAtTrace"].as_str().is_some_and(|k| k != pk) { return Some(run(&["copper", "trace"], "a part moved, turned or changed footprint since the copper was traced: the pads are joined to the copper again where they are now")); }+      if r["placementAtMeasure"].as_str().is_some_and(|k| k != pk) { return Some(run(&["connect", "measure"], "a part moved, turned or changed footprint since the photos were read for attribution: each pad's edge is read again")); } }+    let key = attrib::zones_key(&rd(f, "copper.json"));+    let ms = rd(f, "detect/attrib/measure.json");+    if ms.is_null() || ms["copperSha"].as_str().is_some_and(|s| s != key) || ms["stitch"].is_null() {+        return Some(run(&["connect", "measure"], "the photos are read once for attribution, on the copper as traced"));+    }+    // nets from the parts first: the pinout nets are built, or built again, before the copper is held against them+    if pinnets::stale(f) { return Some(run(&["connect", "pinout"], "nets from the parts first: every pin with a known function gets its net by rule, before the copper confirms or contradicts")); }+    // nets.json missing is no obstacle: `connect attribute` starts it empty, and it is the step that does+    if !has(f, "nets.json") { return Some(run(&["connect", "attribute"], "there is no nets.json yet: attribution starts it and names the pins that share copper")); }+    let r = record(f);+    let edited = has(f, "nets.json") && r["netsAfterAttribute"].as_str().is_some_and(|h| h != crate::sha256(&f.join("nets.json")));+    if !has(f, "detect/attribution.json") || !newer(&f.join("detect/attribution.json"), &f.join("detect/attrib/measure.json")) || edited {+        return Some(run(&["connect", "attribute"], if edited { "nets.json changed since attribution: the copper takes its nets again" } else { "every pour, track and via gets a net with graded evidence" }));+    }+    // pin names from a datasheet that came, went or changed since: the copper at those pins takes its names again+    // `check copper` recorded a face as not read (or a new trace cleared that): the nets are made again, without that face's joins or with them+    if r["notReadAtAttribute"].as_str().unwrap_or("") != crate::notread::key(f) { return Some(run(&["connect", "attribute"], "a face's copper was recorded as NOT READ, or read again: the nets are made again, and none is claimed through a face that was not read")); }+    if r["pinNamesAtAttribute"].as_str().unwrap_or("") != dspins::Names::load(f).key() { return Some(run(&["connect", "attribute"], "the datasheet pin names changed since attribution: the copper at those pins is named again")); }+    None+}++/// The one stop for datasheets, after the parts are identified and before anything is named or drawn from them: every+/// identified chip has its pins file or a reasoned "none available", and parts.json carries the pin names. `None` when it does.+fn datasheets(f: &Path) -> Option<Action> {+    let st = dspins::status(f);+    if !st.missing.is_empty() || !st.bad.is_empty() || !st.unread.is_empty() { return Some(need("datasheet pins", dspins::ask_lines(&st))); }+    if !dspins::applied(f, &st) { return Some(run(&["datasheet", "apply"], "the datasheet pin names and types go into parts.json, for the schematic and the net names")); }+    // part, then orientation, then pinout: a chip whose pinout is known and whose orientation is not gets no nets until it is settled+    let asks = pinnets::unsettled(f);+    if !asks.is_empty() { let mut lines = vec!["A pinout on a chip turned the wrong way is worse than none. For each chip below, settle which way round it sits, then run `auto` again: it builds the nets from the pinouts by itself.".to_string()]; lines.extend(asks); return Some(need("orientation of chips with a pinout", lines)); }+    None+}++/// The audits a review pass is waiting for, as exact instructions; or a program step that has to come first.+fn audits(scores: &Value, drawn: &[String]) -> Result<Vec<String>, Action> {+    let mut lines = vec![];+    for (cat, c) in scores["categories"].as_object().into_iter().flatten() {+        // a sample drawn since these scores were computed is judged by the next pass, not drawn again+        if c["heldByAudit"] != json!(true) || drawn.contains(cat) { continue; }+        let a = &c["audit"];+        let status = a["status"].as_str().unwrap_or("");+        match status {+            "void" | "stale" => return Err(run(&["audit", "draw", "--category", cat], &format!("the {cat} sample is {status}: a new one is drawn"))),+            s if s.starts_with("scored") => {+                let n = a["strata"].as_array().into_iter().flatten().filter_map(|r| r["needForTarget"].as_u64()).max().unwrap_or(0);+                let have = a["strata"].as_array().into_iter().flatten().filter_map(|r| r["audited"].as_u64()).min().unwrap_or(0);+                if n > have { return Err(run(&["audit", "draw", "--category", cat, "--n", &n.to_string()], &format!("the {cat} sample is too small to reach the target: it is extended to {n} per stratum"))); }+                return Err(run(&["audit", "draw", "--category", cat], &format!("items of the {cat} sample changed since they were judged: a new sample is drawn")));+            }+            "tampered" => lines.push(format!("{cat}: the sample files were changed by hand ({}): restore reverse-flow/audits/{cat}/ from the ledger, then `audit draw --category {cat}`", a["detail"].as_str().unwrap_or(""))),+            _ => lines.push(format!("{cat}: {} of {} verdicts. An auditor who did not produce this run (another AI thread or a person) judges every crop in reverse-flow/audits/{cat}/sheet.md and sheet-*.jpg, then: audit verdict --category {cat} --sample {} --file <answers.json> --who <auditor>   (answers.json: [{{\"item\": \"a001\", \"verdict\": \"ok|wrong|cannot_tell\", \"note\": \"\"}}, ...])", a["verdicts"], a["items"], a["sample"].as_str().unwrap_or(""))),+        }+    }+    Ok(lines)+}++/// After the last step: the run video. Built once per report; a build that failed on these files is not tried again,+/// because a missing video never holds a run up.+fn video(f: &Path) -> Action {+    // the bench instruction set is a deliverable of every run (the optional passback): written once, before the video.+    // A failure is remembered and never holds the run up.+    if !f.join("bench/plan.md").exists() && record(f)["benchPlanFailed"].is_null() { return run(&["bench", "plan"], "the instruction set for a person with the real board (the optional passback)"); }+    if crate::video::fresh(f) || record(f)["videoFailedAt"].as_str() == Some(crate::video::input_key(f).as_str()) { return Action::Complete; }+    run(&["video", "build"], "the two minute video of the run, made from its files")+}++/// What `next` and `steps` say about the video once every step is done: nothing while it is still to build or built, the+/// reason when the last build failed.+pub fn video_note(f: &Path) -> Vec<String> {+    let r = record(f);+    if crate::video::fresh(f) { return vec![format!("the run video: {}", crate::video::record(f)["file"].as_str().unwrap_or("reverse-flow/video/"))]; }+    if r["videoFailedAt"].as_str() == Some(crate::video::input_key(f).as_str()) { return vec![format!("the run video was not built: {}. The run is complete without it; `video build` tries again", r["videoError"].as_str().unwrap_or("see reverse-flow/video/build-log.txt"))]; }+    vec![]+}++/// What comes next, from the files alone.+pub fn plan(f: &Path, fl: &flow::Flow) -> Action {+    let Some(step) = flow::current(fl, f) else { return video(f) };+    // nets from the parts first, wherever the run stands: a run past attribution (waiting for audits, at the tests or the report) whose pin+    // tables, orientations or copper are newer than its pinout nets, or that never had any, gets them built before anything else is done+    if !matches!(step.name.as_str(), "intake" | "scale" | "parts" | "blocks" | "copper" | "mimic" | "snapshot" | "connect") && pinnets::stale(f) {+        return run(&["connect", "pinout"], "the pinout nets are older than the pin tables, the orientations or the copper they are built from (or were never built): they are built first, and the flow goes back to hold the copper against them");+    }+    let state = rd(f, "state.json");+    let kdir = f.parent().map(|p| p.join("kicad")).unwrap_or_else(|| PathBuf::from("kicad"));+    let pcb = state["kicad"]["pcb"].as_str().map(PathBuf::from);+    // the faces were stitched again (`stitch --top <photo>`) after the photos were measured: what was measured is of the old faces+    // Told by what the files hold, not by their times (a copied run folder has new times): the stitch record `prep` last ran on, or the one `check prep` passed on+    if !crate::intake::pending(&state) && has(f, "prep.json") && has(f, "stitch.json") {+        let moved = record(f)["stitchAtPrep"].as_str().is_some_and(|h| h != crate::sha256(&f.join("stitch.json"))) || flow::stale(f, "checks/prep.ok").iter().any(|x| x == "stitch.json");+        if moved { return run(&["prep"], "the faces were stitched again after the photos were measured: every photo is measured again"); }+    }+    if !matches!(step.name.as_str(), "intake" | "scale" | "parts") { if let Some(a) = unsnapped(f) { return a; } }+    match step.name.as_str() {+        "intake" => {+            // an unsorted set of photos (`init --photos`) is sorted and stitched before anything is measured+            if crate::intake::pending(&state) { return run(&["stitch"], "the photos are sorted into faces, tiles and overviews and stitched into one image per face"); }+            if !has(f, "prep.json") { return run(&["prep"], "every photo is measured"); }+            run(&["check", "prep"], "the photos' measurements are checked")+        }+        "scale" => need("scale", vec![+            "Pick one part body whose size a datasheet gives (body ids are drawn on reverse-flow/evidence/prep-<top photo>.jpg) and run: scale --body <id>=<length>x<width> --source \"<datasheet, package drawing>\"".into(),+            "A board with no rows of holes and four mounting holes at a spacing a document gives: scale --holes <x>x<y> --board <x>x<y> --source \"<document>\" [--flip x|y]".into(),+        ]),+        "parts" => {+            if !has(f, "detect/parts.json") {+                let bottom = image::open(f.join("board/bottom.jpg")).ok().map(|i| photomat::carries_parts(&i.to_rgb8())).unwrap_or(false);+                return if bottom { run(&["detect", "parts", "--bottom"], "part candidates on both faces: the bottom photo shows parts") } else { run(&["detect", "parts"], "part candidates on the top face: the bottom photo shows no parts") };+            }+            if !has(f, "parts.json") { return run(&["parts", "init"], "parts.json from the candidates"); }+            let r = record(f);+            let sha = crate::sha256(&f.join("parts.json"));+            if r["partsUntouched"].as_str() == Some(sha.as_str()) {+                return need("parts.json", vec![+                    "Read every crop in reverse-flow/evidence/parts/ (contact sheets: evidence/parts-sheet-*.jpg, map: evidence/parts-map.jpg) and complete reverse-flow/parts.json: for each part its ref, value, marking, mpn, function, the footprint where the size guess is wrong, rot, and pin1.mark for every part that fits one way; tag each field by how it is known (fieldTags).".into(),+                    "Add every candidate in `unassigned` as a part, or list it in `dismissed` with why. `decode <code>` reads resistor markings. Then run `auto` again: it snaps every footprint to its pads once, also when you ran `check parts` yourself first. A part you add after that is snapped with `parts snap` by you.".into(),+                ]);+            }+            let doc = rd(f, "parts.json");+            let used: std::collections::HashSet<&str> = doc["parts"].as_array().into_iter().flatten().filter_map(|p| p["candidate"].as_str()).chain(doc["dismissed"].as_array().into_iter().flatten().filter_map(|p| p["candidate"].as_str())).collect();+            let loose = rd(f, "detect/parts.json")["candidates"].as_array().into_iter().flatten().filter_map(|c| c["id"].as_str()).any(|c| !used.contains(c));+            if loose && r["partsReconciled"].as_str() != Some(sha.as_str()) { return run(&["parts", "reconcile"], "candidates that are fragments of placed parts are dismissed"); }+            // pads are snapped once, when no part has been yet. A later edit (a value from the bench, a marking read) must not move every part again:+            // that would make the copper, the placement audit and everything built on them stale for nothing. Parts added later are snapped by hand+            let never = !doc["parts"].as_array().into_iter().flatten().any(|p| p["snap"].is_object());+            if never && r["partsSnapped"].as_str() != Some(sha.as_str()) { return run(&["parts", "snap"], "each footprint is placed by its own pads in the photo"); }+            if let Some(a) = datasheets(f) { return a; }+            run(&["check", "parts"], "parts.json is checked")+        }+        "blocks" => {+            if !has(f, "blocks.json") {+                if !has(f, "detect/blocks-proposal.json") { return run(&["blocks", "propose"], "groups of parts that sit together"); }+                return need("blocks.json", vec!["Write reverse-flow/blocks.json from reverse-flow/detect/blocks-proposal.json: {\"blocks\": [{\"id\", \"name\", \"function\", \"parts\": [refs], \"tag\", \"evidence\" or \"tests\"}]}. Name each block, say what it does, move parts between groups where silkscreen or the circuit says so; every part is in exactly one block (holes and fiducials in a `mechanical` block). Then run `auto` again.".into()]);+            }+            run(&["check", "blocks"], "blocks.json is checked")+        }+        "copper" => {+            if !has(f, "detect/domes.json") { return run(&["copper", "prep"], "gap maps and the vias read from the photos"); }+            if !has(f, "copper.json") || rd(f, "copper.json")["from"] != json!("islands") { return run(&["copper", "trace"], "every island of copper on both faces"); }+            if let Some(a) = attribution(f) { return a; }+            run(&["check", "copper"], "copper.json is checked")+        }+        "mimic" => {+            if let Some(a) = attribution(f) { return a; }+            let r = record(f);+            if !has(f, crate::photolayers::FILE) && r["layersTraceFailed"].is_null() { return run(&["layers", "trace"], "silk, pad shapes, holes and outline read from the photos, for the build to draw"); }+            let built = pcb.as_ref().is_some_and(|p| ["copper.json", "parts.json", "nets.json", "layers.json", crate::photolayers::FILE].iter().all(|x| !has(f, x) || newer(p, &f.join(x))));+            if !built { return run(&["build"], "the KiCad copy of everything visible"); }+            // a board from a build that did not yet record what it left out or could not fill: built again once, so the mimic check has the reasons+            if !has(f, crate::kicad_out::LEFT_OUT) { return run(&["build"], "the board was built before `build` recorded what it left out and which pours it could not fill: it is built again"); }+            if let Some(p) = &pcb {+                let fresh_fp = newer_footprints(f, p);+                if !fresh_fp.is_empty() { return run(&["build"], &format!("a footprint file is newer than the board ({}): the copy is built again with it", fresh_fp.join(", "))); }+                // a part the build left out is said here, with the footprint it wants, not later as "not on the board"+                if let Some(a) = left_out(f, p) { return a; }+            }+            run(&["check", "mimic"], "the copy is checked against what was measured")+        }+        "snapshot" => {+            if let Some(a) = attribution(f) { return a; }+            Action::Run(vec!["snapshot".into(), "--name".into(), "mimic".into(), "--dir".into(), kdir.display().to_string()], "the copy as it stands is saved".into())+        }+        "connect" => {+            if let Some(a) = attribution(f) { return a; }+            let l = rd(f, "layers.json");+            let routed = l["candidates"].as_array().map(|a| !a.is_empty()).unwrap_or(false) && l["method"] != json!("stated");+            if !routed && !layerstate::current(f) { return run(&["layers", "state"], "the layer count is stated from what is known, without routing"); }+            if !fresh(f, "nets") { return run(&["check", "nets"], "nets.json is checked"); }+            // the copy carries the nets and the stated stackup: built before either changed, it is built again, so its ratsnest and+            // evidence classes are the ones the files give now and nothing an earlier route drew stays in it+            if let (Some(p), false) = (&pcb, routed) {+                let have = std::fs::read_to_string(p).map(|t| crate::kicad_in::copper_layers(&t)).unwrap_or(0) as u64;+                if l["proposed"].as_u64().is_some_and(|n| n != have) { return run(&["build"], &format!("the copy takes the stated {} layers", l["proposed"])); }+                if ["nets.json", "layers.json"].iter().any(|x| has(f, x) && !newer(p, &f.join(x))) { return run(&["build"], "the copy is older than the nets or the layer statement: it is built again with the hidden connections left unrouted"); }+            }+            if !fresh(f, "layers") { return run(&["check", "layers"], "the layer statement is checked"); }+            // the schematic follows the nets: its sheets are written again whenever parts, blocks, nets or their evidence are newer,+            // and KiCad's ERC, KiCad's netlist against nets.json and the sheet rules must pass before the review. A failure stops here+            // a run that passed the parts step before it asked for datasheets is asked here, before its chips are drawn as bare boxes+            if let Some(a) = datasheets(f) { return a; }+            let sch = state["kicad"]["sch"].as_str().map(PathBuf::from);+            let drawn = sch.as_ref().is_some_and(|s| s.is_file() && ["parts.json", "blocks.json", "nets.json", "net-evidence.json"].iter().all(|x| !has(f, x) || newer(s, &f.join(x))));+            if !drawn { return run(&["schematic"], "the schematic sheets are drawn from the parts, blocks and nets as they stand"); }+            run(&["check", "schematic"], "the schematic is checked: KiCad's ERC, its netlist against nets.json, the sheet rules")+        }+        "review" => {+            let r = record(f);+            let fid = rd(f, "fidelity.json");+            let fid_stale = fid.is_null() || fid["inputs"].as_object().into_iter().flatten().any(|(k, h)| h.as_str().is_some_and(|w| !flow::matches_hash(&f.join(k), w)));+            let at = format!("{}|{}", attrib::geometry_hash(&f.join("copper.json")), crate::sha256(&f.join("parts.json")));+            if fid_stale && pcb.is_some() && r["fidelityTriedAt"].as_str() != Some(at.as_str()) { return run(&["check", "fidelity"], "what the copy draws is scored against the photos, for the review"); }+            let review = rd(f, "review.json");+            let last = review["passes"].as_array().and_then(|p| p.iter().rev().find(|p| p["scores"].is_object()).cloned());+            let now: std::collections::BTreeMap<String, String> = checks::current_hashes(f);+            let Some(last) = last else { return run(&["review", "pass"], "the first measured pass of the review loop") };+            if r["reviewRedo"] == json!(true) {+                let note = last["note"].as_str().unwrap_or("").trim().to_string();+                if last["verdict"] == json!("STOPPED") && !note.is_empty() {+                    let max = last["maxPasses"].as_u64().unwrap_or(1).to_string();+                    return run(&["review", "pass", "--max-passes", &max, "--note", &note], "the recorded pass no longer matches the files: the review measures again and keeps its declared stop");+                }+                return run(&["review", "pass"], "the recorded pass no longer matches the files: the review measures again");+            }+            // every sample the last pass found void, stale or too small is drawn before the next pass, so one pass sees them all+            let drawn: Vec<String> = if r["drawn"]["pass"] == last["n"] { r["drawn"]["cats"].as_array().into_iter().flatten().filter_map(|c| c.as_str().map(str::to_string)).collect() } else { vec![] };+            if last["verdict"] == json!("CONTINUE") { if let Err(a) = audits(&last["scores"], &drawn) { return a; } }+            let then: std::collections::BTreeMap<String, String> = serde_json::from_value(last["hashes"].clone()).unwrap_or_default();+            if then != now {+                // a declared stop (a STOPPED pass that carries its reason) holds when later steps change files: the review+                // measures again under the same limit and with the same reason, and does not start asking for audits again+                let note = last["note"].as_str().unwrap_or("").trim().to_string();+                if last["verdict"] == json!("STOPPED") && !note.is_empty() {+                    let max = last["maxPasses"].as_u64().unwrap_or(1).to_string();+                    return run(&["review", "pass", "--max-passes", &max, "--note", &note], "files changed since the declared stop: the review measures again and stops again for the same reason");+                }+                return run(&["review", "pass"], "files changed since the last pass: the review measures again");+            }+            match last["verdict"].as_str().unwrap_or("") {+                "TARGET MET" | "STALLED" | "STOPPED" => run(&["check", "review"], "the review's verdict is checked against the files"),+                _ => match audits(&last["scores"], &drawn) {+                    Err(a) => a,+                    Ok(lines) if !lines.is_empty() => need("independent audit verdicts", lines),+                    Ok(_) => run(&["review", "pass"], "no audit is outstanding: another pass shows whether anything still rises"),+                },+            }+        }+        "tests" => {+            let t = f.join("tests.json");+            let generated = t.is_file() && items::ARTIFACTS.iter().all(|a| !has(f, a) || newer(&t, &f.join(a)));+            if !generated { return run(&["tests", "generate"], "a test for every INFERRED or UNKNOWN item"); }+            run(&["check", "tests"], "tests.json is checked")+        }+        "report" => {+            // the net pictures are drawn before the report is written, so whoever writes it has them; `report` draws them again when it passes+            let drawn = f.join("nets/index.md");+            let stale = !drawn.is_file() || ["nets.json", "detect/attribution.json", "copper.json"].iter().any(|x| has(f, x) && !newer(&drawn, &f.join(x)));+            if stale && has(f, "detect/attribution.json") && record(f)["netsDrawFailedAt"].as_str() != Some(crate::sha256(&f.join("nets.json")).as_str()) { return run(&["nets", "draw"], "every net graded by its weakest link and drawn under reverse-flow/nets/, for the report"); }+            if !has(f, "report.md") {+                if !has(f, "report-tables.md") || !newer(&f.join("report-tables.md"), &f.join("checks/tests.ok")) { return run(&["report"], "the generated tables"); }+                let (s, verdict) = crate::report::current(f);+                return need("report.md", vec![+                    "Write reverse-flow/report.md around reverse-flow/report-tables.md: what the board is, the blocks, the layer statement and its evidence, both scores, copied versus inferred, the nets by level (reverse-flow/nets/index.md and confidence.jpg), the tests.".into(),+                    format!("It must carry this line word for word: {}", crate::confidence::overall_line(&s, &verdict)),+                    "`report --draft` writes a starting report.md from the tables with that line in it. Then run `auto` again.".into(),+                ].into_iter().chain(crate::notread::faces(f).iter().map(|x| format!("It must also carry this line word for word: {}", crate::notread::required(x))))+                 .chain(crate::window::size_sentence(f).filter(|_| !crate::window::cut(f).is_empty()).map(|s| format!("The photos cut the board off: where you state a size, say it is the photographed window. The tables say: {s}"))).collect());+            }+            run(&["report"], "the report is checked against the computed scores")+        }+        other => need(other, vec![step.todo.clone()]),+    }+}++/// `next` and every step hint: the exact next action.+pub fn now_lines(f: &Path, fl: &flow::Flow) -> Vec<String> {+    match plan(f, fl) {+        Action::Run(cmd, why) => vec![format!("now: `{}` ({why}). `auto` runs this and every program-only step after it", cmd.join(" "))],+        Action::NeedAi(name, lines) => { let mut v = vec![format!("now: your input is needed: {name}")]; v.extend(lines); v }+        Action::Complete => vec![],+    }+}++/// What a finished command changed that the plan should remember.+fn after(f: &Path, cmd: &[String], ok: bool) {+    let c: Vec<&str> = cmd.iter().map(String::as_str).collect();+    let parts = || json!(crate::sha256(&f.join("parts.json")));+    match (c.as_slice(), ok) {+        (["parts", "init"], true) => remember(f, "partsUntouched", parts()),+        (["parts", "reconcile"], _) => remember(f, "partsReconciled", parts()),+        (["parts", "snap"], _) => remember(f, "partsSnapped", parts()),+        (["prep"], true) => { if has(f, "stitch.json") { remember(f, "stitchAtPrep", json!(crate::sha256(&f.join("stitch.json")))); } }+        (["copper", "trace"], true) => remember(f, "placementAtTrace", json!(placement_key(f))),+        (["connect", "measure"], true) => remember(f, "placementAtMeasure", json!(placement_key(f))),+        (["connect", "attribute"], true) => { remember(f, "netsAfterAttribute", json!(crate::sha256(&f.join("nets.json")))); remember(f, "pinNamesAtAttribute", json!(dspins::Names::load(f).key())); remember(f, "notReadAtAttribute", json!(crate::notread::key(f))); }+        (["layers", "trace"], false) => remember(f, "layersTraceFailed", json!(true)),+        (["nets", "draw"], false) => remember(f, "netsDrawFailedAt", json!(crate::sha256(&f.join("nets.json")))),+        (["review", "pass"], _) | (["review"], _) => remember(f, "reviewRedo", json!(false)),+        (["audit", "draw", "--category", cat, ..], _) => {+            let n = rd(f, "review.json")["passes"].as_array().and_then(|p| p.iter().rev().find(|p| p["scores"].is_object()).map(|p| p["n"].clone())).unwrap_or(Value::Null);+            let r = record(f);+            let mut cats: Vec<Value> = if r["drawn"]["pass"] == n { r["drawn"]["cats"].as_array().cloned().unwrap_or_default() } else { vec![] };+            cats.push(json!(cat));+            remember(f, "drawn", json!({"pass": n, "cats": cats}));+        }+        (["check", "fidelity"], _) => remember(f, "fidelityTriedAt", json!(format!("{}|{}", attrib::geometry_hash(&f.join("copper.json")), crate::sha256(&f.join("parts.json"))))),+        _ => {}+    }+}++/// What the plan reads, as sizes and times: when a command passed and none of this moved, running it again cannot help.+fn fingerprint(f: &Path, pcb: Option<&Path>) -> String {+    let mut files: Vec<PathBuf> = pcb.map(|p| vec![p.to_path_buf()]).unwrap_or_default();+    let mut walk = |dir: PathBuf, depth: usize| {+        let mut stack = vec![(dir, depth)];+        while let Some((d, k)) = stack.pop() {+            for e in std::fs::read_dir(&d).into_iter().flatten().flatten() {+                let (p, name) = (e.path(), e.file_name().to_string_lossy().to_string());+                if p.is_dir() { if k > 0 { stack.push((p, k - 1)); } continue; }+                if matches!(name.as_str(), "run.jsonl" | "views.json" | "auto-log.txt" | "serve.log") || !(name.ends_with(".json") || name.ends_with(".jsonl") || name.ends_with(".ok") || name.ends_with(".md")) { continue; }+                files.push(p);+            }+        }+    };+    walk(f.to_path_buf(), 0);+    for (d, k) in [("checks", 0), ("snapshots", 1), ("detect", 1), ("audits", 1), ("bench", 0)] { walk(f.join(d), k); }+    files.sort();+    let lines: Vec<String> = files.iter().filter_map(|p| { let m = std::fs::metadata(p).ok()?; Some(format!("{}|{}|{:?}", p.display(), m.len(), m.modified().ok()?)) }).collect();+    crate::confidence::sha256_hex(lines.join("\n").as_bytes())+}++/// A step that ended without its own `ERROR:` line (killed, or dead on an allocation) still gets a reason: how it ended and+/// the last thing it printed. `None` when the step passed or said what was wrong itself.+fn died(status: &std::process::ExitStatus, text: &str) -> Option<String> {+    if status.success() || text.lines().any(|l| l.starts_with("ERROR:")) { return None; }+    #[cfg(unix)]+    let signal = { use std::os::unix::process::ExitStatusExt; status.signal() };+    #[cfg(not(unix))]+    let signal: Option<i32> = None;+    Some(died_text(status.code(), signal, text))+}++fn died_text(code: Option<i32>, signal: Option<i32>, text: &str) -> String {+    let last: Vec<&str> = text.lines().map(str::trim).filter(|l| !l.is_empty()).rev().take(2).collect();+    let said = if last.is_empty() { "it printed nothing".to_string() } else { format!("the last it printed: {}", last.into_iter().rev().map(|l| l.chars().take(240).collect::<String>()).collect::<Vec<_>>().join(" | ")) };+    let memory = text.contains("memory allocation of") || text.to_lowercase().contains("out of memory") || text.contains("capacity overflow");+    let how = match (signal, code) {+        (Some(9), _) => "the step was killed (signal 9) before it could answer: the system or the memory guard stopped it, most often for running out of memory".to_string(),+        (Some(6), _) | (_, Some(134)) if memory => "the step ran out of memory and stopped (an allocation failed)".to_string(),+        (Some(n), _) => format!("the step was stopped by signal {n} before it could answer"),+        (_, Some(c)) if memory => format!("the step ran out of memory and stopped (an allocation failed, exit code {c})"),+        (_, Some(c)) => format!("the step ended with exit code {c} and no answer of its own (a crash in the program)"),+        _ => "the step ended with no answer of its own".to_string(),+    };+    format!("{how}; {said}")+}++/// How one `auto` ended.+pub struct End { pub state: &'static str, pub line: String, pub hints: Vec<String>, pub ran: Vec<String> }++/// Run program-only steps until the AI is needed, the run is complete, a command fails or `max` commands have run.+pub fn run_all(f: &Path, project_dir: &Path, fl: &flow::Flow, thread: &str, max: usize, say: &mut dyn FnMut(String)) -> End {+    let exe = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("adom-aiflow-reverse"));+    let mut ran: Vec<String> = vec![];+    let mut last: Option<(Vec<String>, bool, bool)> = None;+    let mut review_redos = 0;+    let pcb_of = || rd(f, "state.json")["kicad"]["pcb"].as_str().map(PathBuf::from);+    let end = |state: &'static str, line: String, mut hints: Vec<String>, ran: &[String]| {+        // what the run carries as failed and no input of yours changes: said at every stop, so nobody waits on it or takes it as passed+        for s in crate::notread::sentences(f) { hints.push(format!("Carried as failed, not waiting for you: {s}")); }+        remember(f, "last", json!({"state": state, "line": line, "hints": hints, "ran": ran, "at": crate::ledger::now()}));+        End { state, line, hints, ran: ran.to_vec() }+    };+    loop {+        let action = plan(f, fl);+        let (cmd, why) = match action {+            Action::Complete => {+                // the video is named in the last line either way: where it is, or plainly that it was not built and why+                let failed = !crate::video::fresh(f) && record(f)["videoFailedAt"].as_str() == Some(crate::video::input_key(f).as_str());+                let line = if failed { format!("AUTO COMPLETE: every step is done; the video was NOT built: {}", record(f)["videoError"].as_str().unwrap_or("see reverse-flow/video/build-log.txt")) }+                    else { format!("AUTO COMPLETE: every step is done; video: {}", crate::video::record(f)["file"].as_str().unwrap_or("none")) };+                let mut hints = vec!["`confidence` prints both scores; `bench plan` writes the optional instructions for a person with the real board".to_string()];+                if failed { hints.push("the run is complete without the video: fix what the line names and run `video build` (reverse-flow/video/build-log.txt has the tool's output)".into()); }+                return end("complete", line, hints, &ran);+            }+            Action::NeedAi(name, lines) => return end("needs-ai", format!("AUTO NEEDS AI: {name}"), lines, &ran),+            Action::Run(cmd, why) => (cmd, why),+        };+        // the same command again after it passed and changed nothing the plan reads: stop, never loop+        if last.as_ref().is_some_and(|(c, ok, moved)| *ok && !*moved && *c == cmd) { return end("failed", format!("AUTO FAILED: `{}` passed and is still next: the flow is not moving", cmd.join(" ")), vec!["This is a defect in the flow, not in the board: report it with reverse-flow/auto-log.txt".into()], &ran); }+        if ran.len() >= max { return end("failed", format!("AUTO FAILED: {max} commands run without reaching an end"), vec![format!("next would be `{}`; run `auto` again to go on, or raise --max-steps", cmd.join(" "))], &ran); }+        // a sample is drawn once per run of `auto`: asked for again, the answer is the auditor's to give+        if cmd.first().map(String::as_str) == Some("audit") && ran.contains(&cmd.join(" ")) { return end("needs-ai", "AUTO NEEDS AI: independent audit verdicts".into(), vec![format!("`{}` was drawn and is still what the review waits for: `audit status` shows every sample, `review pass` says what each needs. Finish the current sample with an auditor who did not produce this run, then run `auto` again.", cmd.join(" "))], &ran); }+        let before = fingerprint(f, pcb_of().as_deref());+        say(format!("> {}   ({why})", cmd.join(" ")));+        let out = std::process::Command::new(&exe).arg("--ai-thread").arg(thread).arg("--project").arg(project_dir).args(&cmd).output();+        let (ok, text) = match out { Ok(o) => { let t = String::from_utf8_lossy(&o.stdout).to_string() + &String::from_utf8_lossy(&o.stderr); let died = died(&o.status, &t); (o.status.success(), match died { Some(d) => format!("{t}\nERROR: {d}\n"), None => t }) } Err(e) => (false, format!("ERROR: could not start {}: {e}", exe.display())) };+        if let Ok(mut lf) = std::fs::OpenOptions::new().create(true).append(true).open(f.join(LOG)) { use std::io::Write; let _ = writeln!(lf, "## {} > {}\n{text}", crate::ledger::now(), cmd.join(" ")); }+        // a step that passed answers OK first; one that failed is known by its ERROR line, its own or the one `died` wrote for it+        let head = text.lines().find(|l| if ok { l.starts_with("OK:") } else { l.starts_with("ERROR:") }).or_else(|| text.lines().find(|l| l.starts_with("OK:") || l.starts_with("ERROR:"))).unwrap_or("").to_string();+        let died_line = !ok && head.starts_with("ERROR: the step ");+        let hints: Vec<String> = text.lines().filter(|l| l.starts_with("Hint: ")).map(|l| l[6..].to_string()).collect();+        say(format!("  {head}"));+        ran.push(cmd.join(" "));+        after(f, &cmd, ok);+        let moved = fingerprint(f, pcb_of().as_deref()) != before;+        if !ok {+            let c: Vec<&str> = cmd.iter().map(String::as_str).collect();+            match c.as_slice() {+                // the photos' own layers and their score feed the review; the flow goes on without them and the review says so+                // the video is a deliverable, not a gate: the reason is kept for the last line and the run completes+                ["bench", "plan"] => { remember(f, "benchPlanFailed", json!(true)); say("  not a gate: the run completes without the bench plan; `bench plan` tries again".into()); last = Some((cmd, false, moved)); continue; }+                ["video", "build"] => {+                    remember(f, "videoFailedAt", json!(crate::video::input_key(f)));+                    remember(f, "videoError", json!(head.trim_start_matches("ERROR: ").trim_start_matches("the video was not built: ")));+                    say("  not a gate: the run completes without the video, and the last line says so".into());+                    last = Some((cmd, false, moved)); continue;+                }+                ["layers", "trace"] | ["check", "fidelity"] | ["nets", "draw"] => { say("  not a gate: the flow goes on, and the review counts the missing figure against the score".into()); last = Some((cmd, false, moved)); continue; }+                // the review's record no longer matches the files (a bench result came in, a score moved): one more pass measures again+                ["check", "review"] if review_redos < 2 => { review_redos += 1; remember(f, "reviewRedo", json!(true)); say("  the recorded pass no longer matches the files: the review measures again".into()); last = Some((cmd, false, moved)); continue; }+                // a holes board whose flip the holes cannot settle is settled by `scale --holes`+                ["check", "prep"] if !has(f, "scale.json") => return end("needs-ai", "AUTO NEEDS AI: scale".into(), { let mut v = hints.clone(); v.push("The photos' check waits for the scale to settle which way the board was turned over: scale --holes <x>x<y> --board <x>x<y> --source \"<document>\" [--flip x|y], or scale --body <id>=<L>x<W> --source \"<datasheet>\". Then run `auto` again.".into()); v }, &ran),+                // the build could not be written at all: what it says is the AI's to fix+                ["build"] => return end("needs-ai", "AUTO NEEDS AI: fix what `build` lists".into(), { let mut v = vec![head.clone()]; v.extend(hints.iter().take(40).cloned()); v.push("Then run `auto` again.".into()); v }, &ran),+                // a failed check lists what to fix: that is the AI's input+                ["check", what, ..] => return end("needs-ai", format!("AUTO NEEDS AI: fix what `check {what}` lists"), { let mut v: Vec<String> = hints.iter().take(40).cloned().collect(); if hints.len() > 40 { v.push(format!("and {} more: run `check {what}`", hints.len() - 40)); } v.push("Then run `auto` again.".into()); v }, &ran),+                ["report"] => return end("needs-ai", "AUTO NEEDS AI: report.md".into(), { let mut v = hints.clone(); v.push("Correct reverse-flow/report.md, then run `auto` again.".into()); v }, &ran),+                // a sample that cannot be drawn on this board is recorded as such: the category stays at its cap and the next pass no longer waits for it+                ["audit", "draw", ..] if head.contains("planted claims") => { say("  no sample can be drawn on this board: the category stays at its cap, and the review goes on without this audit".into()); last = Some((cmd, false, moved)); continue; }+                ["audit", "draw", ..] => return end("needs-ai", "AUTO NEEDS AI: independent audit verdicts".into(), { let mut v = vec![head.clone()]; v.extend(hints.clone()); v.push("`audit status` shows every sample; finish the current one, then run `auto` again.".into()); v }, &ran),+                _ => return end("failed", format!("AUTO FAILED: `{}`: {}", cmd.join(" "), head.trim_start_matches("ERROR: ")), { let mut v = hints; if head.contains("memory") { v.push("This step needs more memory than it was allowed: run it with nothing else running, or raise the limit it runs under. If it still fails, this is a defect in the flow, not in the board: report it with reverse-flow/auto-log.txt".into()); } else if died_line { v.push("The step did not end by itself: this is a defect in the flow, not in the board. Report it with reverse-flow/auto-log.txt".into()); } v }, &ran),+            }+        }+        last = Some((cmd, ok, moved));+    }+}++#[cfg(test)]+mod tests {+    use super::*;++    fn dir(name: &str) -> PathBuf {+        let d = std::env::temp_dir().join(format!("auto-test-{name}-{}", std::process::id())).join("reverse-flow");+        let _ = std::fs::remove_dir_all(&d);+        std::fs::create_dir_all(d.join("checks")).unwrap();+        std::fs::create_dir_all(d.join("detect")).unwrap();+        d+    }+    fn put(d: &Path, f: &str, v: Value) { if let Some(p) = d.join(f).parent() { std::fs::create_dir_all(p).unwrap(); } std::fs::write(d.join(f), v.to_string()).unwrap(); }+    fn pass(d: &Path, check: &str, files: &[&str]) {+        let m: serde_json::Map<String, Value> = files.iter().map(|f| (f.to_string(), json!(if check == "copper" && *f == "copper.json" { attrib::geometry_hash(&d.join(f)) } else { crate::sha256(&d.join(f)) }))).collect();+        put(d, &format!("checks/{check}.ok"), json!({"check": check, "files": m}));+    }+    fn cmd(a: &Action) -> String { match a { Action::Run(c, _) => c.join(" "), Action::NeedAi(n, _) => format!("AI: {n}"), Action::Complete => "complete".into() } }++    #[test]+    fn the_plan_walks_the_early_steps_and_names_each_input_the_ai_owes() {+        let d = dir("early"); let fl = flow::load();+        put(&d, "state.json", json!({"photos": []}));+        assert_eq!(cmd(&plan(&d, &fl)), "prep");+        put(&d, "prep.json", json!({"photos": []}));+        assert_eq!(cmd(&plan(&d, &fl)), "check prep");+        pass(&d, "prep", &["prep.json"]);+        // the scale takes a judgement: which body, from which datasheet+        let a = plan(&d, &fl);+        assert_eq!(cmd(&a), "AI: scale");+        if let Action::NeedAi(_, lines) = &a { assert!(lines.iter().any(|l| l.contains("scale --body")) && lines.iter().any(|l| l.contains("scale --holes"))); }+        put(&d, "scale.json", json!({})); std::fs::create_dir_all(d.join("board")).unwrap(); std::fs::write(d.join("board/top.jpg"), "x").unwrap();+        assert_eq!(cmd(&plan(&d, &fl)), "detect parts");+        put(&d, "detect/parts.json", json!({"candidates": [{"id": "P001"}, {"id": "P002"}]}));+        assert_eq!(cmd(&plan(&d, &fl)), "parts init");+        // parts.json exactly as `parts init` left it: the crops have not been read yet+        put(&d, "parts.json", json!({"parts": [{"ref": "R1", "candidate": "P001", "footprint": "R_0402"}], "unassigned": [{"candidate": "P002"}], "dismissed": []}));+        after(&d, &["parts".into(), "init".into()], true);+        let a = plan(&d, &fl);+        assert_eq!(cmd(&a), "AI: parts.json");+        if let Action::NeedAi(_, lines) = &a { assert!(lines[0].contains("reverse-flow/parts.json")); }+        // once edited, the program goes on: fragments, pads, the check+        put(&d, "parts.json", json!({"parts": [{"ref": "R1", "candidate": "P001", "footprint": "R_0402", "function": "pull-up"}], "unassigned": [{"candidate": "P002"}], "dismissed": []}));+        assert_eq!(cmd(&plan(&d, &fl)), "parts reconcile");+        after(&d, &["parts".into(), "reconcile".into()], true);+        assert_eq!(cmd(&plan(&d, &fl)), "parts snap");+        after(&d, &["parts".into(), "snap".into()], true);+        assert_eq!(cmd(&plan(&d, &fl)), "check parts");+        // a later edit to a snapped part (a value from the bench) does not move the parts again+        put(&d, "parts.json", json!({"parts": [{"ref": "R1", "candidate": "P001", "footprint": "R_0402", "function": "pull-up", "snap": {"method": "pads"}, "value": "10k"}, {"ref": "R2", "footprint": "R_0402"}], "unassigned": [], "dismissed": [{"candidate": "P002", "why": "silk"}]}));+        assert_eq!(cmd(&plan(&d, &fl)), "check parts");+        pass(&d, "parts", &["parts.json"]);+        assert_eq!(cmd(&plan(&d, &fl)), "blocks propose");+        put(&d, "detect/blocks-proposal.json", json!({"groups": []}));+        assert_eq!(cmd(&plan(&d, &fl)), "AI: blocks.json");+        std::fs::remove_dir_all(d.parent().unwrap()).ok();+    }++    #[test]+    fn the_plan_stops_once_for_datasheet_pins_and_goes_on_with_a_sheet_or_a_reasoned_no() {+        let d = dir("datasheets"); let fl = flow::load();+        put(&d, "state.json", json!({"photos": []}));+        put(&d, "prep.json", json!({})); pass(&d, "prep", &["prep.json"]);+        put(&d, "scale.json", json!({})); std::fs::create_dir_all(d.join("board")).unwrap(); std::fs::write(d.join("board/top.jpg"), "x").unwrap();+        put(&d, "detect/parts.json", json!({"candidates": []}));+        // two chips whose markings were read, one that was not, a resistor: snapped already, so the check would be next+        put(&d, "parts.json", json!({"parts": [+            {"ref": "U1", "value": "XY1234", "marking": "XY1234", "fieldTags": {"value": "SEEN"}, "snap": {"method": "pads"}, "pin1": {"mark": "dot at the top left corner", "tag": "SEEN"}},+            {"ref": "U2", "value": "LM000", "marking": "LM000", "fieldTags": {"value": "SEEN"}},+            {"ref": "U3", "value": "regulator", "marking": "unreadable", "fieldTags": {"value": "UNKNOWN"}},+            {"ref": "R1", "value": "10k", "marking": "103", "fieldTags": {"value": "SEEN"}}], "unassigned": [], "dismissed": []}));+        let a = plan(&d, &fl);+        assert_eq!(cmd(&a), "AI: datasheet pins");+        if let Action::NeedAi(_, lines) = &a {+            assert!(lines[0].contains("reverse-flow/datasheets/<ref>.pins.json") && lines[1].contains("\"available\": false"));+            assert!(lines.iter().any(|l| l.starts_with("U1: XY1234")) && lines.iter().any(|l| l.starts_with("U2: LM000")));+            assert!(!lines.iter().any(|l| l.starts_with("U3") || l.starts_with("R1")), "a chip nobody read and a resistor are not asked for");+        }+        // one answered "none available" with a reason: the other still waits+        put(&d, "datasheets/U2.pins.json", json!({"part": "U2", "available": false, "reason": "the top mark fits three parts"}));+        let a = plan(&d, &fl);+        assert_eq!(cmd(&a), "AI: datasheet pins");+        if let Action::NeedAi(_, lines) = &a { assert!(!lines.iter().any(|l| l.starts_with("U2"))); }+        // a file that does not pass is the AI's to fix, with the reason+        put(&d, "datasheets/U1.pins.json", json!({"part": "U1", "document": {"name": "XY1234"}, "pins": [{"pad": "1", "name": "VIN", "type": "power_in"}]}));+        let a = plan(&d, &fl);+        assert_eq!(cmd(&a), "AI: datasheet pins");+        if let Action::NeedAi(_, lines) = &a { assert!(lines.iter().any(|l| l.contains("document.revision"))); }+        // a sheet that passes: the program writes the pins into parts.json, then goes on to the check, and does not stop again+        put(&d, "datasheets/U1.pins.json", json!({"part": "U1", "document": {"name": "XY1234", "revision": "B", "page": 3}, "pins": [{"pad": "1", "name": "VIN", "type": "power_in"}, {"pad": "2", "name": "GND", "type": "ground"}]}));+        assert_eq!(cmd(&plan(&d, &fl)), "datasheet apply");+        dspins::apply(&d).unwrap();+        assert_eq!(items::read_json(&d.join("parts.json")).unwrap()["parts"][0]["pins"]["1"]["name"], "VIN");+        assert_eq!(cmd(&plan(&d, &fl)), "check parts");+        // part, then orientation, then pinout: a chip with a pinout and an inferred orientation is asked about, by name, before anything is built on it+        let mut pj = items::read_json(&d.join("parts.json")).unwrap();+        pj["parts"][0]["pin1"] = json!({"mark": "taken from the reading direction of the marking", "tag": "INFERRED"});+        put(&d, "parts.json", pj.clone());+        let a = plan(&d, &fl);+        assert_eq!(cmd(&a), "AI: orientation of chips with a pinout");+        if let Action::NeedAi(_, lines) = &a { assert!(lines.len() == 2 && lines[1].starts_with("U1 (package") && lines[1].contains("XY1234") && lines[1].contains("pin1: tagged INFERRED") && lines[1].contains("Look for a dot")); }+        // settled, or said to be not determinable: the plan goes on either way+        pj["parts"][0]["pin1"] = json!({"mark": "looked for a dot, a notch and a bevel: none", "tag": "UNKNOWN"});+        put(&d, "parts.json", pj.clone());+        assert_eq!(cmd(&plan(&d, &fl)), "check parts");+        pj["parts"][0]["pin1"] = json!({"mark": "dot at the top left corner", "tag": "SEEN"});+        put(&d, "parts.json", pj);+        assert_eq!(cmd(&plan(&d, &fl)), "check parts");+        // names the sheets give that attribution has not seen send the plan back to it+        put(&d, "copper.json", json!({"from": "islands", "zones": [], "traces": [], "vias": []}));+        put(&d, "detect/attrib/measure.json", json!({"copperSha": attrib::zones_key(&rd(&d, "copper.json")), "stitch": []}));+        put(&d, "nets.json", json!({"nets": []}));+        put(&d, "detect/attribution.json", json!({}));+        remember(&d, "netsAfterAttribute", json!(crate::sha256(&d.join("nets.json"))));+        // the nets are built from the pinout first, then the copper is held against them+        assert_eq!(attribution(&d).map(|a| cmd(&a)).as_deref(), Some("connect pinout"));+        let s = pinnets::build(&d).unwrap();+        assert_eq!((s["pinoutNets"].as_u64(), s["pinoutNetsDatasheet"].as_u64()), (Some(2), Some(2)));+        assert_eq!(attribution(&d).map(|a| cmd(&a)).as_deref(), Some("connect attribute"));+        after(&d, &["connect".into(), "attribute".into()], true);+        assert!(attribution(&d).is_none());+        std::fs::remove_dir_all(d.parent().unwrap()).ok();+    }++    #[test]+    fn attribution_comes_before_the_copper_check_and_an_edit_to_the_nets_sends_the_plan_back_by_itself() {+        let d = dir("order"); let fl = flow::load();+        put(&d, "state.json", json!({"photos": [], "kicad": {"pcb": d.join("x.kicad_pcb").display().to_string()}}));+        put(&d, "prep.json", json!({})); pass(&d, "prep", &["prep.json"]);+        put(&d, "scale.json", json!({})); std::fs::create_dir_all(d.join("board")).unwrap(); std::fs::write(d.join("board/top.jpg"), "x").unwrap();+        put(&d, "parts.json", json!({"parts": []})); pass(&d, "parts", &["parts.json"]);+        put(&d, "blocks.json", json!({"blocks": []})); pass(&d, "blocks", &["blocks.json", "parts.json"]);+        assert_eq!(cmd(&plan(&d, &fl)), "copper prep");+        put(&d, "detect/domes.json", json!({"domes": []}));+        assert_eq!(cmd(&plan(&d, &fl)), "copper trace");+        let copper = json!({"from": "islands", "zones": [{"id": "z", "net": "UNK_1", "layer": "F.Cu", "polygon_mm": [[0, 0], [1, 0], [1, 1]]}], "traces": [], "vias": []});+        put(&d, "copper.json", copper.clone());+        // no net named by anyone and no nets.json: the photos are read for attribution, then attribution starts the nets itself+        assert_eq!(cmd(&plan(&d, &fl)), "connect measure");+        put(&d, "detect/attrib/measure.json", json!({"copperSha": attrib::zones_key(&copper), "stitch": []}));+        assert_eq!(cmd(&plan(&d, &fl)), "connect attribute");+        put(&d, "nets.json", json!({"nets": []}));+        put(&d, "detect/attribution.json", json!({}));+        after(&d, &["connect".into(), "attribute".into()], true);+        assert_eq!(cmd(&plan(&d, &fl)), "check copper");+        pass(&d, "copper", &["copper.json"]);+        // built, checked and saved+        put(&d, crate::photolayers::FILE, json!({}));+        assert_eq!(cmd(&plan(&d, &fl)), "build");+        std::fs::write(d.join("x.kicad_pcb"), "(kicad_pcb (layers (0 \"F.Cu\" signal) (2 \"B.Cu\" signal)))").unwrap();+        // a board with no record of what its build left out is from an older build: built again once+        assert_eq!(cmd(&plan(&d, &fl)), "build");+        put(&d, crate::kicad_out::LEFT_OUT, json!({"leftOut": [], "noFill": []}));+        assert_eq!(cmd(&plan(&d, &fl)), "check mimic");+        pass(&d, "mimic", &["parts.json", "copper.json"]);+        assert!(cmd(&plan(&d, &fl)).starts_with("snapshot --name mimic --dir "));+        put(&d, "snapshots/mimic/manifest.json", json!({"inputs": {"copper.json": crate::sha256(&d.join("copper.json")), "parts.json": crate::sha256(&d.join("parts.json"))}}));+        // the connect step never asks for the router+        assert_eq!(cmd(&plan(&d, &fl)), "layers state");+        assert!(!fl.steps.iter().find(|s| s.name == "connect").unwrap().done.iter().any(|x| x.contains("route")));+        // nets.json gone missing: attribution is the step that writes it, not a check that would only fail on it+        std::fs::remove_file(d.join("nets.json")).unwrap();+        assert_eq!(cmd(&plan(&d, &fl)), "connect attribute");+        put(&d, "nets.json", json!({"nets": []}));+        after(&d, &["connect".into(), "attribute".into()], true);+        assert_eq!(cmd(&plan(&d, &fl)), "layers state");+        // the AI names pins from a datasheet: attribution runs again, which rewrites the nets in copper.json+        put(&d, "nets.json", json!({"nets": [{"name": "VCC", "pins": ["U1.1"], "tag": "DATASHEET", "source": "datasheet p.3"}]}));+        assert_eq!(cmd(&plan(&d, &fl)), "connect attribute");+        let mut named = copper.clone(); named["zones"][0]["net"] = json!("VCC");+        put(&d, "copper.json", named);+        put(&d, "detect/attribution.json", json!({}));+        after(&d, &["connect".into(), "attribute".into()], true);+        // the copper check reads shapes and stays; what was built on the nets is redone without anyone asking+        assert!(flow::exists(&d, "checks/copper.ok"));+        assert_eq!(cmd(&plan(&d, &fl)), "build");+        std::fs::write(d.join("x.kicad_pcb"), "(kicad_pcb (layers (0 \"F.Cu\" signal) (2 \"B.Cu\" signal)))").unwrap();+        assert_eq!(cmd(&plan(&d, &fl)), "check mimic");+        pass(&d, "mimic", &["parts.json", "copper.json"]);+        assert!(cmd(&plan(&d, &fl)).starts_with("snapshot"));+        // every step up to the report done: the net pictures are drawn before the report is asked for, and again when the nets change+        put(&d, "layers.json", json!({"candidates": [{"layers": 2}], "method": "routed"}));+        for (c, files) in [("nets", vec!["nets.json", "parts.json"]), ("layers", vec!["layers.json", "nets.json", "copper.json"]), ("review", vec!["review.json"]), ("tests", vec!["tests.json"])] {+            for x in &files { if !d.join(x).is_file() { put(&d, x, json!({})); } }+            pass(&d, c, &files);+        }+        put(&d, "snapshots/mimic/manifest.json", json!({"inputs": {"copper.json": crate::sha256(&d.join("copper.json")), "parts.json": crate::sha256(&d.join("parts.json"))}}));+        // the schematic belongs to the connect step: drawn once the nets and the layer statement are checked, then checked by itself+        assert_eq!(flow::current(&fl, &d).map(|s| s.name.as_str()), Some("connect"));+        assert_eq!(cmd(&plan(&d, &fl)), "schematic");+        let sch = d.join("x.kicad_sch");+        std::fs::write(&sch, "(kicad_sch)").unwrap();+        put(&d, "state.json", json!({"photos": [], "kicad": {"pcb": d.join("x.kicad_pcb").display().to_string(), "sch": sch.display().to_string()}}));+        assert_eq!(cmd(&plan(&d, &fl)), "check schematic");+        pass(&d, "schematic", &["nets.json", "parts.json", "blocks.json"]);+        assert_eq!(flow::current(&fl, &d).map(|s| s.name.as_str()), Some("report"));+        // parts, blocks or nets newer than the sheets: the check is stale, the flow comes back, and the sheets are drawn again first+        std::thread::sleep(std::time::Duration::from_millis(30));+        put(&d, "blocks.json", json!({"blocks": [{"id": "a"}]}));+        pass(&d, "blocks", &["blocks.json", "parts.json"]);+        assert_eq!(flow::current(&fl, &d).map(|s| s.name.as_str()), Some("connect"));+        assert_eq!(cmd(&plan(&d, &fl)), "schematic");+        std::fs::write(&sch, "(kicad_sch )").unwrap();+        assert_eq!(cmd(&plan(&d, &fl)), "check schematic");+        pass(&d, "schematic", &["nets.json", "parts.json", "blocks.json"]);+        assert_eq!(flow::current(&fl, &d).map(|s| s.name.as_str()), Some("report"));+        assert_eq!(cmd(&plan(&d, &fl)), "nets draw");+        std::fs::create_dir_all(d.join("nets")).unwrap(); std::fs::write(d.join("nets/index.md"), "x").unwrap();+        assert_eq!(cmd(&plan(&d, &fl)), "report");+        std::fs::remove_dir_all(d.parent().unwrap()).ok();+    }++    /// A run that has passed its early steps and has traced, attributed copper: the copper check is next.+    fn at_copper_check(name: &str) -> PathBuf {+        let d = dir(name);+        put(&d, "state.json", json!({"photos": [], "kicad": {"pcb": d.join("x.kicad_pcb").display().to_string()}}));+        put(&d, "prep.json", json!({})); pass(&d, "prep", &["prep.json"]);+        put(&d, "scale.json", json!({})); std::fs::create_dir_all(d.join("board")).unwrap(); for s in ["top", "bottom"] { std::fs::write(d.join(format!("board/{s}.jpg")), "x").unwrap(); }+        put(&d, "parts.json", json!({"parts": [{"ref": "J2", "side": "top", "pos_mm": [110.0, 120.0], "rot": 0.0, "footprint": "Lib:Edge", "snap": {"method": "pads"}}]})); pass(&d, "parts", &["parts.json"]);+        put(&d, "blocks.json", json!({"blocks": []})); pass(&d, "blocks", &["blocks.json", "parts.json"]);+        put(&d, "detect/domes.json", json!({"domes": []}));+        let copper = json!({"from": "islands", "zones": [{"id": "z", "net": "UNK_1", "layer": "B.Cu", "polygon_mm": [[0, 0], [1, 0], [1, 1]]}], "traces": [], "vias": []});+        put(&d, "copper.json", copper.clone());+        put(&d, "detect/attrib/measure.json", json!({"copperSha": attrib::zones_key(&copper), "stitch": []}));+        put(&d, "nets.json", json!({"nets": []}));+        put(&d, "detect/attribution.json", json!({}));+        after(&d, &["connect".into(), "attribute".into()], true);+        d+    }++    #[test]+    fn a_face_recorded_as_not_read_sends_attribution_round_again_and_the_run_goes_on() {+        let d = at_copper_check("notread"); let fl = flow::load();+        assert_eq!(cmd(&plan(&d, &fl)), "check copper");+        // `check copper` found the bottom swallowed: it records the face and the step is done+        let (faces, _) = crate::notread::split(vec!["bottom face: island 2 holds 117 of the 148 pins joined on this face (79 percent).".into()]);+        put(&d, "checks/copper.ok", json!({"check": "copper", "files": {"copper.json": attrib::geometry_hash(&d.join("copper.json"))}, "summary": {"notRead": crate::notread::to_json(&faces)}}));+        assert_eq!(flow::current(&fl, &d).map(|s| s.name.as_str()), Some("mimic"));+        // the nets were made with that face's joins: they are made again without them, once+        let a = plan(&d, &fl);+        assert_eq!(cmd(&a), "connect attribute");+        if let Action::Run(_, why) = &a { assert!(why.contains("NOT READ")); }+        after(&d, &["connect".into(), "attribute".into()], true);+        assert_eq!(cmd(&plan(&d, &fl)), "layers trace");+        // the copper traced again and passing: the record is gone with the stale check, and the nets are made with every face again+        put(&d, "copper.json", json!({"from": "islands", "zones": [{"id": "z", "net": "UNK_1", "layer": "B.Cu", "polygon_mm": [[0, 0], [2, 0], [1, 1]]}], "traces": [], "vias": []}));+        put(&d, "detect/attrib/measure.json", json!({"copperSha": attrib::zones_key(&rd(&d, "copper.json")), "stitch": []}));+        assert_eq!(flow::current(&fl, &d).map(|s| s.name.as_str()), Some("copper"));+        assert_eq!(cmd(&plan(&d, &fl)), "connect attribute");+        after(&d, &["connect".into(), "attribute".into()], true);+        put(&d, "detect/attribution.json", json!({"again": 1}));+        assert_eq!(cmd(&plan(&d, &fl)), "check copper");+        std::fs::remove_dir_all(d.parent().unwrap()).ok();+    }++    #[test]+    fn a_part_turned_after_the_copper_was_read_has_its_joins_read_again() {+        let d = at_copper_check("turned"); let fl = flow::load();+        // `auto` traced and measured with the parts where they were+        after(&d, &["copper".into(), "trace".into()], true);+        after(&d, &["connect".into(), "measure".into()], true);+        assert_eq!(cmd(&plan(&d, &fl)), "check copper");+        // a value read later moves no pad: nothing is read again+        put(&d, "parts.json", json!({"parts": [{"ref": "J2", "side": "top", "pos_mm": [110.0, 120.0], "rot": 0.0, "footprint": "Lib:Edge", "snap": {"method": "pads"}, "value": "edge connector"}]}));+        pass(&d, "parts", &["parts.json"]); pass(&d, "blocks", &["blocks.json", "parts.json"]);+        assert_eq!(cmd(&plan(&d, &fl)), "check copper");+        // the connector turned by 180 degrees (board 1's J2): its 55 pads are joined to the copper again, then read again for attribution+        put(&d, "parts.json", json!({"parts": [{"ref": "J2", "side": "top", "pos_mm": [110.0, 120.0], "rot": 180.0, "footprint": "Lib:Edge", "snap": {"method": "pads"}, "value": "edge connector"}]}));+        pass(&d, "parts", &["parts.json"]); pass(&d, "blocks", &["blocks.json", "parts.json"]);+        assert_eq!(cmd(&plan(&d, &fl)), "copper trace");+        after(&d, &["copper".into(), "trace".into()], true);+        assert_eq!(cmd(&plan(&d, &fl)), "connect measure");+        after(&d, &["connect".into(), "measure".into()], true);+        assert_eq!(cmd(&plan(&d, &fl)), "check copper");+        std::fs::remove_dir_all(d.parent().unwrap()).ok();+    }++    #[test]+    fn parts_are_snapped_even_when_the_parts_check_was_passed_by_hand() {+        let d = dir("handcheck"); let fl = flow::load();+        put(&d, "state.json", json!({"photos": []}));+        put(&d, "prep.json", json!({})); pass(&d, "prep", &["prep.json"]);+        put(&d, "scale.json", json!({})); std::fs::create_dir_all(d.join("board")).unwrap(); std::fs::write(d.join("board/top.jpg"), "x").unwrap();+        put(&d, "detect/parts.json", json!({"candidates": [{"id": "P001"}]}));+        put(&d, "parts.json", json!({"parts": [{"ref": "R1", "candidate": "P001", "footprint": "R_0402"}], "unassigned": [], "dismissed": []}));+        after(&d, &["parts".into(), "init".into()], true);+        // the AI completes parts.json and runs `check parts` itself: the step reads done+        put(&d, "parts.json", json!({"parts": [{"ref": "R1", "candidate": "P001", "footprint": "R_0402", "function": "pull-up"}], "unassigned": [], "dismissed": []}));+        pass(&d, "parts", &["parts.json"]);+        assert_eq!(flow::current(&fl, &d).map(|s| s.name.as_str()), Some("blocks"));+        // the pads are still placed first+        assert_eq!(cmd(&plan(&d, &fl)), "parts snap");+        put(&d, "parts.json", json!({"parts": [{"ref": "R1", "candidate": "P001", "footprint": "R_0402", "function": "pull-up", "snap": {"method": "pads"}}], "unassigned": [], "dismissed": []}));+        after(&d, &["parts".into(), "snap".into()], true);+        // which made the hand-run check stale: the plan is back in the parts step, and goes on from there+        assert_eq!(cmd(&plan(&d, &fl)), "check parts");+        pass(&d, "parts", &["parts.json"]);+        assert_eq!(cmd(&plan(&d, &fl)), "blocks propose");+        std::fs::remove_dir_all(d.parent().unwrap()).ok();+    }++    #[test]+    fn a_restitch_sends_the_plan_back_to_prep_before_the_scale_is_asked_for() {+        let d = dir("restitch"); let fl = flow::load();+        put(&d, "state.json", json!({"photos": [], "intake": {"stitched": true}}));+        put(&d, "stitch.json", json!({"faces": {"top": {"file": "top.jpg"}}, "top": "Board2.3.JPG"}));+        put(&d, "prep.json", json!({"photos": []}));+        after(&d, &["prep".into()], true);+        pass(&d, "prep", &["prep.json", "stitch.json"]);+        assert_eq!(cmd(&plan(&d, &fl)), "AI: scale");+        // `stitch --top <photo>`: the other face is the top now, and what was measured is of the old faces+        put(&d, "stitch.json", json!({"faces": {"top": {"file": "top.jpg"}}, "top": "Board2.7.JPG"}));+        assert_eq!(flow::current(&fl, &d).map(|s| s.name.as_str()), Some("intake"), "the prep check is stale with the stitch");+        assert_eq!(cmd(&plan(&d, &fl)), "prep");+        after(&d, &["prep".into()], true);+        assert_eq!(cmd(&plan(&d, &fl)), "prep", "a check that passed on the old stitch still says so until it is run again");+        pass(&d, "prep", &["prep.json", "stitch.json"]);+        assert_eq!(cmd(&plan(&d, &fl)), "AI: scale");+        std::fs::remove_dir_all(d.parent().unwrap()).ok();+    }++    #[test]+    fn a_part_the_build_left_out_is_asked_for_by_name_and_a_new_footprint_file_builds_again() {+        let d = at_copper_check("leftout"); let fl = flow::load();+        pass(&d, "copper", &["copper.json"]);+        put(&d, crate::photolayers::FILE, json!({}));+        assert_eq!(cmd(&plan(&d, &fl)), "build");+        let pcb = d.join("x.kicad_pcb");+        std::thread::sleep(std::time::Duration::from_millis(30));+        std::fs::write(&pcb, "(kicad_pcb)").unwrap();+        std::thread::sleep(std::time::Duration::from_millis(30));+        // the build could not load J2's footprint and said so in its record+        let rows = crate::kicad_out::left_out_rows(&[format!("{}J2: footprint Lib:Edge not in KiCad's library ()", crate::kicad_out::LEFT_OUT_MARK)], rd(&d, "parts.json")["parts"].as_array().unwrap());+        put(&d, crate::kicad_out::LEFT_OUT, json!({"leftOut": rows}));+        let a = plan(&d, &fl);+        assert_eq!(cmd(&a), "AI: a footprint the build can load");+        if let Action::NeedAi(_, lines) = &a { assert!(lines[0].starts_with("J2: left out of the board") && lines[0].contains("Lib:Edge") && lines[0].contains("reverse-flow/lib/fp/Lib/Edge.kicad_mod"), "{lines:?}"); }+        // the AI writes the file: newer than the board, so the board is built again without anyone asking+        std::thread::sleep(std::time::Duration::from_millis(30));+        put(&d, "lib/fp/Lib/Edge.kicad_mod", json!("(footprint)"));+        let a = plan(&d, &fl);+        assert_eq!(cmd(&a), "build");+        if let Action::Run(_, why) = &a { assert!(why.contains("Lib:Edge")); }+        std::thread::sleep(std::time::Duration::from_millis(30));+        std::fs::write(&pcb, "(kicad_pcb )").unwrap();+        std::thread::sleep(std::time::Duration::from_millis(30));+        put(&d, crate::kicad_out::LEFT_OUT, json!({"leftOut": []}));+        assert_eq!(cmd(&plan(&d, &fl)), "check mimic");+        std::fs::remove_dir_all(d.parent().unwrap()).ok();+    }++    #[test]+    fn a_step_that_dies_without_a_word_still_gets_a_reason() {+        // board 11 under the memory guard: `connect measure` printed the allocator's line and was gone; `auto` said "AUTO FAILED: `connect measure`: "+        let t = died_text(None, Some(6), "tracing the top face\nmemory allocation of 81460080 bytes failed\n");+        assert!(t.starts_with("the step ran out of memory and stopped") && t.contains("memory allocation of 81460080 bytes failed"), "{t}");+        let t = died_text(None, Some(9), "");+        assert!(t.contains("killed (signal 9)") && t.contains("it printed nothing"), "{t}");+        let t = died_text(Some(101), None, "thread 'main' panicked at src/x.rs:1:1:\nindex out of bounds");+        assert!(t.contains("exit code 101") && t.contains("index out of bounds"), "{t}");+    }++    #[test]+    fn the_video_follows_the_report_and_a_failed_build_does_not_hold_the_run() {+        let d = dir("video");+        // the plan after the last step, as `plan` calls it once every step is done: the bench plan first, then the video+        assert_eq!(cmd(&video(&d)), "bench plan");+        std::fs::create_dir_all(d.join("bench")).unwrap(); std::fs::write(d.join("bench/plan.md"), "x").unwrap();+        assert_eq!(cmd(&video(&d)), "video build");+        assert!(video_note(&d).is_empty());+        // it failed on these files: recorded, not tried again, and said plainly+        std::fs::write(d.join("checks/report.ok"), "{}").unwrap();+        remember(&d, "videoFailedAt", json!(crate::video::input_key(&d)));+        remember(&d, "videoError", json!("ffmpeg is not installed on this machine"));+        assert_eq!(cmd(&video(&d)), "complete");+        assert!(video_note(&d)[0].contains("was not built: ffmpeg is not installed"));+        // the report passes again on changed files: the video is tried again+        std::fs::write(d.join("checks/report.ok"), "{\"again\": 1}").unwrap();+        assert_eq!(cmd(&video(&d)), "video build");+        // built: complete, and `next` names the file+        std::fs::create_dir_all(d.join("video")).unwrap();+        std::thread::sleep(std::time::Duration::from_millis(30));+        std::fs::write(d.join("video/x-run.mp4"), "x").unwrap();+        std::fs::write(d.join("video/video.json"), json!({"ok": true, "file": "reverse-flow/video/x-run.mp4"}).to_string()).unwrap();+        assert_eq!(cmd(&video(&d)), "complete");+        assert!(video_note(&d)[0].contains("reverse-flow/video/x-run.mp4"));+        // every step of the flow file comes before it: the video is not a step, so `steps` and the flow window keep their eleven+        assert!(!flow::load().steps.iter().any(|s| s.name == "video"));+        std::fs::remove_dir_all(d.parent().unwrap()).ok();+    }++    #[test]+    fn a_review_waiting_for_audits_names_each_sheet_and_the_exact_command() {+        let d = dir("review");+        let scores = json!({"categories": {+            "copper": {"heldByAudit": true, "audit": {"status": "incomplete", "sample": "abc123", "verdicts": 3, "items": 110}},+            "vias": {"heldByAudit": true, "audit": {"status": "none", "sample": "def456", "verdicts": 0, "items": 57}},+            "layers": {"heldByAudit": false, "audit": {"status": "none"}}}});+        let lines = audits(&scores, &[]).unwrap();+        assert_eq!(lines.len(), 2);+        assert!(lines[0].contains("reverse-flow/audits/copper/sheet.md") && lines[0].contains("audit verdict --category copper --sample abc123 --file"));+        assert!(lines[1].contains("--category vias --sample def456"));+        // a voided sample is drawn again by the program before anyone is asked+        let void = json!({"categories": {"copper": {"heldByAudit": true, "audit": {"status": "void"}}}});+        assert_eq!(cmd(&audits(&void, &[]).err().unwrap()), "audit draw --category copper");+        // and one too small to reach the target is extended+        let small = json!({"categories": {"vias": {"heldByAudit": true, "audit": {"status": "scored", "strata": [{"audited": 6, "needForTarget": 35}]}}}});+        assert_eq!(cmd(&audits(&small, &[]).err().unwrap()), "audit draw --category vias --n 35");+        // once drawn, it is not drawn again before the next pass has looked+        assert!(audits(&small, &["vias".to_string()]).unwrap().is_empty());+        std::fs::remove_dir_all(d.parent().unwrap()).ok();+    }+}
src/bench.rsadded+454
@@ -0,0 +1,454 @@+//! The optional bench passback. After a run completes, `bench plan` writes an ordered instruction set for a person with the+//! real board and a multimeter, and `bench ingest` reads their answers back. The flow never waits for either.+//!+//! The plan holds, in this order: a continuity probe for every hidden connection (`unrouted`), most settled first, each+//! between two named pads with a picture showing where each pad is; the unmarked parts whose value is unknown; the layer+//! count, when it is not measured; and the other measurements tests.json calls for. Ingest upgrades what an answer settles+//! to `MEASURED`, with the answer recorded beside it, and changes nothing else: the scores move because `confidence`+//! computes them again from the files. docs/bench.md gives the results file format.+use crate::kicad_out::{self, Pad};+use crate::{board, draw, items, ledger, layerstate, twoscores, unrouted};+use image::{imageops, RgbImage};+use serde_json::{json, Value};+use std::collections::{BTreeMap, HashMap, HashSet};+use std::path::Path;++/// What ingest has recorded, by hash of the file each answer came in.+pub const MEASURED: &str = "bench/measured.json";+const DIR: &str = "bench";+/// A crop is this wide on the board, mm: a few parts round the pad, enough to find it by eye.+const CROP_MM: f64 = 8.0;+const PANEL: u32 = 480;+/// The ring sits this far outside half the pad's narrow side, mm, so it marks one pad and not its neighbours at fine pitch.+const RING_CLEAR_MM: f64 = 0.12;++fn st<'a>(v: &'a Value, k: &str) -> &'a str { v[k].as_str().unwrap_or("") }+fn strs(v: &Value) -> Vec<String> { v.as_array().into_iter().flatten().filter_map(|x| x.as_str().map(str::to_string)).collect() }+fn wr(p: &Path, v: &Value) -> Result<(), String> { if let Some(d) = p.parent() { std::fs::create_dir_all(d).map_err(|e| e.to_string())?; } std::fs::write(p, serde_json::to_string_pretty(v).map_err(|e| e.to_string())? + "\n").map_err(|e| format!("{}: {e}", p.display())) }+fn known(tag: &str) -> bool { matches!(tag, "SEEN" | "MEASURED" | "DATASHEET") }++/// One end of a probe: a pin, where its pad is, and how easy it is to touch.+#[derive(Clone)]+struct End { pin: String, x: f64, y: f64, side: &'static str, reach: f64, note: &'static str, ring: f64 }++/// How easy a pad is to put a probe on. Higher is easier. An order of preference, not a measurement: a plated hole or a test+/// point takes a probe best, a connector or a large pad well, and a pad under its own part (a ball grid, a no-lead package)+/// cannot be touched at all.+fn reach(pad: &Pad, part: &Value) -> (f64, &'static str) {+    let pk = format!("{} {}", st(part, "package"), st(part, "footprint")).to_uppercase();+    let r = st(part, "ref").to_uppercase();+    if ["BGA", "LGA", "WLCSP"].iter().any(|k| pk.contains(k)) { return (-5.0, "under the part: not reachable with a probe, use another pin of the net or skip"); }+    let mut s = (pad.w * pad.h).min(4.0) / 2.0;+    if pad.drill > 0.0 { s += 3.0; }+    if r.starts_with("TP") { s += 3.0; }+    if r.starts_with('J') || r.starts_with('H') || r.starts_with('P') { s += 1.5; }+    if ["QFN", "DFN", "SON"].iter().any(|k| pk.contains(k)) { return (s - 3.0, "a no-lead package: touch the pad's outer edge with a fine probe"); }+    (s, "")+}++fn ends_of(piece: &[String], pads: &HashMap<String, &Pad>, parts: &HashMap<&str, &Value>) -> Vec<End> {+    piece.iter().filter_map(|pin| {+        let pad = pads.get(pin)?;+        let part = parts.get(pin.rsplit_once('.')?.0)?;+        let (reach, note) = reach(pad, part);+        Some(End { pin: pin.clone(), x: pad.x, y: pad.y, side: if pad.on("F.Cu") { "top" } else { "bottom" }, reach, note, ring: (pad.w.min(pad.h) / 2.0 + RING_CLEAR_MM).clamp(0.25, 1.5) })+    }).collect()+}++/// The two pads to touch for one hidden connection: the easiest pad of the far piece, and the easiest pad of the net's+/// main piece, nearest to it among equals.+fn pick(from: &[End], to: &[End]) -> Option<(End, End)> {+    let b = to.iter().max_by(|a, b| a.reach.partial_cmp(&b.reach).unwrap().then(b.pin.cmp(&a.pin)))?.clone();+    let best = from.iter().map(|e| e.reach).fold(f64::MIN, f64::max);+    let d = |e: &End| (e.x - b.x).hypot(e.y - b.y);+    let a = from.iter().filter(|e| e.reach >= best - 0.5).min_by(|p, q| d(p).partial_cmp(&d(q)).unwrap().then(p.pin.cmp(&q.pin)))?.clone();+    Some((a, b))+}++/// The face as the person holding the board sees it. The bottom image is stored turned over to line up with the top, so it+/// is turned back: mirrored left to right when the board was flipped that way, top to bottom otherwise.+fn as_seen(img: &RgbImage, side: &str, flip: &str) -> RgbImage { if side != "bottom" { img.clone() } else if flip == "y" { imageops::flip_vertical(img) } else { imageops::flip_horizontal(img) } }++/// One end's two panels, side by side: the pad close up with a ring on it, and the whole face with a cross where it is.+fn end_panels(face: &RgbImage, e: &End, flip: &str, mark: [u8; 3]) -> RgbImage {+    let (cx, cy) = board::to_px(e.x, e.y);+    let half = (CROP_MM / 2.0 * board::RES) as i64;+    let side_px = (2 * half) as u32;+    let mut close = RgbImage::from_pixel(side_px, side_px, image::Rgb([40, 40, 40]));+    for v in 0..side_px as i64 { for u in 0..side_px as i64 {+        let (x, y) = (cx as i64 - half + u, cy as i64 - half + v);+        if x >= 0 && y >= 0 && (x as u32) < face.width() && (y as u32) < face.height() { close.put_pixel(u as u32, v as u32, *face.get_pixel(x as u32, y as u32)); }+    } }+    // a ring just outside the pad, and four ticks pointing at it from further out, so a small pad is found and still seen+    let (c, r) = (half as f64, e.ring * board::RES);+    draw::circle(&mut close, (c, c), r, mark, 2);+    draw::circle(&mut close, (c, c), r + 2.0, [0, 0, 0], 1);+    for (dx, dy) in [(1.0, 0.0), (-1.0, 0.0), (0.0, 1.0), (0.0, -1.0)] { draw::line(&mut close, (c + dx * (r + 10.0), c + dy * (r + 10.0)), (c + dx * (r + 40.0), c + dy * (r + 40.0)), mark, 3); }+    let close = imageops::resize(&as_seen(&close, e.side, flip), PANEL, PANEL, imageops::FilterType::Triangle);+    let k = (PANEL as f64 / face.width() as f64).min(PANEL as f64 / face.height() as f64);+    let small = imageops::resize(face, ((face.width() as f64 * k) as u32).max(1), ((face.height() as f64 * k) as u32).max(1), imageops::FilterType::Triangle);+    let mut over = RgbImage::from_pixel(PANEL, PANEL, image::Rgb([40, 40, 40]));+    imageops::overlay(&mut over, &small, 0, 0);+    let (ox, oy) = (cx * k, cy * k);+    for (a, b) in [((ox - 14.0, oy), (ox + 14.0, oy)), ((ox, oy - 14.0), (ox, oy + 14.0))] { draw::line(&mut over, a, b, mark, 3); }+    draw::circle(&mut over, (ox, oy), 9.0, mark, 2);+    // only the board's own area is turned back, so the cross stays on its pad+    let mut placed = RgbImage::from_pixel(PANEL, PANEL, image::Rgb([40, 40, 40]));+    let cut = imageops::crop_imm(&over, 0, 0, small.width().min(PANEL), small.height().min(PANEL)).to_image();+    imageops::overlay(&mut placed, &as_seen(&cut, e.side, flip), 0, 0);+    let mut out = RgbImage::from_pixel(2 * PANEL + 6, PANEL, image::Rgb([255, 255, 255]));+    imageops::overlay(&mut out, &close, 0, 0);+    imageops::overlay(&mut out, &placed, (PANEL + 6) as i64, 0);+    out+}++/// A picture for one probe or one part: end A's panels above end B's.+fn picture(faces: &HashMap<&'static str, RgbImage>, ends: &[&End], flip: &str) -> Option<RgbImage> {+    let marks = [[255, 0, 200], [0, 220, 255]];+    let rows: Vec<RgbImage> = ends.iter().enumerate().filter_map(|(i, e)| faces.get(e.side).map(|f| end_panels(f, e, flip, marks[i % 2]))).collect();+    if rows.len() != ends.len() { return None; }+    let mut out = RgbImage::from_pixel(2 * PANEL + 6, rows.len() as u32 * (PANEL + 6) - 6, image::Rgb([255, 255, 255]));+    for (i, r) in rows.iter().enumerate() { imageops::overlay(&mut out, r, 0, (i as u32 * (PANEL + 6)) as i64); }+    Some(out)+}++fn save(img: &RgbImage, path: &Path) -> Result<(), String> {+    if let Some(d) = path.parent() { std::fs::create_dir_all(d).map_err(|e| e.to_string())?; }+    let mut f = std::io::BufWriter::new(std::fs::File::create(path).map_err(|e| e.to_string())?);+    image::codecs::jpeg::JpegEncoder::new_with_quality(&mut f, 85).encode_image(img).map_err(|e| e.to_string())+}++fn where_is(e: &End) -> String { format!("{} on the {} face at ({:.1}, {:.1}) mm from the board's top left corner seen from the top", e.pin, e.side, e.x - board::ORIGIN, e.y - board::ORIGIN) }++/// The probes for the hidden connections, in the order to make them: the one that settles most first.+fn continuity(nets: &[Value], pads: &HashMap<String, &Pad>, parts: &HashMap<&str, &Value>, done: &HashSet<String>) -> (Vec<Value>, Vec<Value>) {+    let per_net: HashMap<String, usize> = nets.iter().map(|n| (st(n, "name").to_string(), unrouted::pieces(n).len().saturating_sub(1))).collect();+    let (mut probes, mut cannot) = (vec![], vec![]);+    for h in unrouted::connections(nets) {+        if done.contains(&h.id) { continue; }+        let (from, to) = (ends_of(&h.from, pads, parts), ends_of(&h.to, pads, parts));+        // what a `joined` answer settles: the connection, every pin of the far piece (its place on this net), and the net itself when this is its last hidden connection+        let last = per_net.get(&h.net).copied().unwrap_or(0) == 1;+        let mut settles: Vec<String> = vec![h.id.clone()];+        settles.extend(h.to.iter().cloned());+        if last { settles.push(format!("net:{}", h.net)); }+        match pick(&from, &to) {+            Some((a, b)) if a.reach > -5.0 && b.reach > -5.0 => probes.push((settles.len(), h.net.clone(), a, b, h, settles)),+            other => {+                let why = if other.is_some() { "the only pads of one piece are under their part, where no probe reaches: an X-ray settles it" } else { "a piece has no pad the copy places (metal no footprint explains): find it on the photo and probe it by hand" };+                cannot.push(json!({"connection": h.id, "net": h.net, "from": h.from, "to": h.to, "tag": h.tag, "why": why}));+            }+        }+    }+    // nets that rest on a pinout rule and nothing seen are probed first: they are the cheapest to settle and the most likely to be wrong+    let by_rule: HashSet<&str> = nets.iter().filter(|n| n["fromPinout"] == json!(true) && st(n, "tag") == "INFERRED").map(|n| st(n, "name")).collect();+    probes.sort_by(|a, b| by_rule.contains(b.1.as_str()).cmp(&by_rule.contains(a.1.as_str())).then(b.0.cmp(&a.0)).then(a.1.cmp(&b.1)).then(a.4.id.cmp(&b.4.id)));+    let rows = probes.into_iter().enumerate().map(|(i, (n, net, a, b, h, settles))| {+        let id = format!("P{:03}", i + 1);+        json!({"probe": id, "kind": "continuity", "connection": h.id, "net": net, "tag": h.tag, "source": h.source, "tests": h.tests, "to": h.to,+            "a": a.pin, "aAt": {"side": a.side, "mm": [a.x, a.y], "note": a.note, "ringMm": a.ring}, "b": b.pin, "bAt": {"side": b.side, "mm": [b.x, b.y], "note": b.note, "ringMm": b.ring},+            "settles": settles, "settlesCount": n, "expect": "joined", "picture": format!("{DIR}/crops/{id}.jpg"),+            "do": format!("Board unpowered. Continuity from {} to {}.", where_is(&a), where_is(&b)),+            "ifOpen": format!("the pin map is wrong here: {} is not on {net} with {}. Record `open`; the flow keeps the connection unproven and flags it", b.pin, a.pin)})+    }).collect();+    (rows, cannot)+}++/// `bench plan`: the instruction set, its pictures, and a results file ready to fill in.+pub fn plan(flow: &Path) -> Result<(Value, Vec<String>), String> {+    let nets: Vec<Value> = items::read_json(&flow.join("nets.json")).map_err(|e| format!("{e}: the plan is made from the nets, after the connect step"))?["nets"].as_array().cloned().unwrap_or_default();+    let parts_doc = items::read_json(&flow.join("parts.json"))?;+    let parts: Vec<Value> = parts_doc["parts"].as_array().cloned().unwrap_or_default().into_iter().filter(|p| p["onBoard"] != json!(false)).collect();+    let pad_list = kicad_out::pads_of(&kicad_out::cache_dir(flow), &parts);+    let mut pads: HashMap<String, &Pad> = HashMap::new();+    for p in &pad_list { pads.entry(format!("{}.{}", p.part, p.num)).or_insert(p); }+    let by_ref: HashMap<&str, &Value> = parts.iter().map(|p| (st(p, "ref"), p)).collect();+    let done: HashSet<String> = unrouted::measured_now(flow, &nets).into_iter().map(|h| h.id).collect();+    let (probes, cannot) = continuity(&nets, &pads, &by_ref, &done);+    // unmarked parts whose value nothing settles yet: an in-circuit reading is only a lower bound for a capacitor and an upper one for a resistor, so the method says to lift one end when it matters+    let values: Vec<Value> = parts.iter().filter(|p| (!p["fieldTags"]["value"].is_null() || !st(p, "value").is_empty()) && twoscores::value_stratum(p) == "unmarked" && !known(p["fieldTags"]["value"].as_str().unwrap_or(st(p, "tag")))).map(|p| {+        let r = st(p, "ref");+        json!({"value": r, "package": p["package"], "function": p["function"], "stated": p["value"], "side": p["side"], "mm": p["pos_mm"], "picture": format!("{DIR}/crops/V-{r}.jpg"),+            "do": format!("Measure {r} ({}, {}) on the {} face: LCR meter across it for a capacitor or inductor, ohms for a resistor. Board unpowered. Parts in parallel read together: lift one end, or record `in circuit` in the method", st(p, "package"), if st(p, "function").is_empty() { "function not stated" } else { st(p, "function") }, st(p, "side"))})+    }).collect();+    let layers = items::read_json(&flow.join("layers.json")).unwrap_or(json!({}));+    let layers_row = (!matches!(st(&layers, "tag"), "MEASURED")).then(|| json!({"layers": "the copper layer count", "stated": layers["proposed"], "tag": layers["tag"], "fromThePhotos": layers["minimum"]["why"],+        "do": "Count the copper layers on a cut or sanded board edge under a microscope (each copper layer is a bright line), or X-ray the board. Photograph what you counted and give the file as `evidence`", "methods": layerstate::MEASURED_KINDS}));+    // everything else tests.json calls for, grouped by method: continuity of nets is the probes above, and a value or the layer count is its own row+    let covered: HashSet<String> = values.iter().map(|v| st(v, "value").to_string()).collect();+    let mut groups: BTreeMap<String, (Value, Vec<String>, Vec<String>)> = BTreeMap::new();+    for t in items::read_json(&flow.join("tests.json")).ok().and_then(|t| t["tests"].as_array().cloned()).unwrap_or_default() {+        let settles = strs(&t["settles"]);+        if settles.iter().all(|s| s.starts_with("net:") || s == "layers" || s.starts_with("plane:") || covered.contains(s)) { continue; }+        let g = groups.entry(st(&t, "method").to_string()).or_insert_with(|| (json!({"method": t["method"], "equipment": t["equipment"], "expected": t["expected"], "ifNot": t["ifNot"]}), vec![], vec![]));+        g.1.push(st(&t, "id").to_string()); g.2.extend(settles);+    }+    let mut other: Vec<Value> = groups.into_values().map(|(mut g, ids, settles)| { g["tests"] = json!(ids); g["settles"] = json!(settles); g["settlesCount"] = json!(settles.len()); g }).collect();+    other.sort_by(|a, b| b["settlesCount"].as_u64().cmp(&a["settlesCount"].as_u64()));++    // pictures: where each pad is, as the person holding the board sees it+    let flip = items::read_json(&flow.join("scale.json")).ok().and_then(|s| s["frame"]["bottomFlip"].as_str().map(str::to_string)).unwrap_or_else(|| "x".into());+    let mut faces: HashMap<&'static str, RgbImage> = HashMap::new();+    for (side, file) in [("top", "board/top.jpg"), ("bottom", "board/bottom.jpg")] { if let Ok(i) = image::open(flow.join(file)) { faces.insert(side, i.to_rgb8()); } }+    let dir = flow.join(DIR);+    let _ = std::fs::remove_dir_all(dir.join("crops"));+    let mut drawn = 0;+    let end_at = |v: &Value, pin: &str| End { pin: pin.to_string(), x: v["mm"][0].as_f64().unwrap_or(0.0), y: v["mm"][1].as_f64().unwrap_or(0.0), side: if st(v, "side") == "bottom" { "bottom" } else { "top" }, reach: 0.0, note: "", ring: v["ringMm"].as_f64().unwrap_or(0.9) };+    for p in &probes {+        let (a, b) = (end_at(&p["aAt"], st(p, "a")), end_at(&p["bAt"], st(p, "b")));+        if let Some(img) = picture(&faces, &[&a, &b], &flip) { save(&img, &flow.join(st(p, "picture")))?; drawn += 1; }+    }+    for v in &values {+        let e = End { pin: st(v, "value").to_string(), x: v["mm"][0].as_f64().unwrap_or(0.0), y: v["mm"][1].as_f64().unwrap_or(0.0), side: if st(v, "side") == "bottom" { "bottom" } else { "top" }, reach: 0.0, note: "", ring: 0.9 };+        if let Some(img) = picture(&faces, &[&e], &flip) { save(&img, &flow.join(st(v, "picture")))?; drawn += 1; }+    }++    let key: Vec<String> = probes.iter().map(|p| format!("{}|{}|{}|{}", st(p, "probe"), st(p, "connection"), st(p, "a"), st(p, "b"))).collect();+    let plan_hash = crate::confidence::sha256_hex(key.join("\n").as_bytes())[..16].to_string();+    let name = flow.parent().and_then(|p| p.file_name()).map(|f| f.to_string_lossy().to_string()).unwrap_or_default();+    let settled: usize = probes.iter().map(|p| p["settlesCount"].as_u64().unwrap_or(0) as usize).sum();+    let doc = json!({"board": name, "planHash": plan_hash, "at": ledger::now(), "bottomShown": format!("as you see it with the board turned over {}", if flip == "y" { "top to bottom" } else { "left to right" }),+        "continuity": probes, "cannotBeProbed": cannot, "values": values, "layers": layers_row, "other": other});+    wr(&dir.join("plan.json"), &doc)?;+    // a results file ready to fill in+    let mut rows: Vec<Value> = probes.iter().map(|p| json!({"probe": p["probe"], "result": "", "ohms": null, "note": format!("{} to {}", st(p, "a"), st(p, "b"))})).collect();+    rows.extend(values.iter().map(|v| json!({"value": v["value"], "measured": "", "method": ""})));+    if layers_row.is_some() { rows.push(json!({"layers": null, "method": "", "evidence": ""})); }+    wr(&dir.join("results-template.json"), &json!({"board": name, "planHash": plan_hash, "by": "", "date": "", "instrument": "", "results": rows}))?;++    let mut md = format!("# Bench plan: {name}\n\nWritten by `adom-aiflow-reverse bench plan` from the run's files. It is optional: the run is complete without it. Each answer you record settles something the photos could not, and `bench ingest` turns it into `MEASURED`.\n\n");+    md += "## Before you start\n\n- The board is unpowered and nothing is plugged into it. Discharge large capacitors.\n- A multimeter with a continuity beeper and fine probes. An LCR meter for the part values. A microscope for the layer count.\n";+    md += &format!("- Positions are in mm from the board's top left corner, seen from the top. Pictures of the bottom face are shown {}.\n", st(&doc, "bottomShown"));+    md += "- Each picture has, for each pad, a close view 8 mm across with a ring round the pad and four ticks pointing at it, and the whole face with a cross on it. Pink is pad A, blue is pad B. On a fine connector the ring marks one pin: count from the end of the row to be sure.\n";+    md += &format!("- Record answers in a copy of `reverse-flow/{DIR}/results-template.json` (format: docs/bench.md), then `adom-aiflow-reverse bench ingest --file <your file>`. Answer what you measured and leave the rest: a partial file is fine.\n- A probe answer is `joined` (the meter beeps, a few ohms at most), `open`, or `not_measured`.\n");+    md += &format!("\n## 1. Continuity probes ({} probes, settling {settled} items)\n\nIn this order: the probe that settles the most inferred or unknown items comes first.\n\n| Probe | Net | Pad A | Pad B | Settles | Now | Picture |\n|---|---|---|---|---|---|---|\n", probes.len());+    for p in &probes {+        let note = |k: &str| { let n = st(&p[k], "note"); if n.is_empty() { String::new() } else { format!(" ({n})") } };+        md += &format!("| {} | {} | {}, {} face, ({:.1}, {:.1}){} | {}, {} face, ({:.1}, {:.1}){} | {} | {} | [{}](crops/{}.jpg) |\n", st(p, "probe"), st(p, "net"),+            st(p, "a"), st(&p["aAt"], "side"), p["aAt"]["mm"][0].as_f64().unwrap_or(0.0) - board::ORIGIN, p["aAt"]["mm"][1].as_f64().unwrap_or(0.0) - board::ORIGIN, note("aAt"),+            st(p, "b"), st(&p["bAt"], "side"), p["bAt"]["mm"][0].as_f64().unwrap_or(0.0) - board::ORIGIN, p["bAt"]["mm"][1].as_f64().unwrap_or(0.0) - board::ORIGIN, note("bAt"),+            p["settlesCount"], st(p, "tag"), st(p, "probe"), st(p, "probe"));+    }+    if !cannot.is_empty() {+        md += &format!("\n{} hidden connection(s) have no pad a probe can reach:\n\n", cannot.len());+        for c in &cannot { md += &format!("- {} ({}): {}\n", st(c, "net"), strs(&c["to"]).join(", "), st(c, "why")); }+    }+    md += &format!("\n## 2. Values of unmarked parts ({} parts)\n\nA photo cannot show the value of an unmarked part. Board unpowered. A part with others in parallel reads together with them: lift one end, or say `in circuit` in the method.\n\n| Part | Package | Face | At (mm) | Stated now | Picture |\n|---|---|---|---|---|---|\n", values.len());+    for v in &values { md += &format!("| {} | {} | {} | ({:.1}, {:.1}) | {} | [{}](crops/V-{}.jpg) |\n", st(v, "value"), st(v, "package"), st(v, "side"), v["mm"][0].as_f64().unwrap_or(0.0) - board::ORIGIN, v["mm"][1].as_f64().unwrap_or(0.0) - board::ORIGIN, if st(v, "stated").is_empty() { "nothing" } else { st(v, "stated") }, st(v, "value"), st(v, "value")); }+    md += "\n## 3. Layer count\n\n";+    md += &match &layers_row { Some(l) => format!("Stated now: {} ({}). From the photos: {}\n\n{}. Methods: {}.\n", if l["stated"].is_null() { "unknown".to_string() } else { format!("{} layers", l["stated"]) }, st(l, "tag"), st(l, "fromThePhotos"), st(l, "do"), layerstate::MEASURED_KINDS.join(", ")), None => "Already measured.\n".to_string() };+    md += "\n## 4. Other measurements the tests call for\n\nRecorded as given (`{\"test\": \"<id>\", \"result\": \"...\"}`); the item they settle is corrected by whoever runs the flow.\n\n| Method | Equipment | Expected | Settles | Tests |\n|---|---|---|---|---|\n";+    for g in &other { let ids = strs(&g["tests"]); md += &format!("| {} | {} | {} | {} items | {}{} |\n", st(g, "method"), st(g, "equipment"), st(g, "expected"), g["settlesCount"], ids.iter().take(8).cloned().collect::<Vec<_>>().join(", "), if ids.len() > 8 { format!(" and {} more (plan.json)", ids.len() - 8) } else { String::new() }); }+    std::fs::write(dir.join("plan.md"), md).map_err(|e| e.to_string())?;+    let hints = vec![+        format!("{} continuity probes in order of what each settles ({settled} items in all), {} unmarked part values, {}; {drawn} pictures in reverse-flow/{DIR}/crops/", probes.len(), values.len(), if layers_row.is_some() { "the layer count" } else { "the layer count is already measured" }),+        format!("{} hidden connection(s) have no pad a probe can reach (plan.json, cannotBeProbed)", cannot.len()),+        format!("fill in a copy of reverse-flow/{DIR}/results-template.json (docs/bench.md), then `bench ingest --file <it>`. The flow does not wait for this"),+    ];+    Ok((doc, hints))+}++/// Stamp the recorded probe answers on the nets: the answers themselves, and `MEASURED` on a net whose every hidden connection+/// was found joined. Run after ingest, and after `connect attribute`, which writes the nets' tags again from the copper.+pub fn reapply(flow: &Path) -> usize {+    let Ok(mut doc) = items::read_json(&flow.join("nets.json")) else { return 0 };+    let nets: Vec<Value> = doc["nets"].as_array().cloned().unwrap_or_default();+    let all = unrouted::measured(flow);+    if all.is_empty() && !nets.iter().any(|n| !n["benchMeasured"].is_null()) { return 0; }+    let joined: HashMap<String, Value> = unrouted::measured_now(flow, &nets).into_iter().filter_map(|h| all.get(&h.id).map(|v| (h.id.clone(), v.clone()))).collect();+    let mut upgraded = 0;+    for n in doc["nets"].as_array_mut().into_iter().flatten() {+        let name = st(n, "name").to_string();+        let pieces = unrouted::pieces(n);+        let ids: Vec<String> = pieces.iter().skip(1).map(|p| format!("hc:{name}:{}", p[0])).collect();+        let have: Vec<&Value> = ids.iter().filter_map(|i| joined.get(i)).collect();+        if have.is_empty() { if let Some(o) = n.as_object_mut() { o.remove("benchMeasured"); } continue; }+        n["benchMeasured"] = json!(have);+        if have.len() == ids.len() {+            n["tag"] = json!("MEASURED");+            n["evidence"] = json!([MEASURED]);+            upgraded += 1;+        }+    }+    let _ = wr(&flow.join("nets.json"), &doc);+    upgraded+}++/// `bench ingest`: read a results file, record what it settles, upgrade those items to `MEASURED`. An answer is refused, with+/// the reason, when its probe is not in the plan or the connection it was planned for is no longer in nets.json.+pub fn ingest(flow: &Path, file: &Path) -> Result<(Value, Vec<String>), String> {+    let text = std::fs::read(file).map_err(|e| format!("{}: {e}", file.display()))?;+    let res: Value = serde_json::from_slice(&text).map_err(|e| format!("{}: not valid JSON: {e}", file.display()))?;+    let by = st(&res, "by").trim().to_string();+    if by.is_empty() { return Err("the results file needs `by`: who measured".into()); }+    let rows = res["results"].as_array().cloned().ok_or("the results file needs `results`: a list of answers")?;+    let plan = items::read_json(&flow.join(DIR).join("plan.json")).map_err(|_| "no bench plan: run `bench plan` first, so each probe has a number".to_string())?;+    if let Some(h) = res["planHash"].as_str() { if h != st(&plan, "planHash") { return Err(format!("the results were written for plan {h}, and the current plan is {}: the probe numbers differ. Use the plan the results were measured with, or measure again", st(&plan, "planHash"))); } }+    let sha = crate::confidence::sha256_hex(&text);+    let kept = flow.join(DIR).join("ingested").join(format!("{}.json", &sha[..12]));+    std::fs::create_dir_all(kept.parent().unwrap()).map_err(|e| e.to_string())?;+    std::fs::write(&kept, &text).map_err(|e| e.to_string())?;+    let stamp = json!({"by": by, "date": res["date"], "instrument": res["instrument"], "file": format!("{DIR}/ingested/{}.json", &sha[..12]), "sha256": sha, "ingestedAt": ledger::now()});+    let mut m = items::read_json(&flow.join(MEASURED)).unwrap_or(json!({}));+    for k in ["connections", "values", "tests"] { if !m[k].is_object() { m[k] = json!({}); } }+    if !m["files"].is_array() { m["files"] = json!([]); }+    if !m["files"].as_array().unwrap().iter().any(|f| f["sha256"] == stamp["sha256"]) { m["files"].as_array_mut().unwrap().push(stamp.clone()); }++    let nets: Vec<Value> = items::read_json(&flow.join("nets.json"))?["nets"].as_array().cloned().unwrap_or_default();+    let live: HashMap<String, unrouted::Hidden> = unrouted::connections(&nets).into_iter().map(|h| (h.id.clone(), h)).collect();+    let probes: HashMap<&str, &Value> = plan["continuity"].as_array().into_iter().flatten().map(|p| (st(p, "probe"), p)).collect();+    let mut parts_doc = items::read_json(&flow.join("parts.json"))?;+    let (mut joined, mut open, mut skipped, mut values, mut layers, mut tests) = (0, 0, 0, 0, 0, 0);+    let (mut refused, mut hints): (Vec<String>, Vec<String>) = (vec![], vec![]);+    for r in &rows {+        if let Some(id) = r["probe"].as_str() {+            let Some(p) = probes.get(id) else { refused.push(format!("{id}: not a probe of the current plan")); continue };+            let hc = st(p, "connection");+            match live.get(hc) { Some(h) if h.to == strs(&p["to"]) => {}, _ => { refused.push(format!("{id}: the connection it was planned for ({hc}) is no longer in nets.json as planned: run `bench plan` again")); continue; } }+            let rec = |result: &str| { let mut v = stamp.clone(); v["result"] = json!(result); v["probe"] = json!(id); v["net"] = p["net"].clone(); v["a"] = p["a"].clone(); v["b"] = p["b"].clone(); v["to"] = p["to"].clone(); v["ohms"] = r["ohms"].clone(); v["note"] = r["note"].clone(); v };+            match st(r, "result") {+                "joined" => { m["connections"][hc] = rec("joined"); joined += 1; }+                "open" => { m["connections"][hc] = rec("open"); open += 1; hints.push(format!("{id}: {} and {} are NOT joined, so the pin map is wrong on {}: the connection stays unproven. Correct nets.json from what the board shows", st(p, "a"), st(p, "b"), st(p, "net"))); }+                "" | "not_measured" => skipped += 1,+                other => refused.push(format!("{id}: result must be joined, open or not_measured, not {other:?}")),+            }+        } else if let Some(part) = r["value"].as_str() {+            let measured = st(r, "measured").trim();+            if measured.is_empty() { skipped += 1; continue; }+            let Some(pt) = parts_doc["parts"].as_array_mut().and_then(|a| a.iter_mut().find(|p| st(p, "ref") == part)) else { refused.push(format!("{part}: no such part in parts.json")); continue };+            let was = pt["value"].clone();+            pt["value"] = json!(measured);+            pt["fieldTags"]["value"] = json!("MEASURED");+            pt["valueSource"] = json!(format!("bench: {}, by {by}{}", if st(r, "method").is_empty() { "method not stated" } else { st(r, "method") }, res["date"].as_str().map(|d| format!(", {d}")).unwrap_or_default()));+            let mut v = stamp.clone(); v["measured"] = json!(measured); v["method"] = r["method"].clone(); v["was"] = was;+            m["values"][part] = v; values += 1;+        } else if !r["layers"].is_null() {+            let Some(n) = r["layers"].as_u64().filter(|n| *n > 0) else { skipped += 1; continue };+            let kind = st(r, "method");+            if !layerstate::MEASURED_KINDS.contains(&kind) { refused.push(format!("layers: method must be one of {}, not {kind:?}", layerstate::MEASURED_KINDS.join(", "))); continue; }+            // the record of the reading is the results file itself, unless a picture of what was counted came with it+            let ev = r["evidence"].as_str().filter(|e| !e.trim().is_empty()).map(|e| if Path::new(e).is_absolute() { e.to_string() } else { file.parent().map(|d| d.join(e).display().to_string()).unwrap_or_else(|| e.to_string()) }).unwrap_or_else(|| kept.display().to_string());+            match layerstate::state(flow, &layerstate::Ask { count: Some(n as usize), source: Some(&format!("bench: {kind}, by {by}")), evidence: Some(&ev), kind: Some(kind) }) {+                Ok(_) => { m["layers"] = { let mut v = stamp.clone(); v["layers"] = json!(n); v["method"] = json!(kind); v["evidence"] = json!(ev); v }; layers += 1;+                    hints.push(format!("layers: {n} stated as MEASURED. The layers score rises past its cap only when someone who did not make this run judges that evidence: `audit draw --category layers`, then `audit verdict --category layers --sample <seed> --item a001 --verdict ok --who <auditor> --evidence {ev} --kind {kind}`")); }+                Err(e) => refused.push(format!("layers: {e}")),+            }+        } else if let Some(t) = r["test"].as_str() {+            let mut v = stamp.clone(); v["result"] = r["result"].clone(); v["note"] = r["note"].clone();+            m["tests"][t] = v; tests += 1;+        } else { refused.push(format!("an answer with none of `probe`, `value`, `layers` or `test`: {r}")); }+    }+    wr(&flow.join(MEASURED), &m)?;+    if values > 0 { wr(&flow.join("parts.json"), &parts_doc)?; }+    let upgraded = reapply(flow);+    if tests > 0 { hints.push(format!("{tests} other test result(s) are recorded in {MEASURED} as given: correct the items they settle yourself, and say how each is known")); }+    if skipped > 0 { hints.push(format!("{skipped} answer(s) were left empty or not measured: nothing recorded for them")); }+    if upgraded > 0 { hints.push(format!("{upgraded} net(s) now have every hidden connection measured: tagged MEASURED in nets.json")); }+    for r in &refused { hints.push(format!("refused: {r}")); }+    Ok((json!({"file": stamp["file"], "sha256": stamp["sha256"], "by": by, "joined": joined, "open": open, "values": values, "layers": layers, "tests": tests, "skipped": skipped, "refused": refused.len(), "netsUpgraded": upgraded}), hints))+}++#[cfg(test)]+mod tests {+    use super::*;++    fn end(pin: &str, x: f64, reach: f64) -> End { End { pin: pin.into(), x, y: 0.0, side: "top", reach, note: "", ring: 0.5 } }++    #[test]+    fn a_probe_takes_the_easiest_pad_of_each_piece_and_the_nearer_one_among_equals() {+        let from = vec![end("C1.1", 0.0, 1.0), end("TP1.1", 50.0, 4.0), end("TP2.1", 9.0, 4.0)];+        let to = vec![end("U1.7", 10.0, 0.2), end("J1.3", 12.0, 2.5)];+        let (a, b) = pick(&from, &to).unwrap();+        assert_eq!(b.pin, "J1.3");+        assert_eq!(a.pin, "TP2.1");+        assert!(pick(&from, &[]).is_none());+    }++    #[test]+    fn a_pad_under_its_part_cannot_be_probed_and_a_plated_hole_is_preferred() {+        let pad = |drill: f64| Pad { part: "X".into(), num: "1".into(), x: 0.0, y: 0.0, w: 1.0, h: 1.0, rot: 0.0, round: false, drill, layers: vec!["F.Cu".into()], net: String::new() };+        assert!(reach(&pad(0.0), &json!({"ref": "U1", "package": "BGA"})).0 <= -5.0);+        assert!(reach(&pad(0.8), &json!({"ref": "J1", "package": "PinHeader"})).0 > reach(&pad(0.0), &json!({"ref": "C1", "package": "C0402"})).0);+        assert!(reach(&pad(0.0), &json!({"ref": "TP3", "package": "TestPoint"})).0 > reach(&pad(0.0), &json!({"ref": "R1", "package": "R0402"})).0);+    }++    #[test]+    fn probes_are_ordered_by_what_each_settles() {+        let pads_v = vec![("R1", "1"), ("R2", "1"), ("R3", "1"), ("R4", "1"), ("R5", "1"), ("R6", "1")].into_iter().enumerate().map(|(i, (p, n))| Pad { part: p.into(), num: n.into(), x: 100.0 + i as f64, y: 100.0, w: 0.5, h: 0.5, rot: 0.0, round: false, drill: 0.0, layers: vec!["F.Cu".into()], net: String::new() }).collect::<Vec<_>>();+        let pads: HashMap<String, &Pad> = pads_v.iter().map(|p| (format!("{}.{}", p.part, p.num), p)).collect();+        let parts_v: Vec<Value> = (1..=6).map(|i| json!({"ref": format!("R{i}"), "package": "R0402"})).collect();+        let parts: HashMap<&str, &Value> = parts_v.iter().map(|p| (st(p, "ref"), p)).collect();+        // net A: one hidden connection to a lone pin. Net B: a main piece and a far piece of two pins, its only hidden connection+        let nets = vec![json!({"name": "A", "pins": ["R1.1", "R2.1", "R3.1"], "copperGroups": [["R1.1", "R2.1"]], "tag": "INFERRED"}),+                        json!({"name": "B", "pins": ["R4.1", "R5.1", "R6.1"], "copperGroups": [["R5.1", "R6.1"]], "tag": "INFERRED"})];+        let (probes, cannot) = continuity(&nets, &pads, &parts, &HashSet::new());+        assert!(cannot.is_empty());+        assert_eq!(probes.len(), 2);+        // both settle the connection, one pin and the net: equal counts fall back to the net's name+        assert_eq!(probes[0]["net"], "A");+        assert_eq!(probes[0]["settlesCount"], 3);+        assert_eq!(probes[0]["probe"], "P001");+        // a net with two hidden connections does not have its net settled by one probe, so its probes come later+        let nets2 = vec![json!({"name": "A", "pins": ["R1.1", "R2.1", "R3.1"], "copperGroups": [], "tag": "INFERRED"}), nets[1].clone()];+        let (p2, _) = continuity(&nets2, &pads, &parts, &HashSet::new());+        assert_eq!(p2.len(), 3);+        assert_eq!(p2[0]["net"], "B");+        assert_eq!(p2[0]["settlesCount"], 3);+        assert_eq!(p2[1]["settlesCount"], 2);+        // a connection already measured is not planned again+        let (p3, _) = continuity(&nets, &pads, &parts, &["hc:A:R3.1".to_string()].into_iter().collect());+        assert_eq!(p3.len(), 1);+    }++    #[test]+    fn ingest_records_answers_upgrades_what_they_settle_and_refuses_what_is_stale() {+        let d = std::env::temp_dir().join(format!("bench-test-{}", std::process::id()));+        let _ = std::fs::remove_dir_all(&d);+        std::fs::create_dir_all(d.join(DIR)).unwrap();+        let put = |f: &str, v: Value| std::fs::write(d.join(f), v.to_string()).unwrap();+        put("nets.json", json!({"nets": [{"name": "A", "pins": ["R1.1", "R2.1", "R3.1"], "copperGroups": [["R1.1", "R2.1"]], "tag": "INFERRED", "tests": ["T-net-A"]}, {"name": "B", "pins": ["R4.1", "R5.1"], "copperGroups": [], "tag": "INFERRED", "tests": ["T-net-B"]}]}));+        put("parts.json", json!({"parts": [{"ref": "C1", "value": "", "marking": "", "tag": "MEASURED", "fieldTags": {"value": "UNKNOWN"}}]}));+        put("bench/plan.json", json!({"planHash": "abc", "continuity": [+            {"probe": "P001", "connection": "hc:A:R3.1", "net": "A", "a": "R1.1", "b": "R3.1", "to": ["R3.1"]},+            {"probe": "P002", "connection": "hc:B:R5.1", "net": "B", "a": "R4.1", "b": "R5.1", "to": ["R5.1"]},+            {"probe": "P003", "connection": "hc:GONE:R9.1", "net": "GONE", "a": "R8.1", "b": "R9.1", "to": ["R9.1"]}]}));+        let results = d.join("results.json");+        std::fs::write(&results, json!({"by": "a person", "date": "2026-11-11", "instrument": "DMM", "planHash": "abc", "results": [+            {"probe": "P001", "result": "joined", "ohms": 0.2}, {"probe": "P002", "result": "open"}, {"probe": "P003", "result": "joined"}, {"probe": "P999", "result": "joined"},+            {"value": "C1", "measured": "100nF", "method": "LCR, one end lifted"}, {"value": "C2", "measured": "1uF"}, {"probe": "P001x", "result": ""}, {"test": "T-U2", "result": "marking reads AB12"}]}).to_string()).unwrap();+        let (sum, hints) = ingest(&d, &results).unwrap();+        assert_eq!((sum["joined"].as_u64(), sum["open"].as_u64(), sum["values"].as_u64(), sum["tests"].as_u64(), sum["refused"].as_u64()), (Some(1), Some(1), Some(1), Some(1), Some(4)), "{sum} {hints:?}");+        let nets = items::read_json(&d.join("nets.json")).unwrap();+        // every hidden connection of A was found joined: the net is measured, and says by what+        assert_eq!(nets["nets"][0]["tag"], "MEASURED");+        assert_eq!(nets["nets"][0]["benchMeasured"][0]["probe"], "P001");+        assert_eq!(nets["nets"][0]["benchMeasured"][0]["by"], "a person");+        // B's probe read open: nothing is upgraded, and the answer is kept+        assert_eq!(nets["nets"][1]["tag"], "INFERRED");+        let m = items::read_json(&d.join(MEASURED)).unwrap();+        assert_eq!(m["connections"]["hc:B:R5.1"]["result"], "open");+        assert_eq!(unrouted::measured_now(&d, nets["nets"].as_array().unwrap()).len(), 1);+        let parts = items::read_json(&d.join("parts.json")).unwrap();+        assert_eq!(parts["parts"][0]["value"], "100nF");+        assert_eq!(parts["parts"][0]["fieldTags"]["value"], "MEASURED");+        assert!(parts["parts"][0]["valueSource"].as_str().unwrap().contains("LCR"));+        // attribution writes the tags again from the copper; the recorded answer is stamped back+        let mut again = nets.clone(); again["nets"][0]["tag"] = json!("INFERRED"); again["nets"][0].as_object_mut().unwrap().remove("benchMeasured");+        put("nets.json", again);+        assert_eq!(reapply(&d), 1);+        assert_eq!(items::read_json(&d.join("nets.json")).unwrap()["nets"][0]["tag"], "MEASURED");+        // results written for another plan are refused whole+        std::fs::write(&results, json!({"by": "a person", "planHash": "other", "results": []}).to_string()).unwrap();+        assert!(ingest(&d, &results).err().unwrap().contains("plan"));+        std::fs::remove_dir_all(&d).ok();+    }++    #[test]+    fn a_bottom_pad_is_shown_turned_back_as_the_holder_sees_it() {+        let mut img = RgbImage::from_pixel(8, 4, image::Rgb([0, 0, 0]));+        img.put_pixel(0, 0, image::Rgb([255, 0, 0]));+        assert_eq!(as_seen(&img, "top", "x").get_pixel(0, 0)[0], 255);+        assert_eq!(as_seen(&img, "bottom", "x").get_pixel(7, 0)[0], 255);+        assert_eq!(as_seen(&img, "bottom", "y").get_pixel(0, 3)[0], 255);+    }+}
src/board.rs+30−5
@@ -22,13 +22,34 @@ pub fn to_px(x: f64, y: f64) -> P { ((x - ORIGIN) * RES, (y - ORIGIN) * RES) } /// Measure one photo. Returns its record and its straightened image (photo-native scale, not mirrored). pub fn prep_photo(path: &Path, side: &str, ev_dir: &Path) -> Result<Value, String> {     let img = image::open(path).map_err(|e| format!("{}: {e}", path.display()))?.to_rgb8();-    let c = prep::board_corners(&img)?;+    prep_image(&img, path, side, ev_dir, None)+}++/// The same on a photo already read, straightened through `given` corners when both faces share one outline (`facereg::unify`): (corners, where they came from, the corners fitted to this photo alone).+pub fn prep_image(img: &RgbImage, path: &Path, side: &str, ev_dir: &Path, given: Option<([P; 4], String, Value, Value)>) -> Result<Value, String> {+    let img = img.clone();+    // a mosaic from `stitch` carries its board outline: used when the mask is not green and the colour test finds no board+    let fitted = given.as_ref().map(|g| g.2.clone());+    let own_c: Option<[P; 4]> = fitted.as_ref().and_then(|v| v.as_array()).filter(|a| a.len() == 4).map(|a| [0, 1, 2, 3].map(|i| (a[i][0].as_f64().unwrap_or(0.0), a[i][1].as_f64().unwrap_or(0.0))));+    let beyond_given = given.as_ref().map(|g| g.3.clone());+    let (c, outline_from): ([P; 4], String) = match given { Some(g) => (g.0, g.1), None => match crate::outline::corners(&img).or_else(|_| prep::board_corners(&img)) { Ok(c) => (c, "the photo's green mask".to_string()), Err(e) => (crate::intake::board_box(path, side).ok_or(e)?, "stitch.json (the board box of the mosaic: the mask is not green)".to_string()) } };+    // a tight outline that leaves solder mask beyond an edge has an edge along parts, not along the board. When the wide green test closes every edge, its outline+    // is the better start for a face that may end up resting on its own outline (test board 6: a header hid the top edge and the tight outline lost 13 mm of board)+    let (c, outline_from) = if beyond_given.is_none() && crate::outline::mask_beyond(&img, &c).iter().any(|v| *v > crate::outline::OPEN_EDGE_SHARE) {+        match prep::board_corners(&img) { Ok(wide) if crate::outline::mask_beyond(&img, &wide).iter().all(|v| *v <= crate::outline::OPEN_EDGE_SHARE) => (wide, "the photo's green mask, wide test: the tight outline left board beyond an edge".to_string()), _ => (c, outline_from) }+    } else { (c, outline_from) };+    // sides where the photo stops before the board does, by this photo's own outline or the one it shares with the other face+    let (pw, ph) = (img.width() as f64, img.height() as f64);+    let mut cut = crate::outline::cut_sides(&c, pw, ph);+    if let Some(o) = &own_c { for s in crate::outline::cut_sides(o, pw, ph) { if !cut.contains(&s) { cut.push(s); } } }     let w = ((geom::dist(c[0], c[1]) + geom::dist(c[3], c[2])) / 2.0).round() as u32;     let h = ((geom::dist(c[0], c[3]) + geom::dist(c[1], c[2])) / 2.0).round() as u32;     let (r, _) = prep::rectify(&img, &c, w, h, false)?;     let holes = prep::holes(&r, 20.0, 200.0);     let gold_holes = prep::gold_holes(&r, 20.0, 200.0);     let rows = prep::hole_rows(&holes);+    // what goes through the board, in the photo's own pixels, for laying the faces on each other+    let through = geom::homography(&[(0.0, 0.0), (w as f64, 0.0), (w as f64, h as f64), (0.0, h as f64)], &c).map(|hm| crate::facereg::through(&crate::facereg::pads(&img, 20.0, 520.0), &holes, &hm)).unwrap_or_default();     let bodies = if side == "top" { prep::bodies(&r, 4.0, 18.0) } else { vec![] };     let stem = path.file_stem().unwrap().to_string_lossy().to_string();     // evidence: the outline on the photo, and the straightened photo with holes, rows and bodies numbered@@ -40,8 +61,8 @@ pub fn prep_photo(path: &Path, side: &str, ev_dir: &Path) -> Result<Value, Strin     for rw in &rows { let ids: Vec<usize> = rw["holes"].as_array().unwrap().iter().map(|x| x.as_u64().unwrap() as usize).collect();         crate::draw::line(&mut rv, (f(&holes[ids[0]], "x"), f(&holes[ids[0]], "y")), (f(&holes[ids[ids.len() - 1]], "x"), f(&holes[ids[ids.len() - 1]], "y")), [255, 255, 0], 3); }     rv.save(ev_dir.join(format!("prep-{stem}.jpg"))).map_err(|e| e.to_string())?;-    Ok(json!({"file": path.file_name().unwrap().to_string_lossy(), "side": side, "corners": c.iter().map(|p| json!([p.0, p.1])).collect::<Vec<_>>(),-              "rectPx": [w, h], "sharpness": prep::sharpness(&r), "holes": holes, "goldHoles": gold_holes, "rows": rows, "bodies": bodies,+    Ok(json!({"file": path.file_name().unwrap().to_string_lossy(), "side": side, "outlineFrom": outline_from, "corners": c.iter().map(|p| json!([p.0, p.1])).collect::<Vec<_>>(),+              "rectPx": [w, h], "sharpness": prep::sharpness(&r), "holes": holes, "goldHoles": gold_holes, "through": through, "photoPx": [img.width(), img.height()], "cornersOwn": fitted, "notBoardEdge": cut, "maskBeyondOwnEdges": beyond_given.unwrap_or_else(|| json!(crate::outline::mask_beyond(&img, &c))), "rows": rows, "bodies": bodies,               "evidence": [format!("evidence/outline-{stem}.jpg"), format!("evidence/prep-{stem}.jpg")]})) } @@ -71,7 +92,9 @@ pub fn scale_candidates(rows: &[Value]) -> Vec<Value> {         }     }     cands.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap().then(a.0.partial_cmp(&b.0).unwrap()));-    cands.into_iter().take(6).map(|(k, s, e)| json!({"pxPerMm": (k * 1000.0).round() / 1000.0, "score": s, "rows": e})).collect()+    // every candidate is kept: a board whose rows all share one pitch ties every standard, and cutting the list+    // at six dropped the true one (2.0 mm) on the first such board (USB hub, 2026-11-10)+    cands.into_iter().map(|(k, s, e)| json!({"pxPerMm": (k * 1000.0).round() / 1000.0, "score": s, "rows": e})).collect() }  /// Corners of a photo mapped into the board frame (pixels at RES), mirrored for the bottom.@@ -236,7 +259,9 @@ fn plain_detail(img: &RgbImage) -> (crate::raster::Plane, crate::raster::Mask) {     let (w, h) = (img.width() as usize, img.height() as usize);     let l = crate::raster::luma(img);     let (a, b) = (crate::raster::blur(&l, 1, 3), crate::raster::blur(&l, 7, 3));-    let m = crate::raster::Mask { w, h, d: img.pixels().map(|p| (p[1] as f32 - p[0] as f32) > 0.3 * tone.gr50 && p[1] > p[2]).collect() }.erode(6.0);+    // a mask that is not green is told by its own hue (`maskcolour`); the green rule stands as it was+    let mc = crate::maskcolour::of(img);+    let m = crate::raster::Mask { w, h, d: img.pixels().map(|p| if mc.green { (p[1] as f32 - p[0] as f32) > 0.3 * tone.gr50 && p[1] > p[2] } else { mc.is_mask(p[0] as f32, p[1] as f32, p[2] as f32) }).collect() }.erode(6.0);     (crate::raster::Plane { w, h, d: a.d.iter().zip(&b.d).map(|(x, y)| x - y).collect() }, m) } 
src/candconf.rsadded+97
@@ -0,0 +1,97 @@+//! How sure each part candidate is, so the list can be read from the top instead of all at once. `detect parts` writes every+//! candidate; this gives each a `confidence` from 0 to 1 and, where something counts against it, a `doubt`.+//!+//! The ratings are rules, not measured probabilities. They were laid out on the left half of board 10's top photo (2026-11-10),+//! where 67 of 158 candidates sat on a real part: of the candidates each rule covers, what share did. A chip passive read body+//! first brings its own rating (`passives::Passive::confidence`).+use crate::geom;+use serde_json::{json, Value};++/// At or above this a candidate is taken into parts.json by `parts init`; below it, it waits in `unassigned`, most sure first.+pub const SURE: f64 = 0.5;+/// A row of plated holes at a connector pitch, and a large square pad with a hole: measured from holes, which print and solder do not imitate.+const HOLE_ROW: f64 = 0.9; const LUG: f64 = 0.8;+/// A dark or tan body that matches a package; one that does not, by its longer side: 3 mm and over (a moulding too large for the+/// package table), 0.45 mm and over (often a piece of a real chip part), smaller (texture).+const BODY_FITS: f64 = 0.7; const BODY_LARGE: f64 = 0.6; const BODY_SMALL: f64 = 0.5; const BODY_SPECK: f64 = 0.25;+/// Yellow or bright green plastic: a connector when it fits or is large, else a fragment.+const PLASTIC: f64 = 0.5; const PLASTIC_PIECE: f64 = 0.3;+/// A pale body is print, bare tin, a lead or a pad far more often than a part: on that half board 18 of 80 sat on one.+const PALE_FITS: f64 = 0.4; const PALE_LARGE: f64 = 0.3; const PALE: f64 = 0.15;+/// What counts against a candidate, as a factor: its centre on a plated hole's pad, within `EDGE_MM` of the board edge, or inside+/// a body five times its own area (a marking, a lead or a pin of that part).+const ON_HOLE: f64 = 0.3; const AT_EDGE: f64 = 0.4; const INSIDE: f64 = 0.5;+const EDGE_MM: f64 = 0.8; const BIGGER: f64 = 5.0;++fn f(v: &Value, k: &str) -> f64 { v[k].as_f64().unwrap_or(0.0) }+fn size(c: &Value) -> (f64, f64) { (c["size_mm"][0].as_f64().unwrap_or(0.0), c["size_mm"][1].as_f64().unwrap_or(0.0)) }+fn at(c: &Value) -> (f64, f64) { (c["pos_mm"][0].as_f64().unwrap_or(0.0), c["pos_mm"][1].as_f64().unwrap_or(0.0)) }+fn from_holes(c: &Value) -> bool { matches!(c["source"].as_str(), Some("hole row") | Some("large square pad")) }++/// Rate every candidate in place. `holes` are the plated holes of the top and the bottom face (board mm, as detect/holes-*.json has+/// them); `board_mm` the board's width and height when the scale is known.+pub fn rate(cands: &mut [Value], holes: (&[Value], &[Value]), board_mm: Option<(f64, f64)>) {+    let o = crate::board::ORIGIN;+    for c in cands.iter_mut() {+        let (l, w) = size(c); let long = l.max(w); let fits = c["fits"] == json!(true); let colour = c["colour"].as_str().unwrap_or("");+        let mut conf = if c["method"] == json!("body") { c["confidence"].as_f64().unwrap_or(BODY_SMALL) }+            else if c["source"] == json!("hole row") { HOLE_ROW } else if c["source"] == json!("large square pad") { LUG }+            else { match colour {+                "black" | "tan" => if fits { BODY_FITS } else if long >= 3.0 { BODY_LARGE } else if long >= 0.45 { BODY_SMALL } else { BODY_SPECK },+                "yellow" | "bright-green" => if fits || long >= 3.0 { PLASTIC } else { PLASTIC_PIECE },+                _ => if fits { PALE_FITS } else if long >= 3.0 { PALE_LARGE } else { PALE },+            } };+        let mut doubt: Vec<String> = vec![]; let p = at(c);+        if !from_holes(c) {+            let face = if c["side"] == json!("bottom") { holes.1 } else { holes.0 };+            if face.iter().any(|h| geom::dist(p, (f(h, "x"), f(h, "y"))) <= f(h, "outer_mm").max(f(h, "hole_mm")) / 2.0 + 0.1) { conf *= ON_HOLE; doubt.push("on a plated hole's pad".into()); }+        }+        if let Some((bw, bh)) = board_mm { if p.0 - o < EDGE_MM || p.1 - o < EDGE_MM || o + bw - p.0 < EDGE_MM || o + bh - p.1 < EDGE_MM { conf *= AT_EDGE; doubt.push(format!("within {EDGE_MM} mm of the board edge")); } }+        c["confidence"] = json!((conf * 100.0).round() / 100.0);+        if !doubt.is_empty() { c["doubt"] = json!(doubt); }+    }+    // inside a bigger body of the same face: that body's marking, lead or pin. A pale body is not a host (print boxes hold real+    // parts' labels, not parts), nor is a chip passive.+    let hosts: Vec<(String, String, (f64, f64), (f64, f64), f64)> = cands.iter().filter(|b| !from_holes(b) && b["method"] != json!("body") && !matches!(b["colour"].as_str(), Some("white") | None))+        .map(|b| (b["id"].as_str().unwrap_or("").to_string(), b["side"].as_str().unwrap_or("").to_string(), at(b), size(b), f(b, "rot"))).filter(|h| h.3 .0 * h.3 .1 > 0.0).collect();+    for c in cands.iter_mut() {+        if from_holes(c) { continue; }+        let (l, w) = size(c); let area = (l * w).max(0.05); let p = at(c); let side = c["side"].as_str().unwrap_or("").to_string(); let id = c["id"].as_str().unwrap_or("").to_string();+        let host = hosts.iter().find(|h| h.0 != id && h.1 == side && h.3 .0 * h.3 .1 >= BIGGER * area && {+            // the candidate's rot is KiCad's, counter-clockwise on screen; the board frame's y runs down+            let (co, si) = ((-h.4).to_radians().cos(), (-h.4).to_radians().sin()); let (dx, dy) = (p.0 - h.2 .0, p.1 - h.2 .1);+            (dx * co + dy * si).abs() <= h.3 .0 / 2.0 && (-dx * si + dy * co).abs() <= h.3 .1 / 2.0 });+        if let Some(h) = host {+            c["confidence"] = json!((c["confidence"].as_f64().unwrap_or(0.0) * INSIDE * 100.0).round() / 100.0);+            let mut d: Vec<Value> = c["doubt"].as_array().cloned().unwrap_or_default(); d.push(json!(format!("inside {}", h.0))); c["doubt"] = json!(d);+        }+    }+}++#[cfg(test)]+mod tests {+    use super::*;+    fn cand(id: &str, x: f64, y: f64, l: f64, w: f64, colour: &str, fits: bool) -> Value { json!({"id": id, "side": "top", "pos_mm": [x, y], "size_mm": [l, w], "rot": 0.0, "colour": colour, "fits": fits, "source": "body 1"}) }+    #[test]+    fn print_and_fragments_rate_below_parts() {+        let mut c = vec![+            cand("P001", 110.0, 110.0, 6.0, 6.0, "black", true),      // an IC+            cand("P002", 111.0, 111.0, 0.8, 0.3, "white", false),     // its marking+            cand("P003", 120.0, 105.0, 1.6, 0.8, "white", true),      // print the size of an 0603+            cand("P004", 120.0, 110.0, 1.1, 0.6, "black", false),     // a piece of a resistor+            cand("P005", 130.0, 110.0, 1.0, 0.5, "tan", true),        // on a hole's pad+            cand("P006", 100.3, 115.0, 1.6, 0.8, "black", true),      // at the board edge+            json!({"id": "P007", "side": "top", "pos_mm": [125.0, 125.0], "size_mm": [1.0, 0.5], "rot": 0.0, "kind": "tan", "colour": "tan", "fits": true, "method": "body", "confidence": 0.93}),+            json!({"id": "P008", "side": "top", "pos_mm": [130.0, 110.0], "rot": 0.0, "fits": true, "source": "hole row"}),+        ];+        let holes = [json!({"x": 130.0, "y": 110.0, "outer_mm": 1.6, "hole_mm": 0.9})];+        rate(&mut c, (&holes, &[]), Some((40.0, 40.0)));+        let conf: Vec<f64> = c.iter().map(|x| x["confidence"].as_f64().unwrap()).collect();+        assert_eq!(conf, vec![0.7, 0.08, 0.4, 0.5, 0.21, 0.28, 0.93, 0.9]);+        assert_eq!(c[1]["doubt"], json!(["inside P001"]));+        assert_eq!(c[4]["doubt"], json!(["on a plated hole's pad"]));+        assert!(c[5]["doubt"][0].as_str().unwrap().contains("board edge"));+        // what parts init takes: the IC, the resistor piece, the passive, the header+        assert_eq!(c.iter().filter(|x| x["confidence"].as_f64().unwrap() >= SURE).count(), 4);+    }+}
src/checks.rs+98−17
@@ -16,7 +16,7 @@ pub const REVIEWED: [&str; 7] = ["prep.json", "scale.json", "parts.json", "block pub const VIA_KINDS: [&str; 7] = ["stitch", "signal", "thermal", "inner", "unknown", "seen", "seen-net"];  fn doc(flow: &Path, name: &str) -> Result<Value, Vec<String>> {-    items::read_json(&flow.join(name)).map_err(|e| vec![format!("{e} (write it first; schema in docs/schemas.md)")])+    items::read_json(&flow.join(name)).map_err(|e| vec![format!("{e}: the step that writes it has not run yet; `next` names the command")]) }  fn s<'a>(v: &'a Value, k: &str) -> &'a str { v[k].as_str().unwrap_or("") }@@ -62,10 +62,20 @@ pub fn prep(flow: &Path, state: &Value) -> Outcome {     let settled = d["flip"]["how"].is_string();     let symmetric = d["flip"].is_object() && m == o && d["photos"].as_array().into_iter().flatten().all(|p| p["goldHoles"].as_array().map(|a| a.len()).unwrap_or(0) >= 4);     if d["flip"].is_object() && m < 3 * o.max(1) && !settled && !symmetric { errs.push(format!("the bottom-to-top mapping is not clear ({m} holes agree one way, {o} the other): check the photos show the whole board")); }-    if !errs.is_empty() { return Err(errs); }+    // once the frame is made: the faces must be held together by holes or vias, not by one shared hole. A flip the person stated lets a weak one through, with a warning.     let mut hints = vec![];+    if let Ok(sc) = items::read_json(&flow.join("scale.json")) { if let Some(why) = crate::facereg::unregistered(&sc) {+        if settled { hints.push(format!("WARNING: {why}. The flip was stated, so this passes; every via pair and every net across the faces rests on the bottom photo's outline.")); }+        else { errs.push(format!("{why}. Look at board/top.jpg against board/bottom.jpg; if the board has no through features, state the flip (`scale ... --flip x|y`) and say so in your report")); }+    } }+    if !errs.is_empty() { return Err(errs); }+    for p in d["photos"].as_array().into_iter().flatten() { let cut: Vec<&str> = p["notBoardEdge"].as_array().into_iter().flatten().filter_map(|x| x.as_str()).collect();+        if !cut.is_empty() { hints.push(format!("WARNING: {} does not show the whole board: its {} side(s) are the photo's edge, not the board's edge", s(p, "file"), cut.join(" and "))); } }     for p in d["photos"].as_array().into_iter().flatten() { hints.push(format!("{} ({}): sharpness {:.0}, {} holes, {} rows", s(p, "file"), s(p, "side"), p["sharpness"].as_f64().unwrap_or(0.0), p["holes"].as_array().map(|a| a.len()).unwrap_or(0), p["rows"].as_array().map(|a| a.len()).unwrap_or(0))); }-    Ok(Pass { summary: json!({"photos": done.len(), "flip": d["flip"]}), hints, files: vec!["prep.json".into()] })+    // the mosaics the photos were measured on: stitched again (`stitch --top`), the measurements are of the old faces and this check is stale+    let mut files = vec!["prep.json".to_string()];+    if flow.join("stitch.json").is_file() { files.push("stitch.json".into()); }+    Ok(Pass { summary: json!({"photos": done.len(), "flip": d["flip"]}), hints, files }) }  pub fn parts(flow: &Path) -> Outcome {@@ -169,12 +179,25 @@ pub fn copper(flow: &Path) -> Outcome {         if pos(v).is_none() { errs.push(format!("via {}: pos_mm missing", s(v, "id"))); }         if !VIA_KINDS.contains(&s(v, "kind")) { errs.push(format!("via {}: kind must be one of {}", s(v, "id"), VIA_KINDS.join(", "))); }     }-    if !errs.is_empty() { return Err(errs); }+    // the copper as a whole: no island may have swallowed a face (`copperchecks`)+    // A face the reader got wrong as a whole is not something a runner can fix: it is recorded as NOT READ and the run goes on+    // with the failure carried (`notread`). Anything else here is an error to fix, as before.+    let (swallowed, look) = crate::copperchecks::of_run(flow);+    let (mut not_read, other) = crate::notread::split(swallowed);+    // a face the reader gave up on at trace time (`faceread`) is the same failure found earlier: one record, one set of words+    for f in crate::notread::declared(flow) { if !not_read.iter().any(|x| x.side == f.side) { not_read.push(f); } }+    let look: Vec<String> = look.into_iter().filter(|l| !l.contains(" face was NOT READ: ")).collect();+    errs.extend(other);+    if !errs.is_empty() { errs.extend(not_read.iter().flat_map(|f| f.messages.clone())); return Err(errs); }     let n = inner_vias(&d);-    let mut hints = vec![];+    let mut hints: Vec<String> = not_read.iter().map(crate::notread::sentence).collect();+    if !not_read.is_empty() { hints.push("This is a fault of the copper reader, not of your inputs: nothing you may type changes it, and nobody edits copper by hand. `auto` goes on: attribution runs again without that face, and the review, tests and report carry the failure.".into()); }+    hints.extend(look);     if n > 0 { hints.push(format!("{n} inner via(s): ringed on both outer faces with no trace on either, so they continue on an inner layer. The board has at least 4 layers.")); }     else { hints.push("No inner vias recorded. If both faces are fully visible, 2 layers is possible; say so in step 5 only after looking under the large parts and pours.".into()); }-    Ok(Pass { summary: json!({"zones": d["zones"].as_array().map(|a| a.len()).unwrap_or(0), "traces": d["traces"].as_array().map(|a| a.len()).unwrap_or(0), "vias": d["vias"].as_array().map(|a| a.len()).unwrap_or(0), "innerVias": n, "tags": tag_counts(&its)}), hints, files: vec!["copper.json".into()] })+    let mut summary = json!({"zones": d["zones"].as_array().map(|a| a.len()).unwrap_or(0), "traces": d["traces"].as_array().map(|a| a.len()).unwrap_or(0), "vias": d["vias"].as_array().map(|a| a.len()).unwrap_or(0), "innerVias": n, "tags": tag_counts(&its)});+    if !not_read.is_empty() { summary["notRead"] = crate::notread::to_json(&not_read); }+    Ok(Pass { summary, hints, files: vec!["copper.json".into()] }) }  fn find_glob(flow: &Path, prefix: &str) -> bool {@@ -205,6 +228,41 @@ fn outline_area(o: &[(f64, f64)]) -> f64 { mod zone_outline_tests {     use super::*;     #[test]+    fn a_keep_out_rule_area_is_not_a_zone_with_no_fill_and_a_left_out_part_says_why() {+        let d = std::env::temp_dir().join(format!("mimic-keepout-{}", std::process::id())).join("reverse-flow");+        let _ = std::fs::remove_dir_all(&d);+        std::fs::create_dir_all(d.join("evidence")).unwrap();+        std::fs::write(d.join("evidence/overlay-top.jpg"), "x").unwrap();+        std::fs::write(d.join("parts.json"), json!({"parts": []}).to_string()).unwrap();+        std::fs::write(d.join("copper.json"), json!({"zones": [{"id": "z1"}]}).to_string()).unwrap();+        let peg = "\n\t(zone\n\t\t(net 0)\n\t\t(layers \"F.Cu\")\n\t\t(name \"peg\")\n\t\t(keepout\n\t\t\t(tracks not_allowed)\n\t\t)\n\t\t(polygon\n\t\t\t(pts\n\t\t\t\t(xy 0 0) (xy 4 0) (xy 4 3) (xy 0 3)\n\t\t\t)\n\t\t)\n\t)";+        let pour = "\n\t(zone\n\t\t(net 1)\n\t\t(name \"z1\")\n\t\t(polygon\n\t\t\t(pts\n\t\t\t\t(xy 0 0) (xy 4 0) (xy 4 3) (xy 0 3)\n\t\t\t)\n\t\t)\n\t\t(filled_polygon (pts (xy 0 0)))\n\t)";+        let pcb = d.join("b.kicad_pcb");+        std::fs::write(&pcb, format!("(kicad_pcb{peg}{peg}{pour}\n)")).unwrap();+        let r = mimic(&d, &pcb, &json!({"photos": []}));+        let pass = r.unwrap_or_else(|e| panic!("{e:?}"));+        assert_eq!(pass.summary["zones"], 1, "the two rule areas are not counted as copper zones");+        // the same areas with no keepout would be zones that lost their fill+        std::fs::write(&pcb, format!("(kicad_pcb{}{pour}\n)", peg.replace("(keepout", "(other"))).unwrap();+        assert!(mimic(&d, &pcb, &json!({"photos": []})).err().unwrap()[0].contains("zones with no fill: peg"));+        // a 6 mm2 pour with no fill is an error, unless the build recorded it with its area and what covers it: then it is a finding and the check goes on+        let bare = pour.replace("\n\t\t(filled_polygon (pts (xy 0 0)))", "").replace("(xy 4 0) (xy 4 3)", "(xy 4 0) (xy 4 1.5)");+        std::fs::write(&pcb, format!("(kicad_pcb{bare}\n)")).unwrap();+        assert!(mimic(&d, &pcb, &json!({"photos": []})).err().unwrap()[0].contains("zones with no fill: z1"));+        std::fs::write(d.join(crate::kicad_out::LEFT_OUT), json!({"leftOut": [], "noFill": [{"id": "z1", "areaMm2": 6.0, "why": "every part of it lies inside pads of another net: J1.1"}]}).to_string()).unwrap();+        let pass = mimic(&d, &pcb, &json!({"photos": []})).unwrap_or_else(|e| panic!("{e:?}"));+        assert_eq!(pass.summary["noFill"][0]["id"], "z1");+        assert!(pass.hints[0].starts_with("FINDING, not passed: pour z1 (6.0 mm2) is drawn with no fill") && pass.hints[0].contains("J1.1"), "{:?}", pass.hints);+        // a part the build left out is named with the footprint it wants+        std::fs::write(d.join("parts.json"), json!({"parts": [{"ref": "SW2", "side": "top", "footprint": "RE6:THT_3Pin"}]}).to_string()).unwrap();+        let rows = crate::kicad_out::left_out_rows(&[format!("{}SW2: unexpected token at 12", crate::kicad_out::LEFT_OUT_MARK), "zone z9: something else".into()], &[json!({"ref": "SW2", "footprint": "RE6:THT_3Pin"})]);+        assert_eq!(rows.len(), 1);+        std::fs::write(d.join(crate::kicad_out::LEFT_OUT), json!({"leftOut": rows}).to_string()).unwrap();+        let e = mimic(&d, &pcb, &json!({"photos": []})).err().unwrap();+        assert!(e.iter().any(|x| x.contains("SW2: in parts.json but not on the board: the build left it out") && x.contains("RE6:THT_3Pin") && x.contains("reverse-flow/lib/fp/RE6/THT_3Pin.kicad_mod")), "{e:?}");+        std::fs::remove_dir_all(d.parent().unwrap()).ok();+    }+    #[test]     fn outline_stops_at_the_end_of_its_zone() {         let z = "\n\t\t(name \"a#1\")\n\t\t(polygon\n\t\t\t(pts\n\t\t\t\t(xy 0 0) (xy 4 0) (xy 4 3) (xy 0 3)\n\t\t\t)\n\t\t)\n\t)\n\t(footprint (at 9 9))\n\t(xy 50 50)";         let o = zone_outline(z);@@ -224,7 +282,9 @@ pub fn mimic(flow: &Path, pcb: &Path, state: &Value) -> Outcome {         let r = s(p, "ref");         if p["onBoard"] == json!(false) { continue; }         wanted.insert(r.to_string());-        let Some(f) = on_board.get(r) else { errs.push(format!("{r}: in parts.json but not on the board")); continue };+        let Some(f) = on_board.get(r) else {+            let why = items::read_json(&flow.join(crate::kicad_out::LEFT_OUT)).ok().and_then(|d| d["leftOut"].as_array().and_then(|a| a.iter().find(|x| s(x, "ref") == r).map(|x| s(x, "why").to_string())));+            errs.push(match why { Some(w) => format!("{r}: in parts.json but not on the board: the build left it out: {w}"), None => format!("{r}: in parts.json but not on the board") }); continue };         let side_ok = (s(p, "side") == "top") == f.layer.starts_with("F.");         if !side_ok { errs.push(format!("{r}: parts.json says {} but the footprint is on {}", s(p, "side"), f.layer)); }         if let Some((x, y)) = pos(p) {@@ -246,8 +306,11 @@ pub fn mimic(flow: &Path, pcb: &Path, state: &Value) -> Outcome {     let has_bottom = state["photos"].as_array().into_iter().flatten().any(|p| s(p, "side") == "bottom");     if has_bottom && !find_glob(flow, "overlay-bottom") { errs.push("evidence/overlay-bottom.* missing: overlay the bottom render on the bottom photo (mirror one of them)".into()); }     if !errs.is_empty() { return Err(errs); }-    let zones = text.matches("\n\t(zone").count();-    let unfilled: Vec<(String, Vec<(f64, f64)>)> = text.split("\n\t(zone").skip(1).filter(|z| !z.contains("filled_polygon")).map(|z| (z.split("(name \"").nth(1).and_then(|x| x.split('"').next()).unwrap_or("?").to_string(), zone_outline(z))).collect();+    // a keep-out rule area (KiCad's own USB-C footprint carries two named `peg`) is not copper and never has a fill.+    // KiCad writes a zone's `keepout` before its outline, so the text up to the outline says which it is+    let rule_area = |z: &str| z[..z.find("(polygon").unwrap_or(z.len())].contains("(keepout");+    let zones = text.split("\n\t(zone").skip(1).filter(|z| !rule_area(z)).count();+    let unfilled: Vec<(String, Vec<(f64, f64)>)> = text.split("\n\t(zone").skip(1).filter(|z| !z.contains("filled_polygon") && !rule_area(z)).map(|z| (z.split("(name \"").nth(1).and_then(|x| x.split('"').next()).unwrap_or("?").to_string(), zone_outline(z))).collect();     // a traced island under 5 mm2 can be cut away whole by routed copper and its clearance; a pour must never be.     // Each zone is judged by its own drawn outline: a holed island is written as several zones (`id#k`) that copper.json lists as one.     // A small zone centred on a part's origin is metal that part's own pad already draws, so nothing is lost.@@ -260,12 +323,21 @@ pub fn mimic(flow: &Path, pcb: &Path, state: &Value) -> Outcome {         !on_pad     }).map(|(n, _)| n.clone()).collect();     let unfilled: Vec<String> = unfilled.into_iter().map(|(n, _)| n).collect();+    // A pour the build itself found covered whole by other copper, and recorded with its area and what covers it (`build-left-out.json`+    // noFill), is a finding: no rebuild and no input changes it, the copper stays in copper.json, and the run goes on. One the build+    // did not record is still an error: that would be a fill lost without a reason.+    let recorded: HashMap<String, (String, String)> = items::read_json(&flow.join(crate::kicad_out::LEFT_OUT)).ok().and_then(|d| d["noFill"].as_array().cloned()).unwrap_or_default().iter().map(|z| (s(z, "id").to_string(), (z["areaMm2"].to_string(), s(z, "why").to_string()))).collect();+    let base = |n: &str| n.split('#').next().unwrap_or(n).to_string();+    let (known, lost): (Vec<String>, Vec<String>) = lost.into_iter().partition(|n| recorded.contains_key(&base(n)));     if !lost.is_empty() { return Err(vec![format!("zones with no fill: {} (build fills every zone: rebuild, and check the zone is not cut away entirely by other-net copper)", lost.join(", "))]); }+    let no_fill: Vec<Value> = { let mut ids: Vec<String> = known.iter().map(|n| base(n)).collect(); ids.sort(); ids.dedup(); ids.iter().map(|id| json!({"id": id, "areaMm2": recorded[id].0, "why": recorded[id].1})).collect() };     let cz = items::read_json(&flow.join("copper.json")).ok().and_then(|c| c["zones"].as_array().map(|a| a.len())).unwrap_or(0);-    let mut hints = vec![];+    let mut hints: Vec<String> = no_fill.iter().map(|z| format!("FINDING, not passed: pour {} ({} mm2) is drawn with no fill: {}. It stays in copper.json; the review lists it", s(z, "id"), s(z, "areaMm2"), s(z, "why"))).collect();     if !unfilled.is_empty() { hints.push(format!("{} small island(s) under 5 mm2 cut away entirely by other copper: {}", unfilled.len(), unfilled.join(", "))); }     if zones < cz { return Err(vec![format!("the board has {zones} zone(s), copper.json lists {cz}: every seen zone must be on the board")]); }-    Ok(Pass { summary: json!({"pcb": pcb.display().to_string(), "pcbSha256": sha256(pcb), "footprints": fps.len(), "zones": zones}), hints, files: vec!["parts.json".into(), "copper.json".into()] })+    let mut summary = json!({"pcb": pcb.display().to_string(), "pcbSha256": sha256(pcb), "footprints": fps.len(), "zones": zones});+    if !no_fill.is_empty() { summary["noFill"] = json!(no_fill); }+    Ok(Pass { summary, hints, files: vec!["parts.json".into(), "copper.json".into()] }) }  pub fn nets(flow: &Path) -> Outcome {@@ -293,7 +365,9 @@ pub fn nets(flow: &Path) -> Outcome { }  pub fn layers(flow: &Path, pcb: Option<&Path>) -> Outcome {-    let d = doc(flow, "layers.json")?;+    let d = doc(flow, "layers.json").map_err(|mut e| { e.push("`layers state` writes it without routing: from outside evidence when given, otherwise UNKNOWN with its test".into()); e })?;+    // stated without routing (`layers state`): the default. The rules below judge a count the optional router proposed+    if d["method"] == json!("stated") { return crate::layerstate::check(flow, &d, pcb); }     let c = doc(flow, "copper.json")?;     let its = items::items_of("layers.json", &d);     let mut errs = vec![];@@ -305,7 +379,9 @@ pub fn layers(flow: &Path, pcb: Option<&Path>) -> Outcome {         if s(k, "run").is_empty() { errs.push(format!("candidate {}: `run` must name the route folder or report it came from", k["layers"])); }     }     let proposed = d["proposed"].as_u64().unwrap_or(0);-    if proposed == 0 { errs.push("`proposed` (the layer count you propose) missing".into()); }+    // routing that could not tell the count says UNKNOWN with its test (`write_proposal`): that is an honest answer and passes+    let unknown = proposed == 0 && s(&d, "tag") == "UNKNOWN" && d["tests"].as_array().map(|a| !a.is_empty()).unwrap_or(false) && !cands.is_empty();+    if proposed == 0 && !unknown { errs.push("`proposed` (the layer count you propose) missing: `layers state` states it without routing".into()); }     if s(&d, "why").is_empty() { errs.push("`why` missing: the evidence for the proposal (inner vias, routing results, planes)".into()); }     let inner = inner_vias(&c);     let floor = if inner > 0 { 4 } else { 2 };@@ -328,7 +404,7 @@ pub fn layers(flow: &Path, pcb: Option<&Path>) -> Outcome {         if !nets.contains(s(pl, "net")) { errs.push(format!("plane {l}: net {} is not in nets.json", s(pl, "net"))); }         if s(pl, "why").is_empty() { errs.push(format!("plane {l}: `why` missing (which vias and pins say this layer is {})", s(pl, "net"))); }     }-    if let Some(p) = pcb {+    if let Some(p) = pcb.filter(|_| !unknown) {         let t = std::fs::read_to_string(p).map_err(|e| vec![format!("{}: {e}", p.display())])?;         let n = kicad::copper_layers(&t);         if n as u64 != proposed { errs.push(format!("{} has {n} copper layers, {proposed} proposed: save the project with the proposed stackup", p.display())); }@@ -353,15 +429,19 @@ pub fn review(flow: &Path) -> Outcome {     files.push("review.json".into());     let mut hints = vec![crate::confidence::overall_line(&s, &verdict)];     if s.target != crate::confidence::DEFAULT_TARGET { hints.push(format!("the target here is {}, not the default {}", s.target, crate::confidence::DEFAULT_TARGET)); }-    if verdict != "TARGET MET" { hints.push(format!("{verdict}: the target was not met. The report must say so")); hints.extend(crate::confidence::stall_lines(&s)); }-    Ok(Pass { summary: json!({"verdict": verdict, "target": s.target, "overall": s.overall, "scores": s.cats.iter().map(|c| (c.category.clone(), json!(c.score))).collect::<serde_json::Map<_, _>>()}), hints, files })+    if verdict != "TARGET MET" { hints.push(format!("{verdict}: the photo score did not meet the target. The report must say so")); hints.extend(crate::confidence::stall_lines(&s)); }+    hints.push(format!("bench score {:.1}, reported beside the photo score and outside the target. What would raise it:", s.bench));+    hints.extend(crate::confidence::bench_lines(&s));+    Ok(Pass { summary: json!({"verdict": verdict, "target": s.target, "photo": s.photo, "bench": s.bench, "overall": s.overall, "scores": s.cats.iter().map(|c| (c.category.clone(), json!(c.score))).collect::<serde_json::Map<_, _>>()}), hints, files }) }  pub fn tests(flow: &Path) -> Outcome {     let d = doc(flow, "tests.json")?;     let all = items::all(flow);     let ids: HashSet<&str> = all.iter().map(|i| i.id.as_str()).collect();-    let known = |x: &str| ids.contains(x) || x.rsplit_once('.').map(|(a, _)| ids.contains(a)).unwrap_or(false);+    // a face whose copper was not read is an item too, with its own test (`notread`)+    let faces: HashSet<String> = crate::notread::faces(flow).iter().map(|f| crate::notread::item_id(&f.side)).collect();+    let known = |x: &str| ids.contains(x) || faces.contains(x) || x.rsplit_once('.').map(|(a, _)| ids.contains(a)).unwrap_or(false);     let mut errs = vec![];     let mut test_ids = HashSet::new();     let mut settled: HashSet<String> = HashSet::new();@@ -382,6 +462,7 @@ pub fn tests(flow: &Path) -> Outcome {         let named = it.tests.iter().any(|t| test_ids.contains(t));         if it.open() && !settled.contains(&it.id) && !named { errs.push(format!("{} ({}) is settled by no test in tests.json", it.id, it.open_what().join(", "))); }     }+    for f in &faces { if !settled.contains(f) { errs.push(format!("{f}: its copper was not read and no test in tests.json settles it: `tests generate` writes one")); } }     if !errs.is_empty() { return Err(errs); }     let mut files: Vec<String> = items::ARTIFACTS.iter().filter(|f| flow.join(f).is_file()).map(|f| f.to_string()).collect();     files.push("tests.json".into());
src/confidence.rs+394−57
@@ -9,7 +9,7 @@ use crate::kicad_out::{self, Grid, Pad}; use crate::photomat::{self, Face}; use crate::raster::{self, Mask, Plane};-use crate::{board, draw, fidelity, items, ledger};+use crate::{board, draw, fidelity, items, ledger, twoscores, unrouted}; use image::RgbImage; use serde_json::{json, Value}; use std::cell::OnceCell;@@ -828,7 +828,8 @@ fn population(c: &Ctx, cat: &str) -> Vec<PItem> {         },         "values" => for p in c.parts.iter().filter(|p| !p["fieldTags"]["value"].is_null() || !st(p, "value").is_empty()) {             let body = format!("value is {} (marking {})", if st(p, "value").is_empty() { "not stated" } else { st(p, "value") }, if st(p, "marking").is_empty() { "none read" } else { st(p, "marking") });-            let mut it = part_item(c, p, "all", body, h(&[st(p, "ref"), st(p, "value"), st(p, "marking")]));+            // a value a photo can show is one read from a marking; an unmarked part's value needs the bench (docs/method.md, "Two scores")+            let mut it = part_item(c, p, twoscores::value_stratum(p), body, h(&[st(p, "ref"), st(p, "value"), st(p, "marking")]));             it.class = st(p, "value").to_string();             out.push(it);         },@@ -881,6 +882,8 @@ fn population(c: &Ctx, cat: &str) -> Vec<PItem> {     // two records of one thing are one item: a sample must not count it twice     let mut seen = HashSet::new();     out.retain(|p| seen.insert((p.stratum.clone(), p.id.clone())));+    // a face whose copper failed `check copper` is counted as failed by its own ceiling (`notread`): no auditor is asked to judge it crop by crop+    if matches!(cat, "copper" | "connectivity") { let unread = crate::notread::faces(&c.flow); if !unread.is_empty() { out.retain(|p| !crate::notread::skips(&unread, &p.face)); } }     out } @@ -966,7 +969,38 @@ pub fn check_category(cat: &str) -> Result<(), String> { if CATEGORIES.contains( /// the default can reach `target` with one wrong answer. A current sample cannot be thrown away for a new /// one, because redrawing until a sample looks good would be the easiest way to cheat: only a changed /// population or a voided sample allows a new draw, and the seed follows from those, not from anyone's choice.+/// The record that no sample can be drawn for a category as its items stand (`audits/<cat>/undrawable.json`), when there is one.+fn undrawable(flow: &Path, cat: &str, pop: &[PItem]) -> Option<String> {+    let d = items::read_json(&audit_dir(flow, cat).join("undrawable.json")).ok()?;+    (st(&d, "populationHash") == population_hash(pop) && st(&d, "photos") == photos_key(flow)).then(|| st(&d, "why").to_string())+}++/// The two board images a draw looks at, by hash: a record that no sample can be drawn holds only for the photos it was tried on.+fn photos_key(flow: &Path) -> String {+    let mut all = vec![];+    for face in ["top", "bottom"] { all.extend(std::fs::read(flow.join(board_file(face))).unwrap_or_default()); }+    sha256_hex(&all)+}++/// Draw a sample, or extend the current one. A draw that cannot be made at all on this board (too few places where a false claim would be+/// false beyond doubt) is recorded with the items it was tried on: the category then stays at its cap and the review loop stops waiting for+/// an audit nobody can give. The record lapses when the items change, and a draw that succeeds removes it. pub fn draw_sample(flow: &Path, cat: &str, n: Option<usize>, who: &str, target: f64) -> Result<Value, String> {+    let r = draw_sample_inner(flow, cat, n, who, target);+    let mark = audit_dir(flow, cat).join("undrawable.json");+    match &r {+        Ok(_) => { let _ = std::fs::remove_file(&mark); }+        Err(e) if e.contains("planted claims") => {+            let pop = population(&Ctx::load(flow), cat);+            let _ = std::fs::create_dir_all(audit_dir(flow, cat));+            let _ = std::fs::write(&mark, json!({"populationHash": population_hash(&pop), "photos": photos_key(flow), "why": e, "at": ledger::now(), "note": "no sample can be drawn on these items: the category stays at its cap and the review does not wait for this audit"}).to_string() + "\n");+        }+        Err(_) => {}+    }+    r+}++fn draw_sample_inner(flow: &Path, cat: &str, n: Option<usize>, who: &str, target: f64) -> Result<Value, String> {     check_category(cat)?;     let c = Ctx::load(flow);     let pop = population(&c, cat);@@ -1337,7 +1371,10 @@ pub fn record_verdicts(flow: &Path, cat: &str, sample: &str, answers: &[Answer],     let pop = population(&c, cat);     let now: HashMap<(&str, &str), &str> = pop.iter().map(|p| ((p.stratum.as_str(), p.id.as_str()), p.hash.as_str())).collect();     let ev = match evidence { Some(path) => Some(outside_file(flow, path)?), None => None };-    let mut done: HashSet<String> = verdicts_of(flow, cat, doc["draw"].as_u64().unwrap_or(0)).into_keys().collect();+    let given = verdicts_of(flow, cat, doc["draw"].as_u64().unwrap_or(0));+    // a sample counts only when complete: until every item has a verdict nothing is scored, and the auditor who gave a verdict may correct it+    let unscored = list.iter().any(|i| !given.contains_key(st(i, "a")));+    let mut done: HashSet<String> = given.keys().cloned().collect();     let at = ledger::now();     let (mut lines, mut refused) = (String::new(), vec![]);     for ans in answers {@@ -1346,8 +1383,17 @@ pub fn record_verdicts(flow: &Path, cat: &str, sample: &str, answers: &[Answer],         if !matches!(ans.verdict.as_str(), "ok" | "wrong" | "cannot_tell") { refused.push(format!("{a}: verdict must be ok, wrong or cannot_tell, not {}", ans.verdict)); continue; }         if !is_control(sample, it) && now.get(&(st(it, "stratum"), st(it, "id"))).copied() != it["hash"].as_str() { refused.push(format!("stale: {} changed since sample {sample} was drawn, so {a} cannot be judged: draw again", st(it, "id"))); continue; }         if cat == "layers" && ans.verdict != "cannot_tell" && ev.is_none() { refused.push(format!("{a}: photos cannot settle {}: attach the X-ray, edge micrograph or cross-section with --evidence", st(it, "claim"))); continue; }-        if done.contains(a) && ev.is_none() { refused.push(format!("{a} already has a verdict: only one backed by a new --evidence file can replace it")); continue; }-        lines += &format!("{}\n", json!({"draw": doc["draw"], "seed": sample, "a": a, "id": it["id"], "hash": it["hash"], "verdict": ans.verdict, "who": who, "at": at, "note": ans.note, "evidence": ev, "kind": kind}));+        let mut amends = Value::Null;+        if done.contains(a) && ev.is_none() {+            let old = given.get(a);+            let same_who = old.is_some_and(|o| st(o, "who") == who);+            if !same_who { refused.push(format!("{a} already has a verdict from {}: another auditor cannot overwrite it; only a verdict backed by a new --evidence file can replace it", old.map(|o| st(o, "who")).unwrap_or("someone else"))); continue; }+            if !unscored { refused.push(format!("{a} already has a verdict and the sample is complete, so it is scored: only a verdict backed by a new --evidence file can replace it now")); continue; }+            if ans.note.trim().is_empty() { refused.push(format!("{a}: to correct your own verdict, say why in the note")); continue; }+            // the first verdict stays in the file; this line replaces it and says what it replaced+            amends = old.map(|o| json!({"verdict": o["verdict"], "note": o["note"], "at": o["at"]})).unwrap_or(Value::Null);+        }+        lines += &format!("{}\n", json!({"draw": doc["draw"], "seed": sample, "a": a, "id": it["id"], "hash": it["hash"], "verdict": ans.verdict, "who": who, "at": at, "note": ans.note, "evidence": ev, "kind": kind, "amends": amends}));         done.insert(a.to_string());     }     use std::io::Write;@@ -1403,7 +1449,25 @@ fn audit_state(c: &Ctx, cat: &str, pop: &[PItem], target: f64) -> AuditState {     // a planted false claim called right voids the sample, whoever else answered     let fooled: Vec<String> = list.iter().filter(|i| is_control(&seed, i)).filter_map(|i| verdicts.get(st(i, "a")).filter(|v| st(v, "verdict") == "ok").map(|v| format!("{} by {}", st(i, "a"), st(v, "who")))).collect();     if !fooled.is_empty() { return AuditState { seed, done, total, ..none("void", format!("planted false claim called ok: {}; those verdicts are void, draw again with another auditor", fooled.join(", "))) }; }-    if done < total { return AuditState { seed: seed.clone(), done, total, ..none("incomplete", format!("{done} of {total} verdicts in sample {seed}; a sample counts only when complete")) }; }+    // A sample counts only when complete, with one exception: an extension that nobody has judged yet does not erase the+    // sample it extends. An extension runs along the same random order, so when the judged items of every stratum are exactly+    // the first ones of that order they are the smaller sample the same seed draws, and they are scored as that sample (with+    // its wider bound). The state stays "incomplete" and held, so the loop still asks for the rest. Without this a run that+    // stops with an extension open showed its audited categories at the unaudited cap (test board 11, 2026-11-11: connectivity+    // audited at 0.6 read 40).+    let mut partial = false;+    let reals: Vec<&Value> = if done < total {+        let has = |i: &&Value| verdicts.get(st(i, "a")).map(|v| usable(v)).unwrap_or(false);+        let mut prefix = reals.iter().any(|i| has(i));+        for (s, _) in doc["counts"].as_object().into_iter().flatten() {+            let ids: Vec<String> = drawn.iter().filter(|d| &d.0 == s).map(|d| d.1.clone()).collect();+            let got: std::collections::HashSet<&str> = reals.iter().filter(|i| st(i, "stratum") == s && has(i)).map(|i| st(i, "id")).collect();+            if got.is_empty() || !order(&ids, &seed, s).iter().take(got.len()).all(|id| got.contains(id.as_str())) { prefix = false; }+        }+        if !prefix { return AuditState { seed: seed.clone(), done, total, ..none("incomplete", format!("{done} of {total} verdicts in sample {seed}; a sample counts only when complete")) }; }+        partial = true;+        reals.into_iter().filter(|i| has(i)).collect()+    } else { reals };     let now: HashMap<(&str, &str), &str> = pop.iter().map(|p| ((p.stratum.as_str(), p.id.as_str()), p.hash.as_str())).collect();     let by = strata(pop);     let mut out = AuditState { status: "scored".into(), seed: seed.clone(), done, total, ..Default::default() };@@ -1429,7 +1493,7 @@ fn audit_state(c: &Ctx, cat: &str, pop: &[PItem], target: f64) -> AuditState {         let (with, all) = proof.get(s.as_str()).copied().unwrap_or((1, 1));         let value = 100.0 * lb * share * with as f64 / all.max(1) as f64;         let need = required_n(target, 0, cur.len());-        out.strata.push(json!({"stratum": s, "population": cur.len(), "audited": n, "ok": k, "staleVerdicts": stale, "unchangedShare": (share * 1000.0).round() / 1000.0, "wilson95Lower": (lb * 1000.0).round() / 1000.0, "value": floor1(value), "needForTarget": need}));+        out.strata.push(json!({"stratum": s, "population": cur.len(), "audited": n, "ok": k, "staleVerdicts": stale, "unchangedShare": (share * 1000.0).round() / 1000.0, "wilson95Lower": (lb * 1000.0).round() / 1000.0, "value": floor1(value), "claimsValue": 100.0 * lb * share, "fullValue": value, "needForTarget": need}));         if proof.contains_key(s.as_str()) { let row = out.strata.last_mut().unwrap(); row["unproven"] = json!(all - with); row[if s == "pin" { "pins" } else { "islands" }] = json!(all); }         if n == 0 { stale_any += stale.max(1); low = low.min(0.0); continue; }         if stale > 0 || share < 1.0 { stale_any += stale.max(1); }@@ -1449,6 +1513,10 @@ fn audit_state(c: &Ctx, cat: &str, pop: &[PItem], target: f64) -> AuditState {     out.outside_pins = (pin_ok_outside.1 > 0).then(|| 100.0 * judged * wilson_lower(pin_ok_outside.0, pin_ok_outside.1, pin_ok_outside.2));     out.value = Some(low);     out.detail = out.strata.iter().map(|x| format!("{} {} of {} ok from {} (bound {:.1}%, unchanged {:.0}%)", x["stratum"].as_str().unwrap_or(""), x["ok"], x["audited"], x["population"], x["wilson95Lower"].as_f64().unwrap_or(0.0) * 100.0, x["unchangedShare"].as_f64().unwrap_or(0.0) * 100.0)).collect::<Vec<_>>().join("; ");+    if partial {+        out.status = "incomplete".into(); out.held = true;+        out.detail = format!("{done} of {total} verdicts in sample {seed}: the extension is not judged, so the score stands on the part that was: {}", out.detail);+    }     out } @@ -1459,20 +1527,27 @@ fn audit_state(c: &Ctx, cat: &str, pop: &[PItem], target: f64) -> AuditState { pub struct Ceil { pub name: String, pub value: f64, pub inputs: String, pub raise: String }  #[derive(Clone)]-pub struct CatScore { pub category: String, pub score: f64, pub ceilings: Vec<Ceil>, pub audit: AuditState, pub limit: String, pub raise: String, pub outside: Option<String>, pub held: bool }+pub struct CatScore { pub category: String, pub score: f64, pub ceilings: Vec<Ceil>, pub audit: AuditState, pub limit: String, pub raise: String, pub outside: Option<String>, pub held: bool,+    /// The lowest of this category's ceilings that photos can earn, and of those that need the real board or outside documents (`twoscores`).+    pub photo: Option<twoscores::Part>, pub bench: Option<twoscores::Part> } -pub struct Scores { pub target: f64, pub cats: Vec<CatScore>, pub overall: f64, pub full: bool }+/// `photo` and `bench` are the two overall figures; `overall` is the lower of them, which is the lowest category, kept for reading.+pub struct Scores { pub target: f64, pub cats: Vec<CatScore>, pub overall: f64, pub photo: f64, pub bench: f64, pub full: bool }  impl Scores {-    pub fn met(&self) -> bool { self.cats.iter().all(|c| c.score >= self.target) }-    pub fn below(&self) -> Vec<&CatScore> { self.cats.iter().filter(|c| c.score < self.target).collect() }+    /// The review loop's target applies to the photo score.+    pub fn met(&self) -> bool { self.photo >= self.target }+    /// The categories whose photo figure is under the target.+    pub fn below(&self) -> Vec<&CatScore> { self.cats.iter().filter(|c| c.photo.as_ref().is_some_and(|p| p.score < self.target)).collect() }     pub fn json(&self) -> Value {+        let part = |p: &Option<twoscores::Part>| p.as_ref().map(|p| json!({"score": p.score, "limit": p.limit, "raise": p.raise}));         let cats: serde_json::Map<String, Value> = self.cats.iter().map(|c| (c.category.clone(), json!({-            "score": c.score, "limit": c.limit, "raise": c.raise, "outside": c.outside, "heldByAudit": c.held,+            "score": c.score, "photo": part(&c.photo), "bench": part(&c.bench), "limit": c.limit, "raise": c.raise, "outside": c.outside, "heldByAudit": c.held,             "audit": {"status": c.audit.status, "detail": c.audit.detail, "sample": c.audit.seed, "verdicts": c.audit.done, "items": c.audit.total, "strata": c.audit.strata, "failed": c.audit.failed},-            "ceilings": c.ceilings.iter().map(|x| json!({"name": x.name, "value": floor1(x.value), "inputs": x.inputs})).collect::<Vec<_>>(),+            "ceilings": c.ceilings.iter().map(|x| json!({"name": x.name, "kind": twoscores::kind_of(&c.category, &x.name).word(), "value": floor1(x.value), "inputs": x.inputs})).collect::<Vec<_>>(),         }))).collect();-        json!({"target": self.target, "overall": self.overall, "overallRule": "the lowest category", "fullPass": self.full, "categories": cats})+        json!({"target": self.target, "photo": {"score": self.photo, "rule": twoscores::PHOTO_RULE, "targetApplies": true}, "bench": {"score": self.bench, "rule": twoscores::BENCH_RULE, "targetApplies": false},+            "overall": self.overall, "overallRule": "the lower of the photo and bench scores, which is the lowest category", "fullPass": self.full, "categories": cats})     } } @@ -1571,8 +1646,17 @@ fn measured_ceilings(c: &Ctx, cat: &str, audit: &AuditState) -> Vec<Ceil> {         }         "values" => {             let list: Vec<&Value> = c.parts.iter().filter(|p| !p["fieldTags"]["value"].is_null() || !st(p, "value").is_empty()).collect();-            let k = list.iter().filter(|p| known(p["fieldTags"]["value"].as_str().unwrap_or(st(p, "tag")))).count();-            vec![Ceil { name: "values tagged as seen".into(), value: pct(k as f64, list.len() as f64), inputs: format!("{k} of {} parts with a value have it tagged SEEN, MEASURED or DATASHEET", list.len()), raise: format!("get values for the {} parts with no readable value: unmarked ceramics cannot be read from a photo", list.len() - k) }]+            // the same share as before, taken apart: a marked part's value can be read from a photo, an unmarked part's cannot+            let mut v = vec![];+            for (stratum, name) in [("marked", twoscores::VALUES_MARKED), ("unmarked", twoscores::VALUES_UNMARKED)] {+                let of: Vec<&&Value> = list.iter().filter(|p| twoscores::value_stratum(p) == stratum).collect();+                if of.is_empty() { continue; }+                let k = of.iter().filter(|p| known(p["fieldTags"]["value"].as_str().unwrap_or(st(p, "tag")))).count();+                let raise = if stratum == "marked" { format!("read the marking of the {} marked parts whose value is still INFERRED or UNKNOWN (`decode <code>`, or the maker's marking table)", of.len() - k) } else { format!("measure the {} unmarked parts on the bench (`bench plan` lists them), or attach a bill of materials: an unmarked part's value cannot be read from a photo", of.len() - k) };+                v.push(Ceil { name: name.into(), value: pct(k as f64, of.len() as f64), inputs: format!("{k} of {} {stratum} parts with a value have it tagged SEEN, MEASURED or DATASHEET", of.len()), raise });+            }+            if v.is_empty() { v.push(Ceil { name: twoscores::VALUES_MARKED.into(), value: 0.0, inputs: "0 of 0 parts carry a value".into(), raise: "state each part's value in parts.json with how it is known".into() }); }+            v         }         "blocks" => {             let k: usize = c.blocks.iter().filter(|b| known(st(b, "tag"))).map(|b| b["parts"].as_array().map(|a| a.len()).unwrap_or(0)).sum();@@ -1610,17 +1694,22 @@ fn measured_ceilings(c: &Ctx, cat: &str, audit: &AuditState) -> Vec<Ceil> {             let needed = (pins - nets).max(0.0);             let cand = c.proposed();             let routed = cand.as_ref().and_then(|k| k["connections"].as_f64());-            let seen = routed.map(|r| pct(needed - r, needed)).unwrap_or(0.0);+            // never routed: the connections no photo shows are the ones nets.json leaves between its pieces (`unrouted`)+            // the router also counts copper islands that reach no pin, so its figure can pass what the pins need: no more than all of them can be unseen+            let over = routed.filter(|r| *r > needed).map(|r| format!(" (the router counted {r:.0}, more than the pins need: it also joins islands that reach no pin)")).unwrap_or_default();+            let unseen = routed.unwrap_or(unrouted::count(&c.nets) as f64).min(needed);+            let by_bench = (unrouted::measured_now(&c.flow, &c.nets).len() as f64).min(unseen);+            let seen = pct(needed - unseen + by_bench, needed);             let kicad_open = cand.as_ref().and_then(|k| k["kicadOpen"].as_f64());             let outside = audit.outside_pins.unwrap_or(0.0);             let mut v = vec![-                Ceil { name: "copper with a net, by area".into(), value: pct(area(true), area(false)), inputs: format!("{:.0} of {:.0} mm2 of pour has a net", area(true), area(false)), raise: "attribute the pours of unknown net".into() },-                Ceil { name: "connections the photos show or outside evidence proves".into(), value: seen.max(outside), inputs: format!("{:.0} of {needed:.0} connections ({pins:.0} pins, {nets:.0} nets) are joined by seen copper; {:.0} routed by the program, not seen; outside-evidence bound {outside:.1}", needed - routed.unwrap_or(needed), routed.unwrap_or(needed)), raise: "only outside evidence settles a connection the photos do not show: a continuity log per net".into() },+                Ceil { name: twoscores::NET_AREA.into(), value: pct(area(true), area(false)), inputs: format!("{:.0} of {:.0} mm2 of pour has a net", area(true), area(false)), raise: "attribute the pours of unknown net: copper that reaches no pin on the outer faces gets its net from a bench probe (`bench plan`)".into() },+                Ceil { name: twoscores::CONNECTIONS.into(), value: seen.max(outside), inputs: format!("{:.0} of {needed:.0} connections ({pins:.0} pins, {nets:.0} nets) are joined by seen copper; {unseen:.0} {}, not seen; {by_bench:.0} of those found joined by a recorded bench probe; outside-evidence bound {outside:.1}", needed - unseen, if routed.is_some() { format!("routed by the program{over}") } else { "left unrouted as ratsnest (unrouted.json)".to_string() }), raise: "only the real board settles a connection the photos do not show: `bench plan` writes the continuity probes, `bench ingest` records the answers".into() },             ];-            if len(false) > 0.0 { v.push(Ceil { name: "copper with a net, by length".into(), value: pct(len(true), len(false)), inputs: format!("{:.0} of {:.0} mm of track has a net", len(true), len(false)), raise: "attribute the tracks of unknown net".into() }); }+            if len(false) > 0.0 { v.push(Ceil { name: twoscores::NET_LENGTH.into(), value: pct(len(true), len(false)), inputs: format!("{:.0} of {:.0} mm of track has a net", len(true), len(false)), raise: "attribute the tracks of unknown net".into() }); }             if joined > 0.0 { v.push(Ceil { name: "pins that agree with their copper".into(), value: pct(joined - clashes, joined), inputs: format!("{clashes:.0} of {joined:.0} joined pins disagree with the copper they touch; via net clashes: {}", rep["viaClashes"].as_u64().or_else(|| c.measured["viaClashes"].as_array().map(|a| a.len() as u64)).unwrap_or(0)), raise: "settle each pin clash: a wrong net or a misread island".into() }); }-            if let Some(o) = kicad_open { v.push(Ceil { name: "connections KiCad finds closed".into(), value: pct(needed - o, needed), inputs: format!("{o:.0} of {needed:.0} missing per KiCad at the proposed stackup"), raise: "close what the copy leaves open".into() }); }-            else if cand.as_ref().map(|k| k["drcError"].is_string()).unwrap_or(false) { v.push(Ceil { name: "connections KiCad finds closed".into(), value: 0.0, inputs: "KiCad's DRC did not run at the proposed stackup: unknown, which counts as none closed".into(), raise: "run `connect route` again when service-kicad answers".into() }); }+            if let Some(o) = kicad_open { v.push(Ceil { name: twoscores::KICAD_CLOSED.into(), value: pct(needed - o, needed), inputs: format!("{o:.0} of {needed:.0} missing per KiCad at the proposed stackup"), raise: "close what the copy leaves open".into() }); }+            else if cand.as_ref().map(|k| k["drcError"].is_string()).unwrap_or(false) { v.push(Ceil { name: twoscores::KICAD_CLOSED.into(), value: 0.0, inputs: "KiCad's DRC did not run at the proposed stackup: unknown, which counts as none closed".into(), raise: "run `connect route` again when service-kicad answers".into() }); }             v         }         "layers" => {@@ -1631,7 +1720,11 @@ fn measured_ceilings(c: &Ctx, cat: &str, audit: &AuditState) -> Vec<Ceil> {                 let k = c.proposed();                 let (open, conns) = (k.as_ref().and_then(|x| x["open"].as_f64()), k.as_ref().and_then(|x| x["connections"].as_f64()));                 if let (Some(o), Some(t)) = (open, conns) { v.push(Ceil { name: "the proposed stackup closes".into(), value: if t > 0.0 { pct(t - o, t) } else { 100.0 }, inputs: format!("{o:.0} of {t:.0} connections stay open at {} layers", c.layers["proposed"]), raise: "a stackup that closes every connection".into() }); }-                else { v.push(Ceil { name: "the proposed stackup closes".into(), value: 0.0, inputs: "no routed candidate at the proposed count".into(), raise: "run `connect route`".into() }); }+                // stated without routing (`layers state`): an unknown count has earned nothing; a count from outside evidence sits under the cap above until that evidence is judged+                else if c.layers["method"] == json!("stated") {+                    if c.layers["proposed"].as_u64().is_none() { v.push(Ceil { name: "a layer count is stated".into(), value: 0.0, inputs: format!("the layer count is UNKNOWN; from the photos: {}", st(&c.layers["minimum"], "why")), raise: "T-layers: count the copper layers on the board edge under a microscope or X-ray the board, then `layers state --count <n> --evidence <file> --kind edge-micrograph|cross-section|xray|ct`".into() }); }+                }+                else { v.push(Ceil { name: "the proposed stackup closes".into(), value: 0.0, inputs: "no layer statement and no routed candidate at the proposed count".into(), raise: "run `layers state` (or the optional `connect route`)".into() }); }             }             v         }@@ -1643,6 +1736,9 @@ fn score_one(c: &Ctx, cat: &str, target: f64) -> CatScore {     let pop = population(c, cat);     let mut audit = audit_state(c, cat, &pop, target);     if cat == "copper" { audit.detail = format!("{}; {}", audit.detail, c.census().line()); }+    // a sample that cannot be drawn on this board is not an audit to wait for: the cap stands, and the reason is said+    let no_draw = if audit.value.is_none() { undrawable(&c.flow, cat, &pop) } else { None };+    if let Some(why) = &no_draw { audit.detail = format!("{}; no sample can be drawn on these items: {why}", audit.detail); }     // what no photo audit can reach: said in every state of the audit, and no cap may sit above it     let mut reach = 1.0f64;     if cat == "connectivity" {@@ -1653,6 +1749,7 @@ fn score_one(c: &Ctx, cat: &str, target: f64) -> CatScore {     }     let cap = (if cat == "layers" { PHOTO_ONLY_CAP } else { NO_AUDIT_CAP }).min(100.0 * reach);     let audit_raise = match audit.status.as_str() {+        _ if no_draw.is_some() => "no sample can be drawn on this board as it stands, so no audit can raise this: it stays at the cap. Sharper or more evenly lit photos, or a change to the copper, allow a new draw".to_string(),         "none" if cat == "layers" => "attach outside evidence with `audit verdict --evidence`".to_string(),         "none" => format!("run the audit: `audit draw --category {cat}`, then an independent verdict on every crop"),         "incomplete" => format!("finish sample {}: {} of {} verdicts; audits/{cat}/sheet.md", audit.seed, audit.done, audit.total),@@ -1668,13 +1765,57 @@ fn score_one(c: &Ctx, cat: &str, target: f64) -> CatScore {         Some(_) => format!("{}: {}", audit.status, audit.detail),         None => format!("no independent evidence ({}: {}): capped at {}", audit.status, audit.detail, floor1(cap)),     };-    let mut ceilings = vec![Ceil { name: "independent audit".into(), value, inputs: audit_inputs, raise: audit_raise }];+    let row = |s: &str| audit.strata.iter().find(|r| st(r, "stratum") == s);+    let mut ceilings = match cat {+        // one ceiling per stratum: marked parts are judged from their crops, unmarked parts only with outside evidence+        "values" => {+            let mut v = vec![];+            for (stratum, name) in [("marked", twoscores::AUDIT_MARKED), ("unmarked", twoscores::AUDIT_UNMARKED)] {+                if !pop.iter().any(|p| p.stratum == stratum) { continue; }+                let (val, inputs) = match (&audit.value, row(stratum)) {+                    (Some(_), Some(r)) => (r["fullValue"].as_f64().unwrap_or(0.0), format!("{}: {stratum} {} of {} ok from {} (bound {:.1}%, unchanged {:.0}%)", audit.status, r["ok"], r["audited"], r["population"], r["wilson95Lower"].as_f64().unwrap_or(0.0) * 100.0, r["unchangedShare"].as_f64().unwrap_or(0.0) * 100.0)),+                    (Some(_), None) => (0.0, format!("{}: no {stratum} part was in the sample", audit.status)),+                    (None, _) => (cap, audit_inputs.clone()),+                };+                v.push(Ceil { name: name.into(), value: val, inputs, raise: if stratum == "unmarked" && audit.value.is_some() { "an unmarked part's value is judged only with outside evidence: an LCR reading or a bill of materials, attached to the verdict with --evidence, or measured on the bench (`bench plan`)".into() } else { audit_raise.clone() } });+            }+            if v.is_empty() { v.push(Ceil { name: twoscores::AUDIT.into(), value, inputs: audit_inputs, raise: audit_raise }); }+            v+        }+        // the joins a crop can show are judged as claims; the pins that show no join are a matter for the bench. The lower of the two is the figure this was before.+        "connectivity" => {+            let ((iw, ia), (pw, pa, _)) = (c.island_proof(), c.pin_proof());+            let share = |w: usize, a: usize| if a > 0 { w as f64 / a as f64 } else { 1.0 };+            let seen_joined: HashSet<&str> = pin_joins(c).into_keys().collect();+            let counted: HashSet<&str> = net_pins(c).into_iter().map(|p| p.0).collect();+            let probed: HashSet<String> = unrouted::probed_pins(&c.flow, &c.nets).into_iter().filter(|p| counted.contains(p.as_str()) && !seen_joined.contains(p.as_str())).collect();+            let by_bench = if pa > 0 { 100.0 * probed.len() as f64 / pa as f64 } else { 0.0 };+            let (photo, bench) = match audit.value {+                Some(_) => {+                    let island = if ia > 0 { row("island").and_then(|r| r["fullValue"].as_f64()).unwrap_or(0.0) } else { 100.0 };+                    let claims = row("pin").and_then(|r| r["claimsValue"].as_f64()).unwrap_or(0.0);+                    (if pa > 0 { island.min(claims) } else { island }, if pa > 0 { claims * share(pw, pa) + by_bench } else { 100.0 })+                }+                None => (NO_AUDIT_CAP.min(100.0 * share(iw, ia)).min(if pa > 0 && pw == 0 { 0.0 } else { 100.0 }), NO_AUDIT_CAP.min(100.0 * share(pw, pa)) + by_bench),+            };+            vec![Ceil { name: twoscores::AUDIT_JOINS.into(), value: photo, inputs: audit_inputs, raise: audit_raise },+                 Ceil { name: twoscores::PINS_PROVEN.into(), value: bench.min(100.0), inputs: format!("{pw} of {pa} pins with a net show a join a crop can judge{}; {} more were found joined by a recorded bench probe; the other {} are unproven and count as not correct", if audit.value.is_some() { format!(" (those claims: {:.1})", row("pin").and_then(|r| r["claimsValue"].as_f64()).unwrap_or(0.0)) } else { format!(" (not audited: capped at {NO_AUDIT_CAP})") }, probed.len(), pa.saturating_sub(pw + probed.len())), raise: "probe the pins no photo shows joined: `bench plan` writes a continuity probe for each, `bench ingest` records the answers".into() }]+        }+        _ => vec![Ceil { name: twoscores::AUDIT.into(), value, inputs: audit_inputs, raise: audit_raise }],+    };     ceilings.extend(measured_ceilings(c, cat, &audit));+    // an ERC error the copper as read causes counts against connectivity+    if cat == "connectivity" { if let Some((value, inputs)) = crate::ercfind::ceiling(&c.flow, &c.nets) { ceilings.push(Ceil { name: crate::ercfind::CEILING.into(), value, inputs, raise: "each finding has a continuity test (`tests generate`, `bench plan`); where the board shows no join, the copper reader joined two conductors and only a fix to it, or a better photo, clears the finding".into() }); } }+    // a face not read counts as failed in both scores that rest on copper+    if matches!(cat, "copper" | "connectivity") { if let Some((value, inputs)) = crate::notread::ceiling(&c.flow) { ceilings.push(Ceil { name: crate::notread::CEILING.into(), value, inputs, raise: crate::notread::RAISE.into() }); } }     if matches!(cat, "copper" | "vias" | "placement") { ceilings.push(fidelity_ceiling(c, cat)); }     let bind = ceilings.iter().enumerate().fold(0, |b, (i, x)| if x.value < ceilings[b].value { i } else { b });     let score = floor1(ceilings[bind].value);-    let held = bind == 0 && score < target && audit.held && cat != "layers";-    CatScore { category: cat.into(), score, limit: format!("{}: {}", ceilings[bind].name, ceilings[bind].inputs), raise: ceilings[bind].raise.clone(), ceilings, audit, outside: outside_for(cat), held }+    let (photo, bench) = (twoscores::part(cat, &ceilings, twoscores::Kind::Photo), twoscores::part(cat, &ceilings, twoscores::Kind::Bench));+    // the loop waits for an audit only where the photo figure is the one an audit holds down+    // and a category with nothing to audit (a board with no vias) has no audit to wait for: it would hold the loop for ever+    let held = audit.held && !pop.is_empty() && no_draw.is_none() && photo.as_ref().is_some_and(|p| p.score < target && p.by_audit);+    CatScore { category: cat.into(), score, limit: format!("{}: {}", ceilings[bind].name, ceilings[bind].inputs), raise: ceilings[bind].raise.clone(), ceilings, audit, outside: outside_for(cat), held, photo, bench } }  /// Every score, computed now from the run's files. This is the only place a score comes from.@@ -1683,7 +1824,8 @@ pub fn score_all(flow: &Path, target: f64) -> Scores {     let cats: Vec<CatScore> = CATEGORIES.iter().map(|k| score_one(&c, k, target)).collect();     let overall = cats.iter().map(|x| x.score).fold(100.0, f64::min);     let full = !cats.iter().any(|x| x.held);-    Scores { target, cats, overall, full }+    let (photo, bench) = (twoscores::total(&cats, twoscores::Kind::Photo), twoscores::total(&cats, twoscores::Kind::Bench));+    Scores { target, cats, overall, photo, bench, full } }  pub fn target_of(state: &Value) -> f64 { state["confidenceTarget"].as_f64().unwrap_or(DEFAULT_TARGET) }@@ -1691,7 +1833,7 @@ pub fn target_of(state: &Value) -> f64 { state["confidenceTarget"].as_f64().unwr /// Files a review depends on beyond the artifacts: editing one undoes the review. pub fn audit_files(flow: &Path) -> Vec<String> {     let mut v = vec![];-    for k in CATEGORIES { for f in ["sample.json", "population.json", "verdicts.jsonl"] {+    for k in CATEGORIES { for f in ["sample.json", "population.json", "verdicts.jsonl", "undrawable.json"] {         let rel = format!("audits/{k}/{f}");         if flow.join(&rel).is_file() { v.push(rel); }     } }@@ -1709,30 +1851,23 @@ pub fn verdict_for(prev: &[Value], s: &Scores, max_passes: usize) -> &'static st     let at_limit = measured.len() + 1 >= max_passes;     if !s.full { return if at_limit { "STOPPED" } else { "CONTINUE" }; }     if measured.is_empty() { return "CONTINUE"; }-    let best = |c: &str| measured.iter().filter_map(|p| p["scores"]["categories"][c]["score"].as_f64()).fold(f64::MIN, f64::max);-    let raised = s.cats.iter().any(|c| c.score >= best(&c.category) + MIN_RAISE);+    // the loop works on the photo score: a pass recorded before the two scores has no photo figure, so the first pass after it counts as a rise+    let best = |c: &str| measured.iter().filter_map(|p| p["scores"]["categories"][c]["photo"]["score"].as_f64()).fold(f64::MIN, f64::max);+    let raised = s.cats.iter().any(|c| c.photo.as_ref().is_some_and(|p| p.score >= best(&c.category) + MIN_RAISE));     if !raised { return "STALLED"; }     if at_limit { "STOPPED" } else { "CONTINUE" } }  /// The line report.md must carry word for word.-pub fn overall_line(s: &Scores, verdict: &str) -> String {-    let t = if s.target == DEFAULT_TARGET { format!("target {}", s.target) } else { format!("target {} (lowered or raised from the default {DEFAULT_TARGET})", s.target) };-    format!("Overall confidence: {:.1} of 100, {t}: {verdict}", s.overall)-}+pub fn overall_line(s: &Scores, verdict: &str) -> String { twoscores::line(s, verdict) } -pub fn table(s: &Scores) -> String {-    let mut t = String::from("| Category | Score | Target | Limited by | Most likely to raise it |\n|---|---|---|---|---|\n");-    for c in &s.cats { t += &format!("| {} | {:.1} | {} | {} | {} |\n", c.category, c.score, s.target, c.limit, c.raise); }-    t += &format!("| **overall (the lowest)** | **{:.1}** | {} | | |\n", s.overall, s.target);-    t-}+pub fn table(s: &Scores) -> String { twoscores::tables(s) }  pub fn inputs_text(s: &Scores) -> String {     let mut t = String::new();     for c in &s.cats {-        t += &format!("{} {:.1}\n", c.category, c.score);-        for x in &c.ceilings { t += &format!("  {:>5.1}  {}: {}\n", floor1(x.value), x.name, x.inputs); }+        t += &format!("{}: photo {}, bench {}\n", c.category, twoscores::show(&c.photo), twoscores::show(&c.bench));+        for x in &c.ceilings { t += &format!("  {:>5.1}  [{}] {}: {}\n", floor1(x.value), twoscores::kind_of(&c.category, &x.name).word(), x.name, x.inputs); }         if let Some(o) = &c.outside { if c.score < s.target { t += &format!("         outside evidence: {o}\n"); } }     }     t@@ -1740,10 +1875,15 @@ pub fn inputs_text(s: &Scores) -> String {  /// The categories under the target, each with what limits it and the one thing most likely to raise it. pub fn stall_lines(s: &Scores) -> Vec<String> {-    s.below().iter().map(|c| {+    s.below().iter().filter_map(|c| c.photo.as_ref().map(|p| format!("{} photo figure {:.1} of {}: limited by {}. Most likely to raise it: {}.", c.category, p.score, s.target, p.limit, p.raise))).collect()+}++/// The bench score beside it: each category's bench figure under the target, with what would raise it. Nothing here holds the loop.+pub fn bench_lines(s: &Scores) -> Vec<String> {+    s.cats.iter().filter_map(|c| c.bench.as_ref().filter(|b| b.score < s.target).map(|b| {         let out = c.outside.as_ref().map(|o| format!(" Outside evidence that would raise it: {o}.")).unwrap_or_default();-        format!("{} {:.1} of {}: limited by {}. Most likely to raise it: {}.{out}", c.category, c.score, s.target, c.limit, c.raise)-    }).collect()+        format!("{} bench figure {:.1}: limited by {}. What would raise it: {}.{out}", c.category, b.score, b.limit, b.raise)+    })).collect() }  /// Whether a stated verdict stands, from the files as they are now. Nothing recorded is believed.@@ -1773,8 +1913,11 @@ pub fn gate(flow: &Path, review: &Value, hashes_now: &BTreeMap<String, String>)         "TARGET MET" => {}         "STALLED" => {}         "STOPPED" if s.full => {}+        // a declared stop: the pass that stopped carries its reason in words (`review pass --max-passes <n> --note "<why>"`),+        // so a run whose audits cannot be finished (a sample voided again and again, no auditor left) still ends, and says so+        "STOPPED" if !st(last, "note").trim().is_empty() => {}         _ => {-            errs.push(format!("{} (overall {:.1}, target {}): keep looping, `review pass` says what to do next", if verdict == "STOPPED" { "the pass limit was reached with audits unfinished".to_string() } else { "not finished".to_string() }, s.overall, s.target));+            errs.push(format!("{} (photo score {:.1}, target {}): keep looping, `review pass` says what to do next", if verdict == "STOPPED" { "the pass limit was reached with audits unfinished".to_string() } else { "not finished".to_string() }, s.photo, s.target));             errs.extend(stall_lines(&s));         }     }@@ -1784,7 +1927,7 @@ pub fn gate(flow: &Path, review: &Value, hashes_now: &BTreeMap<String, String>)  /// A line of report.md that claims more confidence than the run earned. pub fn false_claim(md: &str, s: &Scores) -> Option<String> {-    if s.met() { return None; }+    if s.photo >= s.target && s.bench >= s.target { return None; }     for line in md.lines() {         let low = line.to_lowercase();         if !low.contains("confiden") || low.contains("target") || low.contains("goal") { continue; }@@ -1794,7 +1937,7 @@ pub fn false_claim(md: &str, s: &Scores) -> Option<String> {             if b[i].is_ascii_digit() {                 let j = (i..b.len()).find(|&k| !(b[k].is_ascii_digit() || b[k] == b'.')).unwrap_or(b.len());                 let rest = low[j..].trim_start();-                if let Ok(v) = low[i..j].parse::<f64>() { if v > s.overall + 0.05 && v <= 100.0 && (rest.starts_with('%') || rest.starts_with("percent")) { return Some(line.to_string()); } }+                if let Ok(v) = low[i..j].parse::<f64>() { if v > s.photo.max(s.bench) + 0.05 && v <= 100.0 && (rest.starts_with('%') || rest.starts_with("percent")) { return Some(line.to_string()); } }                 i = j;             } else { i += 1; }         }@@ -1910,15 +2053,20 @@ mod tests {         let seed = st(&doc, "seed").to_string();         assert!(record_verdict(&d, "layers", &seed, "a001", "ok", "auditor", "", None, None).unwrap_err().contains("photos cannot settle"));         assert!(record_verdict(&d, "layers", &seed, "a001", "ok", "auditor", "", Some("board/top.jpg"), Some("xray")).unwrap_err().contains("outside evidence"));-        assert!(score_all(&d, DEFAULT_TARGET).cats.iter().find(|c| c.category == "layers").unwrap().score <= PHOTO_ONLY_CAP);+        let s = score_all(&d, DEFAULT_TARGET);+        assert!(s.cats.iter().find(|c| c.category == "layers").unwrap().score <= PHOTO_ONLY_CAP);+        // the photo audits are done, so the photo score is there; the bench score waits for the outside evidence+        assert!(s.met() && s.photo >= 90.0, "{}", table(&s));+        assert!(s.bench <= PHOTO_ONLY_CAP && s.overall <= PHOTO_ONLY_CAP, "{}", table(&s));         std::fs::write(d.join("xray.txt"), "edge view").unwrap();         record_verdict(&d, "layers", &seed, "a001", "ok", "auditor", "", Some("xray.txt"), Some("xray")).unwrap();         let s = score_all(&d, DEFAULT_TARGET);         assert!(s.met(), "{}", table(&s));-        assert!(s.overall >= 90.0);-        // the evidence file is held to its hash+        assert!(s.photo >= 90.0 && s.bench >= 90.0 && s.overall >= 90.0, "{}", table(&s));+        // the evidence file is held to its hash: the bench score falls back, and the photo score never rested on it         std::fs::write(d.join("xray.txt"), "something else").unwrap();-        assert!(!score_all(&d, DEFAULT_TARGET).met());+        let s = score_all(&d, DEFAULT_TARGET);+        assert!(s.bench <= PHOTO_ONLY_CAP && s.photo >= 90.0, "{}", table(&s));         std::fs::remove_dir_all(&d).ok();     } @@ -2036,6 +2184,15 @@ mod tests {         let good: Vec<Answer> = list.iter().take(list.len() / 2).map(|it| ans(it, if is_control(&seed, it) { "wrong" } else { "ok" })).collect();         assert!(record_verdicts(&d, "vias", &seed, &good, "auditor", None, None).unwrap().is_empty());         assert_eq!(score_all(&d, DEFAULT_TARGET).cats.iter().find(|c| c.category == "vias").unwrap().audit.status, "incomplete");+        // while the sample is not complete, so not scored, the same auditor may correct a verdict, with a note; the change is recorded+        let first = st(&list[0], "a").to_string();+        let was = if is_control(&seed, &list[0]) { "wrong" } else { "ok" };+        assert!(record_verdict(&d, "vias", &seed, &first, "cannot_tell", "auditor", "", None, None).unwrap_err().contains("say why in the note"));+        assert!(record_verdict(&d, "vias", &seed, &first, "cannot_tell", "someone-else", "I disagree", None, None).unwrap_err().contains("another auditor cannot overwrite"));+        record_verdict(&d, "vias", &seed, &first, "cannot_tell", "auditor", "looked at the wrong crop the first time", None, None).unwrap();+        let v = verdicts_of(&d, "vias", doc["draw"].as_u64().unwrap_or(0));+        assert_eq!(v[&first]["verdict"], "cannot_tell"); assert_eq!(v[&first]["amends"]["verdict"], was);+        record_verdict(&d, "vias", &seed, &first, was, "auditor", "and back: the first reading was right", None, None).unwrap();         let stamp: Vec<Answer> = list.iter().skip(list.len() / 2).map(|it| ans(it, "ok")).collect();         assert!(record_verdicts(&d, "vias", &seed, &stamp, "auditor", None, None).unwrap().is_empty());         let s = score_all(&d, DEFAULT_TARGET);@@ -2082,18 +2239,35 @@ mod tests {         for k in ["identity", "values", "blocks", "placement", "copper", "vias", "connectivity"] { audit_honestly(&d, k, None); }         let s = score_all(&d, DEFAULT_TARGET);         assert!(s.cats.iter().filter(|c| c.category != "layers").all(|c| c.score >= 90.0), "{}", table(&s));-        assert_eq!(s.overall, PHOTO_ONLY_CAP, "one weak category sets the overall");-        assert!(s.full, "the weak category needs outside evidence, so more photo audits cannot help");+        // the layer count needs outside evidence: it holds the bench score down and no longer the loop+        assert_eq!(s.bench, PHOTO_ONLY_CAP);+        assert_eq!(s.overall, PHOTO_ONLY_CAP, "the lower of the two scores is the lowest category");+        assert!(s.photo >= 90.0 && s.met(), "{}", table(&s));+        assert_eq!(verdict_for(&[], &s, 20), "TARGET MET");+        assert!(stall_lines(&s).is_empty());+        let bench = bench_lines(&s);+        assert_eq!(bench.len(), 1);+        assert!(bench[0].starts_with("layers") && bench[0].contains("What would raise it") && bench[0].contains("X-ray"), "{bench:?}");+        // a photo figure under the target that no audit holds: the loop goes on once, then stalls when nothing rises+        write(&d, "fidelity.json", &json!({"categories": {"copper": {"score": 97.0}, "vias": {"score": 97.0}, "placement": {"score": 50.0}}}));+        let s = score_all(&d, DEFAULT_TARGET);+        assert_eq!(s.photo, 50.0, "one weak photo figure sets the photo score");+        assert!(s.full, "nothing below the target is held by an audit, so more audits cannot help");         assert_eq!(verdict_for(&[], &s, 20), "CONTINUE");         let p1 = json!({"scores": s.json()});         assert_eq!(verdict_for(&[p1.clone()], &s, 20), "STALLED");+        // a pass recorded before the two scores holds no photo figure: the first pass after it counts as a rise, the next one stalls+        let mut old = s.json(); for c in old["categories"].as_object_mut().unwrap().values_mut() { c.as_object_mut().unwrap().remove("photo"); }+        assert_eq!(verdict_for(&[json!({"scores": old.clone()})], &s, 20), "CONTINUE");+        assert_eq!(verdict_for(&[json!({"scores": old}), p1.clone()], &s, 20), "STALLED");         let lines = stall_lines(&s);         assert_eq!(lines.len(), 1);-        assert!(lines[0].starts_with("layers") && lines[0].contains("Most likely to raise it") && lines[0].contains("X-ray"), "{lines:?}");-        assert!(overall_line(&s, "STALLED").contains("30.0") && !overall_line(&s, "STALLED").contains(" 90.0 of"));+        assert!(lines[0].starts_with("placement photo figure 50.0") && lines[0].contains("Most likely to raise it"), "{lines:?}");+        let line = overall_line(&s, "STALLED");+        assert!(line.contains("photo score 50.0 of 100, target 90: STALLED") && line.contains("bench score 30.0 of 100"), "{line}");         // the pass limit stops a loop that keeps creeping         let mut lower = s.json();-        lower["categories"]["layers"]["score"] = json!(10.0);+        lower["categories"]["placement"]["photo"]["score"] = json!(10.0);         assert_eq!(verdict_for(&vec![json!({"scores": lower}); 19], &s, 20), "STOPPED");         std::fs::remove_dir_all(&d).ok();     }@@ -2580,6 +2754,169 @@ mod tests {         std::fs::remove_dir_all(&d).ok();     } +    /// The lower of the two scores is the lowest category, and each category is the lower of its two figures: the regrouping let nothing rise.+    fn regrouped(s: &Scores) {+        for c in &s.cats {+            let parts: Vec<f64> = [&c.photo, &c.bench].iter().filter_map(|p| p.as_ref().map(|p| p.score)).collect();+            assert!(!parts.is_empty(), "{} has no figure", c.category);+            assert_eq!(parts.iter().cloned().fold(100.0, f64::min), c.score, "{}: {}", c.category, inputs_text(s));+            assert_eq!(c.ceilings.iter().map(|x| floor1(x.value)).fold(100.0, f64::min), c.score, "{}", c.category);+        }+        assert_eq!(s.photo.min(s.bench), s.overall, "{}", table(s));+        assert_eq!(s.cats.iter().map(|c| c.score).fold(100.0, f64::min), s.overall);+    }++    #[test]+    fn the_two_scores_regroup_the_ceilings_and_the_lower_one_is_the_old_overall() {+        let d = fixture("regroup");+        two_level_photos(&d);+        let s = score_all(&d, DEFAULT_TARGET);+        regrouped(&s);+        // layers has no photo figure, the photo categories no bench figure, and the two mixed categories have both+        let of = |s: &Scores, c: &str| { let x = s.cats.iter().find(|x| x.category == c).unwrap(); (x.photo.clone().map(|p| p.score), x.bench.clone().map(|p| p.score)) };+        assert_eq!(of(&s, "layers").0, None);+        for c in ["identity", "blocks", "placement", "copper", "vias"] { assert_eq!(of(&s, c).1, None, "{c}"); assert!(of(&s, c).0.is_some(), "{c}"); }+        assert!(of(&s, "connectivity").0.is_some() && of(&s, "connectivity").1.is_some());+        for k in CATEGORIES { if k != "layers" { audit_honestly(&d, k, None); } }+        let s = score_all(&d, DEFAULT_TARGET);+        regrouped(&s);+        // every ceiling in the JSON says which score it belongs to+        let j = s.json();+        assert!(j["categories"]["layers"]["ceilings"].as_array().unwrap().iter().all(|c| c["kind"] == "bench"));+        assert!(j["categories"]["copper"]["ceilings"].as_array().unwrap().iter().all(|c| c["kind"] == "photo"));+        assert_eq!(j["photo"]["score"], json!(s.photo));+        assert_eq!(j["bench"]["score"], json!(s.bench));+        assert_eq!(j["bench"]["targetApplies"], json!(false));+        std::fs::remove_dir_all(&d).ok();+    }++    #[test]+    fn unmarked_values_count_against_the_bench_score_and_never_against_the_photo_score() {+        let d = fixture("unmarked");+        let before = score_all(&d, DEFAULT_TARGET);+        let photo_before = before.cats.iter().find(|c| c.category == "values").unwrap().photo.clone().unwrap().score;+        // fifty unmarked capacitors whose value nobody could read+        let mut doc = read(&d, "parts.json");+        let more: Vec<Value> = (0..50).map(|i| json!({"ref": format!("CX{i}"), "tag": "MEASURED", "side": "top", "package": "C0402", "function": "decoupling", "value": "100nF", "marking": "", "pos_mm": [101.0 + i as f64 * 0.7, 135.0], "rot": 0, "snap": {"method": "pads"}, "fieldTags": {"identity": "SEEN", "value": "INFERRED"}, "tests": ["T-CX"]})).collect();+        doc["parts"].as_array_mut().unwrap().extend(more);+        write(&d, "parts.json", &doc);+        let s = score_all(&d, DEFAULT_TARGET);+        regrouped(&s);+        let v = s.cats.iter().find(|c| c.category == "values").unwrap();+        assert_eq!(v.photo.as_ref().unwrap().score, photo_before, "the marked parts' figure does not move");+        let b = v.bench.as_ref().unwrap();+        assert_eq!(b.score, 0.0, "{}", inputs_text(&s));+        assert!(b.limit.contains("0 of 50 unmarked parts") && b.raise.contains("bench plan"), "{} / {}", b.limit, b.raise);+        // a bench reading recorded on ten of them moves the figure by computation+        let mut doc = read(&d, "parts.json");+        for p in doc["parts"].as_array_mut().unwrap().iter_mut().filter(|p| st(p, "ref").starts_with("CX")).take(10) { p["fieldTags"]["value"] = json!("MEASURED"); }+        write(&d, "parts.json", &doc);+        let s = score_all(&d, DEFAULT_TARGET);+        assert_eq!(s.cats.iter().find(|c| c.category == "values").unwrap().bench.as_ref().unwrap().score, 20.0);+        // and an audit puts the two kinds in their own strata, so a marked part's verdict says nothing for an unmarked one+        let drawn = draw_sample(&d, "values", None, "tester", DEFAULT_TARGET).unwrap();+        assert!(drawn["counts"]["marked"].as_u64().unwrap() > 0 && drawn["counts"]["unmarked"].as_u64().unwrap() > 0, "{}", drawn["counts"]);+        std::fs::remove_dir_all(&d).ok();+    }++    #[test]+    fn a_run_that_never_routed_is_scored_for_what_it_states_and_a_bench_probe_moves_the_bench_score() {+        let d = fixture("unrouted");+        // two nets of 100 pins each: seen copper joins 60 pins of A into one group and 100 of B into another+        let refs: Vec<String> = arr(&read(&d, "parts.json")["parts"]).iter().map(|p| st(p, "ref").to_string()).collect();+        let pins = |r: &[String]| r.iter().map(|x| format!("{x}.1")).collect::<Vec<_>>();+        write(&d, "nets.json", &json!({"nets": [+            {"name": "A", "tag": "INFERRED", "tests": ["T-net-A"], "pins": pins(&refs[..100]), "copperGroups": [pins(&refs[..60])]},+            {"name": "B", "tag": "MEASURED", "pins": pins(&refs[100..]), "copperGroups": [pins(&refs[100..])]}]}));+        // no layer statement and no route: nothing earned, and the way out is named+        write(&d, "layers.json", &json!({}));+        let s = score_all(&d, DEFAULT_TARGET);+        let layers = s.cats.iter().find(|c| c.category == "layers").unwrap();+        assert_eq!(layers.score, 0.0);+        assert!(layers.raise.contains("layers state"), "{}", layers.raise);+        // stated UNKNOWN: still nothing, honestly, with the test that settles it+        std::fs::create_dir_all(d.join("bench")).unwrap();+        crate::layerstate::state(&d, &crate::layerstate::Ask { count: None, source: None, evidence: None, kind: None }).unwrap();+        let s = score_all(&d, DEFAULT_TARGET);+        regrouped(&s);+        let layers = s.cats.iter().find(|c| c.category == "layers").unwrap();+        assert_eq!(layers.score, 0.0);+        assert!(layers.limit.contains("UNKNOWN") && layers.raise.contains("T-layers"), "{} / {}", layers.limit, layers.raise);+        // a count from a document sits under the photo-only cap until that evidence is judged+        crate::layerstate::state(&d, &crate::layerstate::Ask { count: Some(4), source: Some("maker's page"), evidence: None, kind: None }).unwrap();+        assert_eq!(score(&d, "layers"), PHOTO_ONLY_CAP);+        // connectivity: 198 connections needed, 40 of them no photo shows; no ceiling asks for a routed copy+        let s = score_all(&d, DEFAULT_TARGET);+        regrouped(&s);+        let conn = s.cats.iter().find(|c| c.category == "connectivity").unwrap();+        assert!(!conn.ceilings.iter().any(|x| x.name == twoscores::KICAD_CLOSED));+        let seen = conn.ceilings.iter().find(|x| x.name == twoscores::CONNECTIONS).unwrap();+        assert_eq!(floor1(seen.value), floor1(100.0 * 158.0 / 198.0), "{}", seen.inputs);+        assert!(seen.inputs.contains("40 left unrouted as ratsnest"), "{}", seen.inputs);+        let pins_before = conn.ceilings.iter().find(|x| x.name == twoscores::PINS_PROVEN).unwrap().value;+        // ten recorded bench probes, each found joined+        let hidden = unrouted::connections(&arr(&read(&d, "nets.json")["nets"]));+        assert_eq!(hidden.len(), 40);+        let rec: serde_json::Map<String, Value> = hidden.iter().take(10).map(|h| (h.id.clone(), json!({"result": "joined", "a": h.from[0], "b": h.to[0], "to": h.to}))).collect();+        write(&d, crate::bench::MEASURED, &json!({"connections": rec}));+        let s = score_all(&d, DEFAULT_TARGET);+        regrouped(&s);+        let conn = s.cats.iter().find(|c| c.category == "connectivity").unwrap();+        assert_eq!(floor1(conn.ceilings.iter().find(|x| x.name == twoscores::CONNECTIONS).unwrap().value), floor1(100.0 * 168.0 / 198.0));+        assert!(conn.ceilings.iter().find(|x| x.name == twoscores::PINS_PROVEN).unwrap().value >= pins_before);+        // a record for a connection that is no longer in nets.json counts for nothing+        let mut nets = read(&d, "nets.json"); nets["nets"][0]["copperGroups"] = json!([pins(&refs[..100])]);+        write(&d, "nets.json", &nets);+        let s = score_all(&d, DEFAULT_TARGET);+        assert_eq!(floor1(s.cats.iter().find(|c| c.category == "connectivity").unwrap().ceilings.iter().find(|x| x.name == twoscores::CONNECTIONS).unwrap().value), 100.0);+        std::fs::remove_dir_all(&d).ok();+    }++    #[test]+    fn a_router_that_counted_more_than_the_pins_need_gives_no_negative_count() {+        let d = fixture("negative");+        // 198 connections needed by the pins; the router reports 245, because it also joined islands that reach no pin+        write(&d, "layers.json", &json!({"proposed": 2, "candidates": [{"layers": 2, "open": 0, "connections": 245, "kicadOpen": 0}], "planes": []}));+        let s = score_all(&d, DEFAULT_TARGET);+        let seen = s.cats.iter().find(|c| c.category == "connectivity").unwrap().ceilings.iter().find(|x| x.name == twoscores::CONNECTIONS).unwrap().clone();+        assert_eq!(seen.value, 0.0);+        assert!(seen.inputs.starts_with("0 of 198 connections") && !seen.inputs.contains(" -") && seen.inputs.contains("the router counted 245"), "{}", seen.inputs);+        std::fs::remove_dir_all(&d).ok();+    }++    #[test]+    fn a_sample_that_cannot_be_drawn_stops_holding_the_loop_and_earns_nothing() {+        let d = fixture("undrawable");+        // flat photos: no point is plain enough to carry a planted copper claim, so the copper draw refuses+        let e = draw_sample(&d, "copper", None, "tester", DEFAULT_TARGET).unwrap_err();+        assert!(e.contains("planted claims"), "{e}");+        let s = score_all(&d, DEFAULT_TARGET);+        let cu = s.cats.iter().find(|c| c.category == "copper").unwrap();+        assert!(!cu.held, "nobody can give this audit, so the loop does not wait for it");+        assert_eq!(cu.score, NO_AUDIT_CAP, "and it earns nothing: the cap stands");+        assert!(cu.limit.contains("no sample can be drawn") && cu.raise.contains("no audit can raise this"), "{} / {}", cu.limit, cu.raise);+        assert!(audit_files(&d).contains(&"audits/copper/undrawable.json".to_string()));+        // the items change (photos on which a claim can be planted): the record lapses and the audit is waited for again+        two_level_photos(&d);+        let s = score_all(&d, DEFAULT_TARGET);+        assert!(s.cats.iter().find(|c| c.category == "copper").unwrap().held);+        draw_sample(&d, "copper", None, "tester", DEFAULT_TARGET).unwrap();+        assert!(!d.join("audits/copper/undrawable.json").exists());+        std::fs::remove_dir_all(&d).ok();+    }++    #[test]+    fn a_category_with_nothing_to_audit_does_not_hold_the_loop() {+        let d = fixture("novias");+        let mut c = read(&d, "copper.json"); c["vias"] = json!([]);+        write(&d, "copper.json", &c);+        write(&d, "detect/domes.json", &json!({"domes": []}));+        let s = score_all(&d, DEFAULT_TARGET);+        let vias = s.cats.iter().find(|c| c.category == "vias").unwrap();+        assert!(!vias.held, "a board with no vias has no via audit to wait for");+        std::fs::remove_dir_all(&d).ok();+    }+     #[test]     fn a_report_cannot_claim_more_than_was_earned() {         let d = fixture("claim");
src/copperchecks.rsadded+120
@@ -0,0 +1,120 @@+//! What `check copper` asks of the traced copper as a whole: that no island has swallowed a face.+//!+//! On the USB hub test board (2026-11-10) the top face's laminate read as copper, so one island held 192 of the 246 joined pins, among them every pin of the 36-pin+//! hub chip, and `check copper` passed because each item on its own was well formed. These tests read what `copper trace` wrote (`detect/copper-measured.json`,+//! `detect/copper-island-accounting.json`) and `nets.json`, and fail with a sentence that says what to look at.+use serde_json::Value;+use std::collections::{BTreeMap, HashMap};+use std::path::Path;++/// A face needs this many joined pins before a share of them means anything.+pub const MIN_PINS: usize = 30;+/// One island with more than this share of a face's joined pins has swallowed it. Two thirds: the hub board's top read 0.78 when its laminate read as copper; the+/// largest honest share on the boards run so far is the Driver bottom's ground pour at 0.51 (43 of 85, and several of those joins are the weak ring test's), then+/// 0.20 and under on the Driver top and both CM5 faces.+pub const SWALLOW_SHARE: f64 = 2.0 / 3.0;+/// A part with at least `BIG_PART` pins never has more than `PART_SHARE` of them on one net: the worst on the boards run so far is 4 of a 16-pin header (0.25) and+/// 9 of a 49-pad chip (0.18), where the hub chip had all 37 on the one island.+pub const BIG_PART: usize = 16;+pub const PART_SHARE: f64 = 2.0 / 3.0;+/// More copper than this over the part of a face that can be seen is not a board: the fullest face run so far, the Driver bottom, reads 0.80.+pub const MAX_COPPER_SHARE: f64 = 0.95;+/// From this share of a face's pins, an island that also joins `HINT_NETS` or more named nets is worth a look, without failing: the Driver bottom's ground pour is+/// one (0.51 of the pins, 5 nets), and what is wrong there is pad joins, which `connect measure` replaces.+const HINT_SHARE: f64 = 1.0 / 3.0;+const HINT_NETS: usize = 3;++pub struct FaceStat { pub side: String, pub copper_share: Option<f64>, pub both_copper: Option<f64>, pub both_laminate: Option<f64>, pub edges: usize }++/// `joins`: (side, island, pin) for every pin the trace joined to an island. `part_pins`: how many pins each part has. `pin_net`: the nets named so far.+/// Returns what fails and what is only worth a look.+pub fn judge(joins: &[(String, u64, String)], part_pins: &HashMap<String, usize>, pin_net: &HashMap<String, String>, faces: &[FaceStat]) -> (Vec<String>, Vec<String>) {+    let (mut errs, mut hints) = (vec![], vec![]);+    let mut by_face: BTreeMap<&str, BTreeMap<u64, Vec<&str>>> = BTreeMap::new();+    for (side, isl, pin) in joins { by_face.entry(side.as_str()).or_default().entry(*isl).or_default().push(pin.as_str()); }+    for (side, islands) in &by_face {+        let total: usize = islands.values().map(|v| v.len()).sum();+        let see = format!("evidence/copper-islands-{side}.jpg");+        if let Some((isl, pins)) = islands.iter().max_by_key(|x| x.1.len()) {+            let share = pins.len() as f64 / total.max(1) as f64;+            let mut nets: BTreeMap<&str, usize> = BTreeMap::new();+            for p in pins { if let Some(n) = pin_net.get(*p) { *nets.entry(n.as_str()).or_default() += 1; } }+            let named = if nets.len() >= 2 { let mut v: Vec<(&str, usize)> = nets.iter().map(|x| (*x.0, *x.1)).collect(); v.sort_by(|a, b| b.1.cmp(&a.1)); format!(", named on {} different nets ({})", v.len(), v.iter().take(5).map(|x| format!("{} x{}", x.0, x.1)).collect::<Vec<_>>().join(", ")) } else { String::new() };+            if total >= MIN_PINS && share > SWALLOW_SHARE {+                errs.push(format!("{side} face: island {isl} holds {} of the {total} pins joined on this face ({:.0} percent){named}. No one conductor holds two thirds of a face's pins: laminate has been read as copper here, or a gap was lost, and everything joined through it. Look at {see} against the photo, and at the levels in the run log of `copper trace`.", pins.len(), share * 100.0));+            } else if total >= MIN_PINS && share > HINT_SHARE && nets.len() >= HINT_NETS {+                hints.push(format!("{side} face: island {isl} holds {} of the {total} joined pins ({:.0} percent){named}. Look at it in {see}: either pads were joined to a pour they only sit in, or the pour is two conductors.", pins.len(), share * 100.0));+            }+        }+        // one island with most of a large part's pins+        for (isl, pins) in islands {+            let mut per: BTreeMap<&str, usize> = BTreeMap::new();+            for p in pins { *per.entry(p.split('.').next().unwrap_or("")).or_default() += 1; }+            for (part, n) in per { let all = part_pins.get(part).copied().unwrap_or(0);+                if all >= BIG_PART && n as f64 > PART_SHARE * all as f64 { errs.push(format!("{side} face: island {isl} joins {n} of the {all} pins of {part}. A part that size has its pins on many nets, so this island is several conductors read as one. Look at {part} in {see}.")); } }+        }+    }+    for f in faces {+        if let Some(s) = f.copper_share { if s > MAX_COPPER_SHARE { errs.push(format!("{} face: {:.0} percent of what can be seen read as copper. No board is that full: the laminate level was not found. Look at evidence/copper-islands-{}.jpg.", f.side, s * 100.0, f.side)); } }+        if let (Some(a), Some(b)) = (f.both_copper, f.both_laminate) { if f.edges >= 500 && a.max(b) > 0.5 {+            errs.push(format!("{} face: {:.0} percent of the photo's copper-sized step edges lie wholly in the {} class, after the levels were taken from those edges. The copper and laminate levels do not fit this photo, so the islands on this face cannot be trusted.", f.side, a.max(b) * 100.0, if a > b { "copper" } else { "laminate" })); } }+    }+    (errs, hints)+}++/// Read what the trace wrote and judge it. A run traced before these figures existed has only the pin tests applied.+pub fn of_run(flow: &Path) -> (Vec<String>, Vec<String>) {+    let rd = |p: &str| -> Value { std::fs::read_to_string(flow.join(p)).ok().and_then(|s| serde_json::from_str(&s).ok()).unwrap_or(Value::Null) };+    let m = rd("detect/copper-measured.json");+    if m.is_null() { return (vec![], vec![]); }+    let joins: Vec<(String, u64, String)> = m["joins"].as_array().into_iter().flatten().filter_map(|j| Some((j["side"].as_str()?.to_string(), j["island"].as_u64()?, j["pin"].as_str()?.to_string()))).collect();+    let mut part_pins: HashMap<String, usize> = HashMap::new();+    for p in m["profile"].as_array().into_iter().flatten() { if let Some(pin) = p["pin"].as_str() { *part_pins.entry(pin.split('.').next().unwrap_or("").to_string()).or_default() += 1; } }+    let mut pin_net: HashMap<String, String> = HashMap::new();+    for n in rd("nets.json")["nets"].as_array().into_iter().flatten() { for q in n["pins"].as_array().into_iter().flatten().filter_map(|x| x.as_str()) { pin_net.insert(q.to_string(), n["name"].as_str().unwrap_or("").to_string()); } }+    // a face the reader gave up on is said so, not failed again (`faceread`)+    let acc = rd("detect/copper-island-accounting.json");+    let unread: Vec<String> = acc["perFace"].as_array().into_iter().flatten().filter(|f| f["unread"].is_string()).map(|f| format!("{} face was NOT READ: {}. Nothing is drawn from it and its pins join nothing there: their nets must come from the other face, a datasheet or the bench.", f["side"].as_str().unwrap_or(""), f["unread"].as_str().unwrap_or(""))).collect();+    let faces: Vec<FaceStat> = acc["perFace"].as_array().into_iter().flatten().filter(|f| !f["unread"].is_string()).map(|f| FaceStat { side: f["side"].as_str().unwrap_or("").to_string(), copper_share: f["copperShare"].as_f64(), both_copper: f["levelsAgainstEdges"]["bothCopper"].as_f64(), both_laminate: f["levelsAgainstEdges"]["bothLaminate"].as_f64(), edges: f["levelsAgainstEdges"]["edges"].as_u64().unwrap_or(0) as usize }).collect();+    let (errs, mut hints) = judge(&joins, &part_pins, &pin_net, &faces);+    hints.extend(unread);+    for f in acc["perFace"].as_array().into_iter().flatten().filter(|f| f["warning"].is_string() && !f["unread"].is_string()) { hints.push(format!("{} face, worth a look: {}", f["side"].as_str().unwrap_or(""), f["warning"].as_str().unwrap_or(""))); }+    for r in m["partPinsRefused"].as_array().into_iter().flatten() { hints.push(format!("{}: island {} on the {} face held {} of its {} pins, so none of them was joined to it. A part that size has its pins on many nets: the island is several conductors read as one. Look at {} in evidence/copper-islands-{}.jpg.", r["part"].as_str().unwrap_or(""), r["island"], r["side"].as_str().unwrap_or(""), r["pins"], r["of"], r["part"].as_str().unwrap_or(""), r["side"].as_str().unwrap_or(""))); }+    for r in m["padPairsRefused"].as_array().into_iter().flatten() { hints.push(format!("{}: both pads lay on one island ({}), so neither was joined to it. Look at the part in evidence/copper-islands-*.jpg: a gap beside it was lost.", r["part"].as_str().unwrap_or(""), r["islands"])); }+    (errs, hints)+}++#[cfg(test)]+mod tests {+    use super::*;+    fn joins(spec: &[(&str, u64, &str, usize)]) -> Vec<(String, u64, String)> { spec.iter().flat_map(|(side, isl, part, n)| (1..=*n).map(move |k| (side.to_string(), *isl, format!("{part}.{k}")))).collect() }+    #[test]+    fn one_island_with_most_of_a_faces_pins_fails_and_says_which() {+        // the hub board's baseline: island 157 with 192 pins, the rest spread thin+        let mut j = joins(&[("top", 157, "U1", 37), ("top", 157, "R", 155)]);+        j.extend(joins(&[("top", 9, "C", 30), ("top", 12, "J", 24)]));+        let parts = HashMap::from([("U1".to_string(), 37usize)]);+        let (errs, _) = judge(&j, &parts, &HashMap::new(), &[]);+        assert!(errs.iter().any(|e| e.contains("island 157 holds 192 of the 246")), "{errs:?}");+        assert!(errs.iter().any(|e| e.contains("37 of the 37 pins of U1")), "{errs:?}");+    }+    #[test]+    fn a_ground_pour_with_half_the_pins_passes_with_a_hint() {+        // the Driver bottom: 43 of 85 on the ground pour, five nets named among them+        let mut j = joins(&[("bottom", 211, "J7", 4), ("bottom", 211, "H", 39)]);+        j.extend(joins(&[("bottom", 3, "K", 42)]));+        let mut nets = HashMap::new();+        for (k, n) in ["GND", "GND", "+3V3", "CAN_H"].iter().enumerate() { nets.insert(format!("J7.{}", k + 1), n.to_string()); }+        let parts = HashMap::from([("J7".to_string(), 16usize), ("H".to_string(), 100), ("K".to_string(), 100)]);+        let (errs, hints) = judge(&j, &parts, &nets, &[]);+        assert!(errs.is_empty(), "{errs:?}");+        assert!(hints.iter().any(|h| h.contains("island 211") && h.contains("3 different nets")), "{hints:?}");+    }+    #[test]+    fn a_face_that_is_all_copper_or_whose_levels_miss_its_edges_fails() {+        let f = [FaceStat { side: "top".into(), copper_share: Some(0.97), both_copper: Some(0.1), both_laminate: Some(0.1), edges: 9000 }, FaceStat { side: "bottom".into(), copper_share: Some(0.5), both_copper: Some(0.73), both_laminate: Some(0.06), edges: 9000 }];+        let (errs, _) = judge(&[], &HashMap::new(), &HashMap::new(), &f);+        assert_eq!(errs.len(), 2, "{errs:?}");+        assert!(errs[0].contains("97 percent") && errs[1].contains("73 percent"));+    }+}
src/dspins.rsadded+510
@@ -0,0 +1,510 @@+//! Datasheet pins: what a chip's public datasheet says of every pin, as a file the program can read.+//!+//! `reverse-flow/datasheets/<ref>.pins.json` is written by the AI for each identified chip, from the datasheet it fetched+//! during the run, and cites the document by name, revision and page:+//!+//! ```json+//! {"part": "U2", "mpn": "DRV8323SRTAR",+//!  "document": {"name": "TI DRV832x 6 to 60-V Three-Phase Smart Gate Driver", "number": "SLVSDJ3", "revision": "D, March 2022", "page": 8, "file": "datasheets/ti-drv8323-slvsdj3.pdf"},+//!  "pins": [{"pad": "3", "name": "VCP", "type": "power_out", "expects": [{"kind": "capacitor_to_pin", "pin": "VM", "note": "1 uF, 25 V"}]}, ...]}+//! ```+//!+//! or, when there is no public datasheet or the marking does not settle the exact part, `{"part": "U5", "available": false,+//! "reason": "..."}`. A part whose marking was not read gets no datasheet witness, whatever file is there: identities are not guessed.+//!+//! What the program does with it:+//! - `datasheet apply` writes each part's `pins` (pad to name and KiCad pin type) into parts.json, so the schematic draws the+//!   chip with its real pin names. `pinsSource` holds the citation.+//! - attribution names a conductor that no net of the pin map reached from the named pin that sits on it (`Names::name_for`),+//!   and `stamp` tags that name DATASHEET with the document cited. The join stays exactly as the copper shows it.+//! - `auto` stops once with "AUTO NEEDS AI: datasheet pins" while an identified chip has neither file nor a "none available".+use crate::{items, kicad_out, sch};+use serde_json::{json, Value};+use std::collections::BTreeMap;+use std::path::Path;++pub const DIR: &str = "datasheets";+/// The electrical types a pins file may state.+pub const TYPES: [&str; 9] = ["power_in", "power_out", "ground", "input", "output", "bidirectional", "open_drain", "passive", "no_connect"];+/// What a pin may be said to expect around it. The witness (`connect witness`) checks each against the copper and the parts.+pub const EXPECTS: [&str; 11] = ["capacitor_to_ground", "capacitor_to_pin", "tied_to_pin", "tied_to_ground", "resistor_to_pin", "resistor_to_ground", "pull_up", "crystal", "inductor", "not_connected", "other"];++/// The KiCad pin type the schematic generator takes for one of ours.+pub fn kicad_type(t: &str) -> &'static str {+    match t { "power_in" | "ground" => "power_in", "power_out" => "power_out", "input" => "input", "output" => "output", "bidirectional" => "bidirectional", "open_drain" => "open_collector", _ => "passive" }+}++#[derive(Clone, Debug)]+pub struct Pin { pub pad: String, pub name: String, pub ty: String, pub net: Option<String>, pub expects: Vec<Value> }++#[derive(Clone, Debug)]+pub struct Sheet { pub part: String, pub file: String, pub cite: String, pub mpn: String, pub pins: Vec<Pin> }++impl Sheet {+    pub fn pin(&self, pad: &str) -> Option<&Pin> { self.pins.iter().find(|p| p.pad == pad) }+    /// Pads a pin name or a pad number stands for (a name may sit on several pads: GND, VDD).+    pub fn pads_named(&self, what: &str) -> Vec<String> {+        let by_name: Vec<String> = self.pins.iter().filter(|p| p.name.eq_ignore_ascii_case(what)).map(|p| p.pad.clone()).collect();+        if !by_name.is_empty() { return by_name; }+        self.pins.iter().filter(|p| p.pad == what).map(|p| p.pad.clone()).collect()+    }+}++/// A chip the stop names: an IC or a transistor of three pads or more whose marking was read.+#[derive(Clone, Debug)]+pub struct Chip { pub r: String, pub value: String, pub marking: String, pub footprint: String, pub pads: usize }++#[derive(Default)]+pub struct Status {+    /// identified chips with neither a pins file nor a "none available"+    pub missing: Vec<Chip>,+    /// "none available", with the reason given+    pub declined: Vec<(String, String)>,+    /// valid sheets of identified parts, by reference+    pub sheets: BTreeMap<String, Sheet>,+    /// files that do not pass, with what is wrong+    pub bad: Vec<(String, Vec<String>)>,+    /// files for parts whose marking was not read: no witness+    pub unread: Vec<String>,+    pub chips: usize,+}++fn text(v: &Value) -> String { match v { Value::String(s) => s.trim().to_string(), Value::Number(n) => n.to_string(), _ => String::new() } }++/// The marking was read: the part's value (or part number) is tagged as seen, measured or from a document, never inferred or+/// unknown. With no field tag on either, a part number beside a marking that is not called unreadable counts as read.+pub fn identified(p: &Value) -> bool {+    let has = |k: &str| !p[k].as_str().unwrap_or("").trim().is_empty();+    if !has("mpn") && !has("value") { return false; }+    let ft = &p["fieldTags"];+    match ft["mpn"].as_str().or(ft["value"].as_str()).or(ft["marking"].as_str()) {+        Some(t) => matches!(t, "SEEN" | "MEASURED" | "DATASHEET"),+        None => { let m = p["marking"].as_str().unwrap_or("").to_lowercase(); has("mpn") && !m.trim().is_empty() && !["unreadable", "none", "not read", "?"].iter().any(|w| m.contains(w)) }+    }+}++fn pads_of(flow: &Path, p: &Value) -> Option<Vec<String>> {+    let fp = p["footprint"].as_str().unwrap_or("");+    if fp.is_empty() { return None; }+    kicad_out::footprint_text(&kicad_out::cache_dir(flow), fp, p).ok().map(|t| sch::pad_numbers(&t))+}++fn cite(doc: &Value) -> String {+    let mut s = text(&doc["name"]);+    let num = text(&doc["number"]); if !num.is_empty() { s += &format!(", {num}"); }+    s += &format!(", revision {}", text(&doc["revision"]));+    s += &format!(", page {}", text(&doc["page"]));+    s+}++/// One file read against its part: the sheet, or everything wrong with it. `pads`: the footprint's pads when they are known.+pub fn parse(v: &Value, r: &str, file: &str, pads: Option<&[String]>) -> Result<Sheet, Vec<String>> {+    let mut e = vec![];+    let doc = &v["document"];+    for k in ["name", "revision", "page"] { if text(&doc[k]).is_empty() { e.push(format!("{file}: document.{k} is missing: cite the datasheet by name, revision and the page of its pin table")); } }+    let mut pins: Vec<Pin> = vec![];+    let rows = v["pins"].as_array().cloned().unwrap_or_default();+    if rows.is_empty() { e.push(format!("{file}: no pins: list every pin as {{\"pad\", \"name\", \"type\"}}, or answer {{\"part\": \"{r}\", \"available\": false, \"reason\": \"...\"}}")); }+    for row in &rows {+        let (pad, name, ty) = (text(&row["pad"]), text(&row["name"]), text(&row["type"]));+        if pad.is_empty() { e.push(format!("{file}: a pin has no pad number: {row}")); continue; }+        if pins.iter().any(|p| p.pad == pad) { e.push(format!("{file}: pad {pad} is listed twice")); continue; }+        if name.is_empty() { e.push(format!("{file}: pad {pad} has no name")); }+        if !TYPES.contains(&ty.as_str()) { e.push(format!("{file}: pad {pad} ({name}): type {ty:?} is not one of {}", TYPES.join(", "))); }+        let expects: Vec<Value> = row["expects"].as_array().cloned().unwrap_or_default();+        for x in &expects { let k = text(&x["kind"]); if !EXPECTS.contains(&k.as_str()) { e.push(format!("{file}: pad {pad} ({name}): expects kind {k:?} is not one of {}", EXPECTS.join(", "))); } }+        let net = Some(text(&row["net"])).filter(|s| !s.is_empty());+        pins.push(Pin { pad, name, ty, net, expects });+    }+    let sheet = Sheet { part: r.to_string(), file: file.to_string(), cite: cite(doc), mpn: text(&v["mpn"]), pins };+    for p in &sheet.pins { for x in &p.expects { if matches!(text(&x["kind"]).as_str(), "capacitor_to_pin" | "tied_to_pin" | "resistor_to_pin") {+        let other = text(&x["pin"]);+        if other.is_empty() || sheet.pads_named(&other).is_empty() { e.push(format!("{file}: pad {} ({}): expects {} names pin {other:?}, which is no pin name or pad of this part", p.pad, p.name, text(&x["kind"]))); }+    } } }+    if let (Some(pads), false) = (pads, sheet.pins.is_empty()) {+        for p in &sheet.pins { if !pads.contains(&p.pad) { e.push(format!("{file}: pad {} ({}) is not on the footprint of {r} (its pads: {}): the footprint or the package variant is wrong", p.pad, p.name, span(pads))); } }+        let lacking: Vec<&String> = pads.iter().filter(|q| sheet.pin(q).is_none()).collect();+        if !lacking.is_empty() { e.push(format!("{file}: footprint pad(s) {} of {r} have no entry: every pad takes one (an exposed pad is what the datasheet says it is, usually ground; a pad the datasheet does not list means the footprint is not this package)", lacking.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", "))); }+    }+    if e.is_empty() { Ok(sheet) } else { Err(e) }+}++fn span(pads: &[String]) -> String { if pads.len() > 12 { format!("{} to {}, {} pads", pads[0], pads[pads.len() - 1], pads.len()) } else { pads.join(", ") } }++/// Every `*.pins.json`, by the reference it answers for: its `part` (and `refs` for identical parts), else the file's name.+fn files(flow: &Path) -> BTreeMap<String, (String, Value)> {+    let mut out = BTreeMap::new();+    let mut names: Vec<String> = std::fs::read_dir(flow.join(DIR)).into_iter().flatten().flatten().map(|e| e.file_name().to_string_lossy().to_string()).filter(|n| n.ends_with(".pins.json")).collect();+    names.sort();+    for n in names {+        let rel = format!("{DIR}/{n}");+        let v = items::read_json(&flow.join(&rel)).unwrap_or_else(|e| json!({"unreadable": e}));+        let mut refs: Vec<String> = v["refs"].as_array().into_iter().flatten().map(text).filter(|s| !s.is_empty()).collect();+        let part = text(&v["part"]);+        refs.push(if part.is_empty() { n.trim_end_matches(".pins.json").to_string() } else { part });+        for r in refs { out.entry(r).or_insert((rel.clone(), v.clone())); }+    }+    out+}++/// A part whose value, part number or function says nothing is fitted at its position (`"fitted": false` says it outright).+pub fn not_fitted(p: &Value) -> bool {+    if p["fitted"] == json!(false) || p["dnp"] == json!(true) { return true; }+    ["value", "mpn", "function"].iter().any(|k| {+        let t = p[*k].as_str().unwrap_or("").to_lowercase();+        ["not fitted", "unfitted", "not populated", "unpopulated", "not placed", "not mounted", "no part fitted", "empty footprint", "do not populate", "do not fit"].iter().any(|w| t.contains(w))+            || t.split(|c: char| !c.is_ascii_alphanumeric()).any(|w| matches!(w, "dnp" | "dnf" | "dni" | "nf"))+    })+}++/// Where the run stands: which chips still want an answer, which sheets hold, which files do not.+pub fn status(flow: &Path) -> Status {+    let mut st = Status::default();+    let doc = items::read_json(&flow.join("parts.json")).unwrap_or(Value::Null);+    let mut fs = files(flow);+    for p in doc["parts"].as_array().into_iter().flatten() {+        let r = p["ref"].as_str().unwrap_or("").to_string();+        let fp = p["footprint"].as_str().unwrap_or("");+        let read = identified(p);+        let kind = matches!(sch::kind_of(&r, fp), sch::Kind::Ic | sch::Kind::Q);+        // the footprint is read only for a part that is asked about or answered for: this runs on every step of `auto`+        let pads = if (kind && read && !not_fitted(p)) || fs.contains_key(&r) { pads_of(flow, p) } else { None };+        // a footprint with no pads at all (a body outline standing in for a package nobody could match) has nothing to name: not asked+        // a position with no part on it has no datasheet to read: not asked+        let chip = kind && !not_fitted(p) && pads.as_ref().map(|v| v.len() >= 3).unwrap_or(true);+        let pads = pads.filter(|v| !v.is_empty());+        if chip && read { st.chips += 1; }+        match fs.remove(&r) {+            None => { if chip && read { st.missing.push(Chip { r, value: text(&p["mpn"]).is_empty().then(|| text(&p["value"])).unwrap_or_else(|| text(&p["mpn"])), marking: text(&p["marking"]), footprint: fp.rsplit(':').next().unwrap_or(fp).to_string(), pads: pads.map(|v| v.len()).unwrap_or(0) }); } }+            Some((file, v)) => {+                if !v["unreadable"].is_null() { st.bad.push((r, vec![text(&v["unreadable"])])); continue; }+                if v["available"] == json!(false) {+                    let why = text(&v["reason"]);+                    if why.is_empty() { st.bad.push((r.clone(), vec![format!("{file}: \"available\": false needs a \"reason\" (no public datasheet found, the marking fits several parts, ...)")])); } else { st.declined.push((r, why)); }+                    continue;+                }+                if !read { st.unread.push(format!("{r} ({file}): its marking was not read (parts.json tags its value {}), so it gets no datasheet witness. Read the marking first, or remove the file", p["fieldTags"]["value"].as_str().unwrap_or("with no tag"))); continue; }+                match parse(&v, &r, &file, pads.as_deref()) { Ok(s) => { st.sheets.insert(r, s); } Err(e) => st.bad.push((r, e)) }+            }+        }+    }+    for (r, (file, _)) in fs { st.bad.push((r.clone(), vec![format!("{file}: {r} is not a part in parts.json")])); }+    st+}++/// The lines the stop prints: the format, then each chip that waits.+pub fn ask_lines(st: &Status) -> Vec<String> {+    let mut v = vec![];+    if !st.missing.is_empty() {+        v.push(format!("For each chip below, fetch its public datasheet and write reverse-flow/{DIR}/<ref>.pins.json: {{\"part\": \"U1\", \"mpn\": \"<orderable part>\", \"document\": {{\"name\", \"number\", \"revision\", \"page\": <page of the pin table>, \"url\" or \"file\"}}, \"pins\": [{{\"pad\": \"1\", \"name\": \"VDD\", \"type\": \"{}\", \"expects\": [{{\"kind\": \"capacitor_to_ground\", \"note\": \"100 nF\"}}]}}, ...]}}. Every pad of the footprint takes an entry, the exposed pad too. `expects` is optional and holds what the datasheet's typical application shows at the pin ({}; `pin` names the other pin for the *_to_pin kinds). docs/method.md has a worked example.", TYPES.join("|"), EXPECTS.join(", ")));+        v.push(format!("No public datasheet, or the marking does not settle the exact part and package: write {{\"part\": \"<ref>\", \"available\": false, \"reason\": \"<why>\"}} in the same file instead. Never write pins for a part you could not identify. Then run `auto` again."));+        for c in &st.missing { v.push(format!("{}: {} (marking \"{}\", {}, {} pads): reverse-flow/{DIR}/{}.pins.json is missing", c.r, c.value, c.marking, c.footprint, c.pads, c.r)); }+    }+    for (_, errs) in &st.bad { v.extend(errs.iter().take(12).cloned()); }+    v.extend(st.unread.iter().cloned());+    v+}++/// parts.json as it would stand with every valid sheet written into its part's `pins`. `None` when it stands so already.+fn applied_doc(flow: &Path, st: &Status) -> Option<Value> {+    let mut doc = items::read_json(&flow.join("parts.json")).ok()?;+    let mut changed = false;+    for p in doc["parts"].as_array_mut().into_iter().flatten() {+        let r = p["ref"].as_str().unwrap_or("").to_string();+        match st.sheets.get(&r) {+            Some(s) => {+                // several pads of one name that drive (two VCAP pins of one regulator) are one output inside the chip: the first keeps the+                // driving type and the rest are passive, as KiCad's own symbols do, so the ERC does not call the chip's own tie a conflict+                let mut drives: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new();+                let pins: serde_json::Map<String, Value> = s.pins.iter().map(|q| { let t = kicad_type(&q.ty); let again = matches!(t, "power_out" | "output") && !drives.insert(q.name.as_str()); (q.pad.clone(), json!({"name": q.name, "type": if again { "passive" } else { t }})) }).collect();+                let (pins, src) = (Value::Object(pins), json!(s.cite));+                if p["pins"] != pins || p["pinsSource"] != src { p["pins"] = pins; p["pinsSource"] = src; changed = true; }+            }+            // pins an earlier sheet gave go with the sheet; pins typed by hand (no pinsSource) are not ours to remove+            None => if p["pinsSource"].is_string() { if let Some(o) = p.as_object_mut() { o.remove("pins"); o.remove("pinsSource"); changed = true; } }+        }+    }+    changed.then_some(doc)+}++pub fn applied(flow: &Path, st: &Status) -> bool { applied_doc(flow, st).is_none() }++/// `datasheet apply`: every valid sheet's pin names and types into its part. Fails on a file that does not pass.+pub fn apply(flow: &Path) -> Result<Value, Vec<String>> {+    let st = status(flow);+    if !st.bad.is_empty() || !st.unread.is_empty() { return Err(ask_lines(&Status { missing: vec![], ..st })); }+    let wrote = match applied_doc(flow, &st) {+        Some(doc) => { std::fs::write(flow.join("parts.json"), serde_json::to_string_pretty(&doc).unwrap_or_default() + "\n").map_err(|e| vec![format!("parts.json: {e}")])?; true }+        None => false,+    };+    Ok(json!({"chips": st.chips, "sheets": st.sheets.keys().collect::<Vec<_>>(), "pins": st.sheets.values().map(|s| s.pins.len()).sum::<usize>(), "declined": st.declined.iter().map(|(r, w)| json!({"part": r, "reason": w})).collect::<Vec<_>>(), "missing": st.missing.iter().map(|c| c.r.clone()).collect::<Vec<_>>(), "partsJsonChanged": wrote}))+}++/// What `datasheet status` prints.+pub fn status_lines(flow: &Path) -> (String, Vec<String>) {+    let st = status(flow);+    let mut h: Vec<String> = st.sheets.values().map(|s| format!("{}: {} pins from {} ({})", s.part, s.pins.len(), s.cite, s.file)).collect();+    for (r, why) in &st.declined { h.push(format!("{r}: none available: {why}")); }+    h.extend(ask_lines(&st));+    if !applied(flow, &st) && st.bad.is_empty() { h.push("parts.json does not carry these pins yet: `datasheet apply` (or `auto`) writes them".into()); }+    (format!("{} identified chip(s): {} with datasheet pins, {} none available, {} waiting, {} file(s) refused", st.chips, st.sheets.len(), st.declined.len(), st.missing.len(), st.bad.len() + st.unread.len()), h)+}++/// Net names the datasheets give, by pin ("U2.6").+#[derive(Default, Clone)]+pub struct Names { by_pin: BTreeMap<String, Named> }++#[derive(Clone, Debug)]+pub struct Named { pub net: String, pub pin_name: String, pub ty: String, pub cite: String, rank: u8 }++/// Which pin names a conductor that several named pins sit on: ground first, then what drives it, then what listens.+fn rank(ty: &str) -> u8 { match ty { "ground" => 0, "power_out" => 1, "power_in" => 2, "output" => 3, "open_drain" => 4, "bidirectional" => 5, "input" => 6, _ => 7 } }++fn clean(name: &str) -> String {+    let s: String = name.chars().map(|c| if c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '_') { c } else { '_' }).collect();+    s.trim_matches('_').to_string()+}++/// The net a named pin gives its copper: the name the sheet states for the board (`net`), GND for a ground pin, else+/// `<ref>_<pin name>`, so no two chips' pins share a name unless a sheet says they do. A no-connect pin names nothing.+pub fn net_of(r: &str, p: &Pin) -> Option<String> {+    if p.ty == "no_connect" { return None; }+    if let Some(n) = &p.net { return Some(n.clone()); }+    if p.ty == "ground" { return Some("GND".into()); }+    let n = clean(&p.name);+    if n.is_empty() { None } else { Some(format!("{r}_{n}")) }+}++impl Names {+    pub fn from(st: &Status) -> Names {+        let mut by_pin = BTreeMap::new();+        for s in st.sheets.values() { for p in &s.pins { if let Some(net) = net_of(&s.part, p) { by_pin.insert(format!("{}.{}", s.part, p.pad), Named { net, pin_name: p.name.clone(), ty: p.ty.clone(), cite: s.cite.clone(), rank: rank(&p.ty) }); } } }+        Names { by_pin }+    }+    pub fn load(flow: &Path) -> Names { Names::from(&status(flow)) }+    pub fn get(&self, pin: &str) -> Option<&Named> { self.by_pin.get(pin) }+    pub fn is_empty(&self) -> bool { self.by_pin.is_empty() }+    /// The pin that names a conductor these pins sit on, and the name it gives.+    pub fn namer<'a>(&'a self, pins: impl IntoIterator<Item = &'a String>) -> Option<(&'a String, &'a Named)> {+        let mut best: Option<(&String, &Named)> = None;+        for q in pins { if let Some((k, n)) = self.by_pin.get_key_value(q) { if best.map(|b| (n.rank, sch_natural(k)) < (b.1.rank, sch_natural(b.0))).unwrap_or(true) { best = Some((k, n)); } } }+        best+    }+    pub fn name_for<'a>(&'a self, pins: impl IntoIterator<Item = &'a String>) -> Option<String> { self.namer(pins).map(|(_, n)| n.net.clone()) }+    /// Changes whenever a name the datasheets give changes: attribution runs again then.+    pub fn key(&self) -> String {+        if self.by_pin.is_empty() { return String::new(); }+        crate::confidence::sha256_hex(self.by_pin.iter().map(|(k, n)| format!("{k}={}", n.net)).collect::<Vec<_>>().join("\n").as_bytes())+    }+}++/// Order pins as people count them: U2.9 before U2.10.+fn sch_natural(pin: &str) -> (String, u64, String) {+    let (r, p) = pin.rsplit_once('.').unwrap_or((pin, ""));+    (r.to_string(), p.parse::<u64>().unwrap_or(u64::MAX), p.to_string())+}++/// After attribution: every net records the datasheet pins that sit on it, and a net that took its name from one is tagged+/// for what it is. The name is from the document (`nameSource` cites it, `nameTag` DATASHEET); the pins are on the net because+/// the copper joins them, which `copperEvidence` and `tag` MEASURED go on saying. Writes nets.json and net-evidence.json.+pub fn stamp(flow: &Path) -> Result<Value, String> {+    let names = Names::load(flow);+    let mut nets = items::read_json(&flow.join("nets.json"))?;+    let mut ev = items::read_json(&flow.join("net-evidence.json")).unwrap_or(Value::Null);+    let (mut named, mut carrying) = (vec![], 0);+    for n in nets["nets"].as_array_mut().into_iter().flatten() {+        let name = n["name"].as_str().unwrap_or("").to_string();+        let pins: Vec<String> = n["pins"].as_array().into_iter().flatten().filter_map(|p| p.as_str().map(str::to_string)).collect();+        let mut on: Vec<(&String, &Named)> = pins.iter().filter_map(|q| names.get(q).map(|d| (q, d))).collect();+        on.sort_by_key(|(q, d)| (d.rank, sch_natural(q)));+        let Some(o) = n.as_object_mut() else { continue };+        if on.is_empty() { o.remove("datasheetPins"); } else { carrying += 1; o.insert("datasheetPins".into(), json!(on.iter().map(|(q, d)| json!({"pin": q, "name": d.pin_name, "type": d.ty})).collect::<Vec<_>>())); }+        // only a net attribution made from copper takes a datasheet name; a net somebody stated keeps what they stated+        let by = on.iter().find(|(_, d)| d.net == name);+        let ours = o.get("fromCopper") == Some(&json!(true));+        match (ours, by) {+            (true, Some((q, d))) => {+                o.insert("nameSource".into(), json!(d.cite)); o.insert("source".into(), json!(d.cite)); o.insert("nameTag".into(), json!("DATASHEET")); o.insert("statedTag".into(), json!("DATASHEET"));+                o.insert("why".into(), json!(format!("the copper at {q} carries the name of that pin ({}, {}): the name is from the datasheet, the pins are joined as the copper shows", d.pin_name, d.ty.replace('_', " "))));+                // the name is documented; a join every pin of which is seen stays MEASURED, and a contradiction stays a finding+                if o.get("tag") == Some(&json!("INFERRED")) && !o.contains_key("contradiction") { o.insert("tag".into(), json!("DATASHEET")); }+                named.push(name.clone());+            }+            // a name an earlier sheet gave and no sheet gives now is not carried on as documented+            (true, None) if o.get("nameTag") == Some(&json!("DATASHEET")) => { for k in ["nameSource", "source", "nameTag", "statedTag"] { o.remove(k); } if o.get("tag") == Some(&json!("DATASHEET")) { o.insert("tag".into(), json!("INFERRED")); } o.insert("why".into(), json!("no name in the pin map: pins joined by copper")); }+            _ => {}+        }+        let (tag, src, dp) = (o.get("tag").cloned().unwrap_or(Value::Null), o.get("nameSource").cloned(), o.get("datasheetPins").cloned());+        if let Some(row) = ev["nets"].as_array_mut().into_iter().flatten().find(|r| r["net"].as_str() == Some(name.as_str())) {+            row["tag"] = tag;+            if named.last() == Some(&name) { if let Some(s) = src { row["nameSource"] = s; } row["nameTag"] = json!("DATASHEET"); }+            match dp { Some(d) => row["datasheetPins"] = d, None => { if let Some(r) = row.as_object_mut() { r.remove("datasheetPins"); } } }+        }+    }+    let w = |p: &Path, v: &Value| std::fs::write(p, serde_json::to_string_pretty(v).unwrap_or_default() + "\n").map_err(|e| format!("{}: {e}", p.display()));+    w(&flow.join("nets.json"), &nets)?;+    if ev.is_object() { w(&flow.join("net-evidence.json"), &ev)?; }+    Ok(json!({"netsNamedFromDatasheet": named, "netsCarryingDatasheetPins": carrying, "nets": nets["nets"].as_array().map(|a| a.len()).unwrap_or(0)}))+}++#[cfg(test)]+mod tests {+    use super::*;+    use std::collections::BTreeSet;++    fn dir(name: &str) -> std::path::PathBuf {+        let d = std::env::temp_dir().join(format!("dspins-test-{name}-{}", std::process::id())).join("reverse-flow");+        let _ = std::fs::remove_dir_all(&d);+        std::fs::create_dir_all(d.join(DIR)).unwrap();+        d+    }+    fn put(d: &Path, f: &str, v: Value) { std::fs::write(d.join(f), v.to_string()).unwrap(); }+    #[test]+    fn a_position_with_no_part_fitted_is_not_asked_for_datasheet_pins() {+        let d = dir("notfitted");+        put(&d, "parts.json", json!({"parts": [+            {"ref": "U1", "value": "XY1234", "marking": "XY1234", "fieldTags": {"value": "SEEN"}},+            {"ref": "U2", "value": "not fitted", "marking": "", "fieldTags": {"value": "SEEN"}},+            {"ref": "U3", "value": "DNP", "fieldTags": {"value": "SEEN"}},+            {"ref": "Q1", "value": "AO3400", "mpn": "AO3400", "function": "pads only, unpopulated", "fieldTags": {"value": "SEEN"}},+            {"ref": "U4", "value": "LM000", "fitted": false, "fieldTags": {"value": "SEEN"}}]}));+        let st = status(&d);+        assert_eq!(st.missing.iter().map(|c| c.r.as_str()).collect::<Vec<_>>(), vec!["U1"], "only the chip that is there is asked about");+        assert_eq!(st.chips, 1);+        // words that only look alike do not count: a part number with NF or NC inside it is a part+        assert!(!not_fitted(&json!({"value": "NFC reader ST25", "mpn": "INF123NC7"})));+        assert!(not_fitted(&json!({"value": "0R (NF)"})) && not_fitted(&json!({"function": "Do Not Populate"})));+        std::fs::remove_dir_all(d.parent().unwrap()).ok();+    }++    fn sheet() -> Value {+        json!({"part": "U1", "mpn": "XY1234", "document": {"name": "XY1234 regulator", "number": "DS-1", "revision": "C", "page": 4},+            "pins": [{"pad": 1, "name": "VIN", "type": "power_in", "expects": [{"kind": "capacitor_to_ground"}]}, {"pad": "2", "name": "GND", "type": "ground"}, {"pad": "3", "name": "SW", "type": "output", "expects": [{"kind": "inductor"}, {"kind": "capacitor_to_pin", "pin": "BST"}]}, {"pad": "4", "name": "BST", "type": "passive"}, {"pad": "5", "name": "NC", "type": "no_connect"}]})+    }+    // parts with no footprint: the pads are not known, so only the file's own rules are checked+    fn parts() -> Value {+        json!({"parts": [+            {"ref": "U1", "value": "XY1234", "marking": "XY1234 831", "fieldTags": {"identity": "SEEN", "value": "SEEN"}},+            {"ref": "U2", "value": "regulator (marking unreadable)", "marking": "unreadable", "fieldTags": {"identity": "INFERRED", "value": "UNKNOWN"}},+            {"ref": "U3", "value": "ABC55", "mpn": "ABC55-Q1", "marking": "ABC55"},+            {"ref": "R1", "value": "10k", "marking": "103", "fieldTags": {"value": "SEEN"}}]})+    }++    #[test]+    fn a_sheet_is_read_cited_and_checked_against_the_footprint() {+        let pads: Vec<String> = (1..=5).map(|k| k.to_string()).collect();+        let s = parse(&sheet(), "U1", "datasheets/U1.pins.json", Some(&pads)).unwrap();+        assert_eq!(s.cite, "XY1234 regulator, DS-1, revision C, page 4");+        assert_eq!(s.pins.len(), 5);+        assert_eq!(s.pin("1").unwrap().name, "VIN");+        assert_eq!(s.pads_named("bst"), vec!["4".to_string()]);+        // a footprint with a pad the sheet does not name, and a sheet pad the footprint lacks: both are said+        let six: Vec<String> = ["1", "2", "3", "4", "6"].iter().map(|k| k.to_string()).collect();+        let e = parse(&sheet(), "U1", "f", Some(&six)).unwrap_err();+        assert!(e.iter().any(|l| l.contains("pad 5 (NC) is not on the footprint")) && e.iter().any(|l| l.contains("footprint pad(s) 6")));+        // no citation, an unknown type, an expectation naming no pin: refused, each with its reason+        let mut bad = sheet(); bad["document"] = json!({"name": "x"}); bad["pins"][0]["type"] = json!("supply"); bad["pins"][2]["expects"][1]["pin"] = json!("BOOT");+        let e = parse(&bad, "U1", "f", None).unwrap_err();+        assert!(e.iter().any(|l| l.contains("document.revision")) && e.iter().any(|l| l.contains("document.page")));+        assert!(e.iter().any(|l| l.contains("type \"supply\"")) && e.iter().any(|l| l.contains("names pin \"BOOT\"")));+    }++    #[test]+    fn only_chips_whose_marking_was_read_are_asked_for_and_a_reasoned_no_is_an_answer() {+        let d = dir("status");+        put(&d, "parts.json", parts());+        let st = status(&d);+        // U1 (read) and U3 (part number beside a marking) wait; U2's marking was not read; R1 is no chip+        assert_eq!(st.missing.iter().map(|c| c.r.as_str()).collect::<Vec<_>>(), vec!["U1", "U3"]);+        let lines = ask_lines(&st);+        assert!(lines[0].contains("datasheets/<ref>.pins.json") && lines[1].contains("\"available\": false"));+        assert!(lines.iter().any(|l| l.starts_with("U1: XY1234")) && !lines.iter().any(|l| l.starts_with("U2")));+        put(&d, "datasheets/U1.pins.json", sheet());+        // "none available" without a reason is no answer; with one it is+        put(&d, "datasheets/U3.pins.json", json!({"part": "U3", "available": false}));+        let st = status(&d);+        assert!(st.missing.is_empty() && st.sheets.contains_key("U1") && st.bad.len() == 1 && st.bad[0].1[0].contains("needs a \"reason\""));+        put(&d, "datasheets/U3.pins.json", json!({"part": "U3", "available": false, "reason": "ABC55 is a top mark shared by three parts"}));+        let st = status(&d);+        assert!(st.bad.is_empty() && st.declined == vec![("U3".to_string(), "ABC55 is a top mark shared by three parts".to_string())]);+        // pins for a chip nobody identified are refused: no witness from a guess+        put(&d, "datasheets/U2.pins.json", { let mut s = sheet(); s["part"] = json!("U2"); s });+        let st = status(&d);+        assert!(st.unread.len() == 1 && st.unread[0].contains("its marking was not read") && !st.sheets.contains_key("U2"));+        assert!(apply(&d).is_err());+        std::fs::remove_dir_all(d.parent().unwrap()).ok();+    }++    #[test]+    fn apply_writes_pin_names_and_kicad_types_once_and_takes_them_back_with_the_sheet() {+        let d = dir("apply");+        put(&d, "parts.json", parts());+        put(&d, "datasheets/U1.pins.json", sheet());+        put(&d, "datasheets/U3.pins.json", json!({"part": "U3", "available": false, "reason": "no public datasheet"}));+        assert!(!applied(&d, &status(&d)));+        let s = apply(&d).unwrap();+        assert_eq!(s["partsJsonChanged"], true);+        let p = items::read_json(&d.join("parts.json")).unwrap();+        assert_eq!(p["parts"][0]["pins"]["1"], json!({"name": "VIN", "type": "power_in"}));+        assert_eq!(p["parts"][0]["pins"]["2"], json!({"name": "GND", "type": "power_in"}));+        assert_eq!(p["parts"][0]["pins"]["5"], json!({"name": "NC", "type": "passive"}));+        assert_eq!(p["parts"][0]["pinsSource"], "XY1234 regulator, DS-1, revision C, page 4");+        assert!(p["parts"][1]["pins"].is_null() && p["parts"][2]["pins"].is_null());+        // two pads of one name that both drive are one output inside the chip: the second is passive, so the chip's own tie is no ERC conflict+        let mut two = sheet(); two["pins"][3] = json!({"pad": "4", "name": "SW", "type": "output"}); two["pins"][2]["expects"] = json!([]);+        put(&d, "datasheets/U1.pins.json", two);+        apply(&d).unwrap();+        let q = items::read_json(&d.join("parts.json")).unwrap();+        assert_eq!(q["parts"][0]["pins"]["3"]["type"], "output"); assert_eq!(q["parts"][0]["pins"]["4"], json!({"name": "SW", "type": "passive"}));+        put(&d, "datasheets/U1.pins.json", sheet());+        apply(&d).unwrap();+        // a second run changes nothing: the plan sees it as done+        assert!(applied(&d, &status(&d)));+        assert_eq!(apply(&d).unwrap()["partsJsonChanged"], false);+        // the sheet withdrawn: the pins it gave go with it+        put(&d, "datasheets/U1.pins.json", json!({"part": "U1", "available": false, "reason": "the marking fits two packages"}));+        apply(&d).unwrap();+        assert!(items::read_json(&d.join("parts.json")).unwrap()["parts"][0]["pins"].is_null());+        std::fs::remove_dir_all(d.parent().unwrap()).ok();+    }++    #[test]+    fn a_conductor_takes_its_name_from_the_pin_that_matters_most_and_the_join_stays_the_coppers() {+        let d = dir("names");+        put(&d, "parts.json", parts());+        put(&d, "datasheets/U1.pins.json", sheet());+        let names = Names::load(&d);+        let set = |v: &[&str]| v.iter().map(|s| s.to_string()).collect::<BTreeSet<String>>();+        // an output and a passive pin on one conductor: the output names it; ground wins over everything; a part with no sheet names nothing+        assert_eq!(names.name_for(&set(&["U1.4", "U1.3", "C9.1"])).as_deref(), Some("U1_SW"));+        assert_eq!(names.name_for(&set(&["U1.1", "U1.2"])).as_deref(), Some("GND"));+        assert_eq!(names.name_for(&set(&["U1.5"])), None);+        assert_eq!(names.name_for(&set(&["C9.1", "R1.2"])), None);+        assert!(!names.key().is_empty());+        // after attribution: the net made from copper is tagged by its name's source, a net somebody stated is left as stated+        put(&d, "nets.json", json!({"nets": [+            {"name": "U1_SW", "fromCopper": true, "tag": "MEASURED", "pins": ["L1.1", "U1.3"], "why": "no name in the pin map: pins joined by copper", "tests": ["T-pour-nets"]},+            {"name": "U1_VIN", "fromCopper": true, "tag": "INFERRED", "pins": ["U1.1"], "why": "no name in the pin map: pins joined by copper", "tests": ["T-pour-nets"]},+            {"name": "VOUT", "tag": "INFERRED", "pins": ["U1.4", "C3.1"], "why": "typed", "tests": ["T-net-VOUT"]},+            {"name": "CU_R1_1", "fromCopper": true, "tag": "MEASURED", "pins": ["R1.1", "R2.1"]}]}));+        put(&d, "net-evidence.json", json!({"nets": [{"net": "U1_SW", "tag": "MEASURED", "nameSource": "copper"}, {"net": "U1_VIN", "tag": "INFERRED", "nameSource": "copper"}]}));+        let s = stamp(&d).unwrap();+        assert_eq!(s["netsNamedFromDatasheet"], json!(["U1_SW", "U1_VIN"]));+        let n = items::read_json(&d.join("nets.json")).unwrap();+        assert_eq!(n["nets"][0]["tag"], "MEASURED"); assert_eq!(n["nets"][0]["nameTag"], "DATASHEET");+        assert_eq!(n["nets"][0]["nameSource"], "XY1234 regulator, DS-1, revision C, page 4");+        assert_eq!(n["nets"][0]["datasheetPins"], json!([{"pin": "U1.3", "name": "SW", "type": "output"}]));+        assert_eq!(n["nets"][1]["tag"], "DATASHEET"); assert_eq!(n["nets"][1]["source"], "XY1234 regulator, DS-1, revision C, page 4");+        assert_eq!(n["nets"][2]["tag"], "INFERRED"); assert_eq!(n["nets"][2]["why"], "typed"); assert!(n["nets"][2]["nameTag"].is_null());+        assert_eq!(n["nets"][2]["datasheetPins"][0]["name"], "BST");+        assert!(n["nets"][3]["datasheetPins"].is_null());+        let e = items::read_json(&d.join("net-evidence.json")).unwrap();+        assert_eq!(e["nets"][0]["nameTag"], "DATASHEET"); assert_eq!(e["nets"][1]["tag"], "DATASHEET");+        // the sheet withdrawn and attribution run again under the old name: the net is no longer called documented+        std::fs::remove_file(d.join("datasheets/U1.pins.json")).unwrap();+        stamp(&d).unwrap();+        let n = items::read_json(&d.join("nets.json")).unwrap();+        assert!(n["nets"][1]["nameTag"].is_null() && n["nets"][1]["tag"] == "INFERRED" && n["nets"][0]["tag"] == "MEASURED");+        std::fs::remove_dir_all(d.parent().unwrap()).ok();+    }+}
src/edgelevels.rsadded+166
@@ -0,0 +1,166 @@+//! Copper and laminate levels read from the photo's own step edges, and a test of any pair of levels against those edges.+//!+//! The two-class fit in `trace.rs` splits the brightness of plain mask in two. On a face that is nearly all laminate with thin tracks on it, under a light that also+//! casts shadows, the split lands between shadow and laminate, the laminate takes the copper level, and every track, pad and pour joins through it into one island+//! (USB hub test board, top face, 2026-11-10: one island held 192 of 246 joined pins).+//!+//! A copper edge is a sharp step, whatever share of the face each class has. The step that occurs most often among a face's sharp edges is its copper over laminate+//! step, and each such edge gives one sample of the copper level (its bright side) and one of the laminate level (its dark side) at that place. Two uses:+//!+//! - `agreement` says how many of those edges have both sides on one side of the class threshold of a given pair of levels. Levels that put most copper edges wholly+//!   in one class are wrong, and the photo itself says so.+//! - `levels` interpolates the edge samples into the two level fields, which then follow the light because the samples do.+use crate::raster::{blur, Mask, Plane};++/// How far each side of an edge is sampled, frame pixels at 40 px/mm: from 2 px, clear of the edge's own blur, to 7 px, 0.175 mm, which stays on a track of the+/// narrowest width the frame resolves. Not fitted to any photo.+const NEAR: i64 = 2;+const FAR: i64 = 7;+/// A copper edge is sharp: the brightness change across `SHARP_PX` pixels at the crest, at the crest's slope, is at least `SHARP_SHARE` of the whole step. An edge the lens+/// and a 1.4 px smoothing have blurred to sigma 2 px gives 0.8; the penumbra of a part's shadow, a ramp 15 px wide on the hub board's top beside its connectors, gives 0.27.+const SHARP_PX: f32 = 4.0;+const SHARP_SHARE: f32 = 0.5;+/// The shortest run of crest pixels that counts as an edge: 0.5 mm at 40 px/mm. Specks on the hub board's top give runs of 3 to 10.+const MIN_RUN: u32 = 20;+/// Edges are the pixels in the top tenth of the gradient of the seen face. Not fitted: a face with fewer sharp edges than that only adds weak ones, whose small+/// steps fall below the modal band.+const EDGE_SHARE: f64 = 0.90;+/// A step counts as a copper edge within 35 percent of the modal step. On the hub board's top the histogram's peak at 0.37 has fallen to half by 0.30 and 0.45;+/// shadows, pits and glare make the wide tails outside that.+const BAND: f32 = 0.35;+/// Below this modal step (6 percent in brightness) the face shows no copper edges that stand clear of grain, and nothing here applies.+const MIN_STEP: f32 = 0.06;+/// Fewer edge samples than this in a window and the next wider window is used.+const MIN_SAMPLES: f32 = 60.0;++pub struct StepEdges {+    pub w: usize, pub h: usize,+    /// position, bright side and dark side in the log measure, for every edge whose step lies in the modal band+    pub at: Vec<(u32, u32, f32, f32)>,+    /// the step that occurs most often, in the log measure+    pub modal: f32,+    /// all edges measured, in the band or not+    pub measured: usize,+}++/// How a pair of levels sits against the face's copper edges: shares of the in-band edges.+#[derive(Clone, Copy, Debug, Default)]+pub struct Agreement { pub edges: usize, pub straddle: f32, pub both_copper: f32, pub both_laminate: f32, pub modal_step: f32 }++impl Agreement {+    /// More than half the face's copper edges wholly inside one class: the levels did not come from the copper. A majority, not a fitted line. The larger of the two shares reads 0.19 and 0.29 on the+    /// Driver top and bottom and 0.20 on the CM5 bottom, where the fit holds, and 0.77 on the hub board's top and 0.58 on the CM5 top, where it does not (`TRACE_DEBUG` prints them).+    pub fn levels_are_wrong(&self) -> bool { self.edges >= 500 && self.both_copper.max(self.both_laminate) > 0.5 }+}++/// Every sharp edge of `measure` (linear brightness in the mask's channel) clear of `unseen`, with the level on each side. None when the face shows no usable steps.+pub fn step_edges(measure: &Plane, unseen: &Mask) -> Option<StepEdges> {+    let (w, h) = (measure.w, measure.h);+    if w < 40 || h < 40 { return None; }+    let l = { let b = blur(measure, 1, 3); Plane { w, h, d: b.d.iter().map(|v| v.max(1.0).ln()).collect() } };+    let off = unseen.dilate(4.0);+    let (mut gx, mut gy, mut mag) = (Plane::new(w, h, 0.0), Plane::new(w, h, 0.0), Plane::new(w, h, 0.0));+    for y in 1..h - 1 { for x in 1..w - 1 { let i = y * w + x; let (a, b) = ((l.d[i + 1] - l.d[i - 1]) / 2.0, (l.d[i + w] - l.d[i - w]) / 2.0); gx.d[i] = a; gy.d[i] = b; mag.d[i] = (a * a + b * b).sqrt(); } }+    let mut seen: Vec<f32> = (0..w * h).step_by(7).filter(|i| !off.d[*i]).map(|i| mag.d[i]).collect();+    if seen.len() < 2000 { return None; }+    let k = ((seen.len() - 1) as f64 * EDGE_SHARE) as usize;+    let bar = *seen.select_nth_unstable_by(k, |a, b| a.partial_cmp(b).unwrap()).1;+    let m = FAR as usize + 2;+    let mut all: Vec<(u32, u32, f32, f32)> = vec![];+    for y in m..h - m { for x in m..w - m { let i = y * w + x; let g = mag.d[i]; if g <= bar || off.d[i] { continue; }+        let (ux, uy) = ((gx.d[i] / g) as f64, (gy.d[i] / g) as f64);+        let px = |k: f64| -> usize { ((y as f64 + uy * k).round() as usize) * w + (x as f64 + ux * k).round() as usize };+        // the crest of the edge only: a pixel beside the crest measures the same step again, off centre+        if g < mag.d[px(1.0)] || g < mag.d[px(-1.0)] { continue; }+        if (-FAR..=FAR).any(|k| off.d[px(k as f64)]) { continue; }+        let mut up: Vec<f32> = (NEAR..=FAR).map(|k| l.d[px(k as f64)]).collect(); let mut down: Vec<f32> = (NEAR..=FAR).map(|k| l.d[px(-(k as f64))]).collect();+        up.sort_by(|a, b| a.partial_cmp(b).unwrap()); down.sort_by(|a, b| a.partial_cmp(b).unwrap());+        // the upper quartile of the bright side and the lower of the dark: a track or a lane narrower than the reach has the other class at its far end+        let (hi, lo) = (up[up.len() * 3 / 4], down[down.len() / 4]);+        // a cast shadow's edge is a ramp: the copper edge's step is nearly all there within `SHARP_PX` of the crest+        if g * SHARP_PX < SHARP_SHARE * (hi - lo) { continue; }+        all.push((x as u32, y as u32, hi, lo));+    } }+    // A copper edge runs on; a bright speck of grain or dust gives a few crest pixels in a ring. Crests are joined through their neighbours and short runs dropped.+    let all: Vec<(u32, u32, f32, f32)> = {+        let mut crest = Mask::new(w, h); for e in &all { crest.set(e.0 as usize, e.1 as usize, true); }+        let (lab, cs) = crate::raster::components(&crest.dilate(1.0));+        let mut n = vec![0u32; cs.len() + 1]; for e in &all { n[lab[e.1 as usize * w + e.0 as usize] as usize] += 1; }+        all.into_iter().filter(|e| n[lab[e.1 as usize * w + e.0 as usize] as usize] >= MIN_RUN).collect()+    };+    if all.len() < 500 { return None; }+    let mut hist = [0f32; 40];+    for e in &all { let s = e.2 - e.3; if s > 0.0 && s < 1.0 { hist[(s / 0.025) as usize] += 1.0; } }+    let sm: Vec<f32> = (0..40).map(|i| { let (a, b) = (i.max(1) - 1, (i + 2).min(40)); hist[a..b].iter().sum::<f32>() }).collect();+    let peak = (0..40).max_by(|a, b| sm[*a].partial_cmp(&sm[*b]).unwrap())?;+    let modal = (peak as f32 + 0.5) * 0.025;+    // on a stitched face grain can outnumber copper edges: then the commonest step among edges that go on in line is the copper's (`locallevels::without_grain`)+    let (all, modal, _) = crate::locallevels::without_grain(all, modal, &gx, &gy);+    if modal < MIN_STEP { return None; }+    let measured = all.len();+    let at: Vec<_> = all.into_iter().filter(|e| { let s = e.2 - e.3; s > (1.0 - BAND) * modal && s < (1.0 + BAND) * modal }).collect();+    Some(StepEdges { w, h, at, modal, measured })+}++/// Where the face's copper edges fall against the class threshold `at` (a share of the way from `lam` to `cop`).+pub fn agreement(e: &StepEdges, cop: &Plane, lam: &Plane, at: f32) -> Agreement {+    let (mut st, mut up, mut dn) = (0usize, 0usize, 0usize);+    for (x, y, hi, lo) in &e.at { let i = *y as usize * e.w + *x as usize; let (c, l) = (cop.d[i], lam.d[i]); let d = (c - l).max(0.02);+        let (vh, vl) = ((hi - l) / d, (lo - l) / d);+        if vl >= at { up += 1; } else if vh <= at { dn += 1; } else { st += 1; } }+    let n = e.at.len().max(1) as f32;+    Agreement { edges: e.at.len(), straddle: st as f32 / n, both_copper: up as f32 / n, both_laminate: dn as f32 / n, modal_step: e.modal }+}++/// The copper and laminate levels over the face, from the edge samples: a mean over the nearest window that holds enough of them (12, 6 and 3 mm half width at+/// 40 px/mm, the finest that has the samples), the face's medians where none does.+pub fn levels(e: &StepEdges) -> (Plane, Plane) {+    let (w, h) = (e.w, e.h);+    let (mut sh, mut sl, mut sc) = (Plane::new(w, h, 0.0), Plane::new(w, h, 0.0), Plane::new(w, h, 0.0));+    for (x, y, hi, lo) in &e.at { let i = *y as usize * w + *x as usize; sh.d[i] += hi; sl.d[i] += lo; sc.d[i] += 1.0; }+    let med = |k: usize| -> f32 { let mut v: Vec<f32> = e.at.iter().map(|s| if k == 0 { s.2 } else { s.3 }).collect(); if v.is_empty() { return 0.0; } let n = v.len() / 2; *v.select_nth_unstable_by(n, |a, b| a.partial_cmp(b).unwrap()).1 };+    let (mut cop, mut lam) = (Plane::new(w, h, med(0)), Plane::new(w, h, med(1)));+    for r in [480usize, 240, 120] {+        let (bh, bl, bc) = (blur(&sh, r, 2), blur(&sl, r, 2), blur(&sc, r, 2));+        let area = ((2 * r + 1) * (2 * r + 1)) as f32;+        for i in 0..w * h { if bc.d[i] * area >= MIN_SAMPLES { cop.d[i] = bh.d[i] / bc.d[i]; lam.d[i] = bl.d[i] / bc.d[i]; } }+    }+    // where one window hands over to the next the level must not step+    let (mut cop, mut lam) = (blur(&cop, 40, 2), blur(&lam, 40, 2));+    let floor = 0.5 * e.modal;+    for i in 0..w * h { if cop.d[i] - lam.d[i] < floor { let m = (cop.d[i] + lam.d[i]) / 2.0; cop.d[i] = m + floor / 2.0; lam.d[i] = m - floor / 2.0; } }+    (cop, lam)+}++#[cfg(test)]+mod tests {+    use super::*;+    /// Laminate at 100 with tracks at 145 every 60 px, under a light that falls to 60 percent across the face, with a little grain.+    fn face() -> (Plane, Mask) {+        let (w, h) = (900usize, 400usize);+        let mut p = Plane::new(w, h, 0.0);+        for y in 0..h { for x in 0..w { let light = 1.0 - 0.4 * x as f32 / w as f32; let base = if y % 60 < 8 { 145.0 } else { 100.0 }; let n = ((x * 7919) ^ (y * 104729)) % 7; p.d[y * w + x] = base * light + n as f32; } }+        (p, Mask::new(w, h))+    }+    #[test]+    fn the_modal_step_is_copper_over_laminate() {+        let (p, m) = face();+        let e = step_edges(&p, &m).unwrap();+        assert!((e.modal - (145.0f32 / 100.0).ln()).abs() < 0.08, "modal step {}", e.modal);+        assert!(e.at.len() > 2000, "{}", e.at.len());+    }+    #[test]+    fn levels_that_put_the_laminate_in_the_copper_class_are_caught_and_the_edges_give_better_ones() {+        let (p, m) = face();+        let e = step_edges(&p, &m).unwrap();+        // what the two-class fit did on the hub board: the copper level on the laminate, the laminate level on something darker+        let (bad_c, bad_l) = (Plane::new(p.w, p.h, (80.0f32).ln()), Plane::new(p.w, p.h, (45.0f32).ln()));+        let a = agreement(&e, &bad_c, &bad_l, 0.5);+        assert!(a.levels_are_wrong() && a.both_copper > 0.5, "{a:?}");+        let (c, l) = levels(&e);+        let b = agreement(&e, &c, &l, 0.5);+        assert!(!b.levels_are_wrong() && b.straddle > 0.9, "{b:?}");+        // the levels follow the light: at both ends the track reads copper and the laminate does not+        for x in [100usize, 800] { let (i, j) = (2 * p.w + x, 30 * p.w + x); let v = |k: usize| ((p.d[k].ln()) - l.d[k]) / (c.d[k] - l.d[k]); assert!(v(i) > 0.5 && v(j) < 0.5, "x {x}: track {} laminate {}", v(i), v(j)); }+    }+}
src/ercfind.rsadded+151
@@ -0,0 +1,151 @@+//! An ERC error that comes from what the copper reading joined is a finding about that join, not a stop.+//!+//! `check schematic` runs KiCad's ERC on the sheets, and the sheets say what nets.json says. When the copper reader joins two+//! outputs (test board 6, 2026-11-11: QB and QD of a shift register on one island, an op-amp output on its own supply pin) the+//! ERC is right to object, and nothing whoever runs the flow may type clears it: the nets are measured, and nobody edits them by+//! hand. Such an error is recorded with the pins, the net, the copper that joins them and a test, `check schematic` lists it+//! and passes, and the connectivity score carries it. An ERC error of any other kind (a symbol, a wire, a label: the+//! generator's own work), or one on a net somebody stated rather than the copper, still fails the check.+use crate::{flow, items};+use serde_json::{json, Value};+use std::path::Path;++/// The ERC error types that speak about which pins share a net, and so can come from a join.+const ELECTRICAL: [&str; 3] = ["pin_to_pin", "pin_not_driven", "power_pin_not_driven"];++/// "Symbol U17 Pin 7 [OUT2, Output, Line]" -> "U17.7". Power flags and other `#` symbols are the generator's own.+fn pin_of(description: &str) -> Option<String> {+    let w: Vec<&str> = description.split_whitespace().collect();+    if w.len() >= 4 && w[0] == "Symbol" && w[2] == "Pin" { Some(format!("{}.{}", w[1], w[3])) } else { None }+}++/// The net a pin is on, when the copper put it there: the net is `fromCopper` and the pin is among the pins seen joined to it.+fn copper_net<'a>(nets: &'a Value, pin: &str) -> Option<(&'a Value, Vec<String>)> {+    let n = nets["nets"].as_array()?.iter().find(|n| n["pins"].as_array().into_iter().flatten().any(|p| p.as_str() == Some(pin)))?;+    // a net built from the pinouts carries the pins the copper joined in `copperGroups` too: a clash inside such a group is+    // the copper's join as much as on a net the copper alone made (test board 6: nine op-amp pins in one ground group)+    if n["fromCopper"] != json!(true) && n["copperGroups"].as_array().map_or(true, |g| g.is_empty()) { return None; }+    let group = n["copperGroups"].as_array().into_iter().flatten().find(|g| g.as_array().into_iter().flatten().any(|p| p.as_str() == Some(pin)))+        .map(|g| g.as_array().into_iter().flatten().filter_map(|p| p.as_str().map(str::to_string)).collect::<Vec<_>>());+    match group {+        Some(g) => Some((n, g)),+        // a run from before the groups were recorded: a measured net's pins are the ones the copper joined+        None if n["copperGroups"].is_null() && n["tag"] == json!("MEASURED") => Some((n, n["pins"].as_array().into_iter().flatten().filter_map(|p| p.as_str().map(str::to_string)).collect())),+        None => None,+    }+}++/// One ERC violation, judged: `Some(finding)` when it comes from pins the copper reading joined, `None` when it must still fail.+pub fn classify(viol: &Value, nets: &Value, copper: &Value) -> Option<Value> {+    let ty = viol["type"].as_str()?;+    if !ELECTRICAL.contains(&ty) { return None; }+    let all: Vec<String> = viol["items"].as_array().into_iter().flatten().filter_map(|i| i["description"].as_str()).filter_map(pin_of).collect();+    let pins: Vec<String> = all.iter().filter(|p| !p.starts_with('#')).cloned().collect();+    let flagged = pins.len() < all.len();+    if pins.is_empty() { return None; }+    let (net, group) = copper_net(nets, &pins[0])?;+    let name = net["name"].as_str().unwrap_or("");+    // every pin the error names sits on that one net, joined by the same copper+    if !pins.iter().all(|p| group.contains(p)) { return None; }+    // a clash needs a join: two pins of the error, or one pin and the rest of its copper+    if ty == "pin_to_pin" && pins.len() < 2 && group.len() < 2 { return None; }+    let pieces: Vec<String> = ["zones", "traces"].iter().flat_map(|k| copper[*k].as_array().into_iter().flatten()).filter(|c| c["net"].as_str() == Some(name)).filter_map(|c| c["id"].as_str().map(str::to_string)).collect();+    let said = viol["description"].as_str().unwrap_or("");+    let finding = match ty {+        "pin_to_pin" if pins.len() >= 2 => format!("{} are joined by the copper as read (net {name}), and KiCad's rules object: {said}. Either the reader joined two conductors, or a pin is named or turned wrongly", pins.join(" and ")),+        "pin_to_pin" => format!("{} sits on net {name}, which the copper as read joins to {}, and KiCad's rules object: {said}{}. Either the reader joined two conductors, or a pin is named or turned wrongly", pins[0], group.iter().filter(|p| **p != pins[0]).take(4).cloned().collect::<Vec<_>>().join(", "), if flagged { " (the power output is the flag the schematic puts on a rail)" } else { "" }),+        _ => format!("{} is an input that nothing drives on net {name} as the copper was read ({} pin(s) joined: {}): its driver is on copper the photos do not show joined, or the reader joined the wrong pins", pins[0], group.len(), group.iter().take(6).cloned().collect::<Vec<_>>().join(", ")),+    };+    let id = format!("E-{}", pins.join("-").replace('.', "_"));+    Some(json!({"id": id, "erc": ty, "ercSays": said, "pins": pins, "net": name, "joinedBy": group, "copper": pieces.iter().take(8).collect::<Vec<_>>(), "copperPieces": pieces.len(), "finding": finding, "test": format!("T-erc-{}", pins.join("-").replace('.', "_"))}))+}++/// What `check schematic` recorded, while that check stands.+pub fn recorded(flow_dir: &Path) -> Vec<Value> {+    if !flow::exists(flow_dir, "checks/schematic.ok") { return vec![]; }+    items::read_json(&flow_dir.join("checks/schematic.ok")).ok().and_then(|v| v["summary"]["copperFindings"].as_array().cloned()).unwrap_or_default()+}++pub fn lines(findings: &[Value]) -> Vec<String> {+    findings.iter().map(|f| format!("FINDING, not passed ({}): {}. Copper: {}{}. Test {}", f["id"].as_str().unwrap_or(""), f["finding"].as_str().unwrap_or(""), f["copper"].as_array().into_iter().flatten().filter_map(|c| c.as_str()).collect::<Vec<_>>().join(", "), match f["copperPieces"].as_u64() { Some(n) if n > 8 => format!(" and {} more pieces", n - 8), _ => String::new() }, f["test"].as_str().unwrap_or(""))).collect()+}++/// A continuity test for each finding: it settles the net.+pub fn tests(flow_dir: &Path) -> Vec<Value> {+    recorded(flow_dir).iter().map(|f| {+        let pins: Vec<&str> = f["pins"].as_array().into_iter().flatten().filter_map(|p| p.as_str()).collect();+        let others: Vec<&str> = f["joinedBy"].as_array().into_iter().flatten().filter_map(|p| p.as_str()).filter(|p| !pins.contains(p)).take(4).collect();+        let method = if pins.len() >= 2 { format!("Continuity between {}, board unpowered", pins.join(" and ")) } else { format!("Continuity from {} to {}, board unpowered", pins.first().copied().unwrap_or(""), if others.is_empty() { "the other pins of its net".to_string() } else { others.join(", ") }) };+        json!({"id": f["test"], "settles": [format!("net:{}", f["net"].as_str().unwrap_or(""))], "generated": true, "method": format!("{method}. {}", f["finding"].as_str().unwrap_or("")),+            "equipment": "DMM in continuity mode, fine probes", "expected": "no continuity where KiCad's rules object to the join: the copper reading joined two conductors", "ifNot": "the join is real: check the part's identity, its pin names and which way it is turned, then correct parts.json or the datasheet pins"})+    }).collect()+}++pub const CEILING: &str = "nets the electrical rules accept";++/// The connectivity ceiling: of the nets with two or more pins, the share no finding names.+pub fn ceiling(flow_dir: &Path, nets: &[Value]) -> Option<(f64, String)> {+    let f = recorded(flow_dir);+    if f.is_empty() { return None; }+    let multi = nets.iter().filter(|n| n["pins"].as_array().map(|p| p.len() >= 2).unwrap_or(false)).count().max(1);+    let mut bad: Vec<&str> = f.iter().filter_map(|x| x["net"].as_str()).collect(); bad.sort(); bad.dedup();+    let ok = multi.saturating_sub(bad.len());+    Some((100.0 * ok as f64 / multi as f64, format!("{} of {multi} nets with two or more pins carry an ERC finding from the copper as read ({}): {}", bad.len(), f.len(), bad.iter().take(6).cloned().collect::<Vec<_>>().join(", "))))+}++#[cfg(test)]+mod tests_ercfind {+    use super::*;++    fn viol(ty: &str, said: &str, items: &[&str]) -> Value { json!({"type": ty, "severity": "error", "description": said, "items": items.iter().map(|d| json!({"description": d})).collect::<Vec<_>>()}) }++    #[test]+    fn an_erc_error_from_pins_the_copper_joined_is_a_finding_and_any_other_still_fails() {+        let nets = json!({"nets": [+            {"name": "U3_QB", "fromCopper": true, "tag": "MEASURED", "pins": ["U3.1", "U3.3"], "copperGroups": [["U3.1", "U3.3"]]},+            {"name": "U18_VCC+", "fromCopper": true, "tag": "MEASURED", "pins": ["U18.7", "U18.8"], "copperGroups": [["U18.7", "U18.8"]]},+            {"name": "U17_IN2+", "fromCopper": true, "tag": "MEASURED", "pins": ["U17.5", "U17.6"], "copperGroups": [["U17.5", "U17.6"]]},+            {"name": "SDA", "tag": "INFERRED", "pins": ["U1.4", "U2.4"]},+            {"name": "MIX", "fromCopper": true, "tag": "MEASURED", "pins": ["U5.1", "U5.2", "U9.9"], "copperGroups": [["U5.1", "U5.2"]]}]});+        let copper = json!({"zones": [{"id": "isl-top-7", "net": "U3_QB"}], "traces": [{"id": "trk-top-9", "net": "U3_QB"}]});+        // board 6: two outputs of a shift register on one island+        let f = classify(&viol("pin_to_pin", "Pins of type Output and Output are connected", &["Symbol U3 Pin 1 [QB, Output, Line]", "Symbol U3 Pin 3 [QD, Output, Line]"]), &nets, &copper).unwrap();+        assert_eq!(f["pins"], json!(["U3.1", "U3.3"])); assert_eq!(f["net"], "U3_QB"); assert_eq!(f["copper"], json!(["isl-top-7", "trk-top-9"]));+        assert_eq!(f["test"], "T-erc-U3_1-U3_3");+        assert!(f["finding"].as_str().unwrap().contains("U3.1 and U3.3 are joined by the copper as read (net U3_QB)"));+        // an output on its own supply pin: the other end of the error is the rail's flag+        let f = classify(&viol("pin_to_pin", "Pins of type Output and Power output are connected", &["Symbol U18 Pin 7 [OUT2, Output, Line]", "Symbol #FLG0002 Pin 1 [pwr, Power output, Line]"]), &nets, &copper).unwrap();+        assert_eq!(f["pins"], json!(["U18.7"])); assert!(f["finding"].as_str().unwrap().contains("joins to U18.8"));+        // an input the copper as read leaves with no driver+        assert!(classify(&viol("pin_not_driven", "Input pin not driven by any Output pins", &["Symbol U17 Pin 5 [IN2+, Input, Line]"]), &nets, &copper).is_some());+        // the same clash on a net somebody stated: theirs to correct, so it still fails+        assert!(classify(&viol("pin_to_pin", "Pins of type Output and Output are connected", &["Symbol U1 Pin 4 [SDA, Output, Line]", "Symbol U2 Pin 4 [SDA, Output, Line]"]), &nets, &copper).is_none());+        // a pin that is on a copper net by statement, not by a seen join, with one that is+        assert!(classify(&viol("pin_to_pin", "Pins of type Output and Output are connected", &["Symbol U5 Pin 1 [A, Output, Line]", "Symbol U9 Pin 9 [B, Output, Line]"]), &nets, &copper).is_none());+        // the generator's own faults always fail+        for ty in ["wire_dangling", "label_dangling", "lib_symbol_issues", "duplicate_reference", "multiple_net_names"] { assert!(classify(&viol(ty, "x", &["Symbol U3 Pin 1 [QB, Output, Line]", "Symbol U3 Pin 3 [QD, Output, Line]"]), &nets, &copper).is_none(), "{ty}"); }+        // and an electrical error with only the generator's symbols in it+        assert!(classify(&viol("pin_to_pin", "x", &["Symbol #FLG01 Pin 1 [pwr, Power output, Line]", "Symbol #FLG02 Pin 1 [pwr, Power output, Line]"]), &nets, &copper).is_none());+    }++    #[test]+    fn a_recorded_finding_brings_a_test_and_a_ceiling_and_goes_stale_with_the_check() {+        let d = std::env::temp_dir().join(format!("ercfind-{}", std::process::id())).join("reverse-flow");+        let _ = std::fs::remove_dir_all(&d); std::fs::create_dir_all(d.join("checks")).unwrap();+        let nets = json!({"nets": [{"name": "U3_QB", "pins": ["U3.1", "U3.3"]}, {"name": "A", "pins": ["R1.1", "R2.1"]}, {"name": "B", "pins": ["R1.2"]}, {"name": "C", "pins": ["R3.1", "R4.1"]}, {"name": "D", "pins": ["R5.1", "R6.1"]}]});+        std::fs::write(d.join("nets.json"), nets.to_string()).unwrap();+        assert!(recorded(&d).is_empty() && tests(&d).is_empty() && ceiling(&d, nets["nets"].as_array().unwrap()).is_none());+        let f = json!({"id": "E-U3_1-U3_3", "erc": "pin_to_pin", "pins": ["U3.1", "U3.3"], "net": "U3_QB", "joinedBy": ["U3.1", "U3.3"], "copper": ["isl-top-7"], "copperPieces": 1, "finding": "U3.1 and U3.3 are joined", "test": "T-erc-U3_1-U3_3"});+        std::fs::write(d.join("checks/schematic.ok"), json!({"check": "schematic", "files": {"nets.json": crate::sha256(&d.join("nets.json"))}, "summary": {"copperFindings": [f]}}).to_string()).unwrap();+        let t = tests(&d);+        assert_eq!(t[0]["id"], "T-erc-U3_1-U3_3"); assert_eq!(t[0]["settles"], json!(["net:U3_QB"]));+        assert!(t[0]["method"].as_str().unwrap().starts_with("Continuity between U3.1 and U3.3"));+        let (v, why) = ceiling(&d, nets["nets"].as_array().unwrap()).unwrap();+        assert_eq!(v, 75.0); assert!(why.contains("1 of 4 nets"), "{why}");+        assert!(lines(&recorded(&d))[0].starts_with("FINDING, not passed (E-U3_1-U3_3)"));+        // the nets changed: the check is stale and its findings no longer stand+        std::fs::write(d.join("nets.json"), json!({"nets": []}).to_string()).unwrap();+        assert!(recorded(&d).is_empty());+        std::fs::remove_dir_all(d.parent().unwrap()).ok();+    }+}
src/faceread.rsadded+142
@@ -0,0 +1,142 @@+//! A face that cannot be read is said to be unread, with why, and nothing is drawn from it; and no two pads of one two-pad part are joined to one conductor.+//!+//! Until 2026-11-11 a face one island had swallowed was written out like any other: test board 1's bottom gave one "GND" net of 38 pins that shorted three supplies,+//! and `check copper` could only stop the run after the fact. The tests are outcomes of the trace, applied before anything is written: one island with over two thirds+//! of the face's joined pins, over 95 percent of the seen face read as copper, or most of the face's two-pad parts with both pads on one island. How the levels sit+//! against the face's edges is never a reason: it is recorded as a warning on the face and the reading is kept. Such a face's islands are taken out, the copper is measured again without them, and the accounting carries `unread` for that face.+//! A wrong net is worse than a missing one: the pins on that face then join nothing, and their nets have to come from the other face, a datasheet or the bench.+use crate::islands::{Face, Options, SeenDome};+use crate::kicad_out::Pad;+use serde_json::{json, Value};+use std::collections::{BTreeMap, HashMap, HashSet};++/// Levels taken from a face's own edges that under this share of those edges straddle are worth a warning on the face, and no more. Test board 4's top holds 0.56 and did come+/// out with its gaps lost, but board 9's bottom holds 0.59 and board 5's top 0.66 and both read well (board 9's bottom agrees with by-eye labels at 24 of 25 places), so the+/// figure does not tell a face that reads from one that does not. It was a reason to give a face up for one build, 2026-11-11, and emptied two faces that read.+const STRADDLE_WARN: f32 = 2.0 / 3.0;+/// Two-pad parts with both pads on one island: from this many on a face, and over half of the face's two-pad parts whose pads both lie on copper, the face has lost its gaps+/// wholesale. A majority, not a fitted line. The hub board's top lost 18 pairs at its worst and read; the count and the share of each face are in `padPairsSeen`.+const PAIRS_MIN: usize = 10;++/// What is worth a look on each face without giving it up: side and sentence.+pub fn warnings(faces: &[Face]) -> Vec<(String, String)> {+    let mut out = vec![];+    for f in faces { let st = &f.t.steps;+        if let Some(a) = &st.edge_check { if st.levels_from_edges && a.edges >= 500 && a.straddle < STRADDLE_WARN && !a.levels_are_wrong() {+            out.push((f.side.to_string(), format!("only {:.0} percent of the photo's copper-sized step edges straddle the copper and laminate levels taken from those same edges (most faces hold 80 percent or more). The reading is kept: look at evidence/copper-islands-{}.jpg for pours whose gaps were lost before trusting its nets.", a.straddle * 100.0, f.side))); } } }+    out+}++/// Why each face that cannot be read cannot, from what `islands::measure` made of it. Only outcomes count: a face one island has swallowed, a face read as all copper, a face+/// whose two-pad parts are shorted wholesale. How the levels sit against the edges is a warning (`warnings`), never a reason here.+pub fn unreadable(m: &Value, faces: &[Face]) -> Vec<(String, String)> {+    let mut out = vec![];+    for f in faces {+        let mut per: BTreeMap<u64, usize> = BTreeMap::new();+        for j in m["joins"].as_array().into_iter().flatten().filter(|j| j["side"].as_str() == Some(f.side)) { if let Some(i) = j["island"].as_u64() { *per.entry(i).or_default() += 1; } }+        let total: usize = per.values().sum();+        let st = &f.t.steps;+        if let Some((isl, n)) = per.iter().max_by_key(|x| *x.1) { if total >= crate::copperchecks::MIN_PINS && *n as f64 > crate::copperchecks::SWALLOW_SHARE * total as f64 {+            out.push((f.side.to_string(), format!("island {isl} held {n} of the {total} pins joined on this face ({:.0} percent); no one conductor holds two thirds of a face's pins, so laminate was read as copper or a gap was lost", *n as f64 / total as f64 * 100.0))); continue; } }+        if st.copper_share as f64 > crate::copperchecks::MAX_COPPER_SHARE { out.push((f.side.to_string(), format!("{:.0} percent of what can be seen read as copper; no board is that full, so the laminate level was not found", st.copper_share * 100.0))); continue; }+        let refused = m["padPairsRefused"].as_array().into_iter().flatten().filter(|r| r["side"].as_str() == Some(f.side)).count();+        let seen = m["padPairsSeen"].as_array().into_iter().flatten().find(|r| r["side"].as_str() == Some(f.side)).and_then(|r| r["parts"].as_u64()).unwrap_or(0) as usize;+        if refused >= PAIRS_MIN && refused * 2 > seen { out.push((f.side.to_string(), format!("{refused} of the {seen} two-pad parts whose pads both lie on copper had both pads on one island; a face that shorts most of its resistors and capacitors has lost its gaps"))); }+    }+    out+}++/// `islands::measure`, and again without the faces that cannot be read. The second pass sees those faces as showing no copper at all.+pub fn measure(faces: &mut [Face], pads: &[Pad], domes: &[SeenDome], pin_net: &HashMap<String, String>, opt: &Options) -> Value {+    let m = crate::islands::measure(faces, pads, domes, pin_net, opt);+    if std::env::var("TRACE_KEEP_UNREAD").is_ok() { return m; }+    let bad = unreadable(&m, faces);+    let warn = warnings(faces);+    let with_warnings = |mut m: Value| -> Value {+        if let Some(rows) = m["accounting"]["perFace"].as_array_mut() { for r in rows.iter_mut() { if let Some(b) = warn.iter().find(|b| Some(b.0.as_str()) == r["side"].as_str()) { r["warning"] = json!(b.1); } } }+        m["warnings"] = json!(warn.iter().filter(|b| !bad.iter().any(|x| x.0 == b.0)).map(|b| json!({"side": b.0, "what": b.1})).collect::<Vec<_>>()); m };+    if bad.is_empty() { return with_warnings(m); }+    for f in faces.iter_mut() { if bad.iter().any(|b| b.0 == f.side) { for l in f.t.labels.iter_mut() { *l = 0; } f.t.regions.clear(); } }+    let mut m2 = crate::islands::measure(faces, pads, domes, pin_net, opt);+    if let Some(rows) = m2["accounting"]["perFace"].as_array_mut() { for r in rows.iter_mut() { if let Some(b) = bad.iter().find(|b| Some(b.0.as_str()) == r["side"].as_str()) { r["unread"] = json!(b.1); } } }+    m2["unread"] = json!(bad.iter().map(|b| json!({"side": b.0, "why": b.1})).collect::<Vec<_>>());+    with_warnings(m2)+}++/// The (pad, face) joins to refuse: every pad of a two-pad part that shares an island with the part's other pad on that face, and what was refused, for the report.+/// `on(pad index, face index)` is the island the pad lies on. A part is two-pad when its pads carry exactly two different numbers.+/// Third: per face, how many two-pad parts have pads of both numbers on some island, the count a face's refusals are judged against.+pub fn pairs_on_one_island(pads: &[Pad], faces: usize, on: &dyn Fn(usize, usize) -> Option<u32>) -> (HashSet<(usize, usize)>, Vec<Value>, Vec<usize>) {+    let mut by_part: BTreeMap<&str, Vec<usize>> = BTreeMap::new();+    for (i, p) in pads.iter().enumerate() { if !p.num.is_empty() { by_part.entry(p.part.as_str()).or_default().push(i); } }+    let (mut refuse, mut rows) = (HashSet::new(), vec![]); let mut seen = vec![0usize; faces];+    for (part, idx) in by_part {+        let nums: HashSet<&str> = idx.iter().map(|i| pads[*i].num.as_str()).collect();+        if nums.len() != 2 { continue; }+        for fi in 0..faces {+            let at: Vec<(usize, u32)> = idx.iter().filter_map(|i| on(*i, fi).map(|l| (*i, l))).collect();+            if at.iter().map(|a| pads[a.0].num.as_str()).collect::<HashSet<_>>().len() == 2 { seen[fi] += 1; }+            let shared: HashSet<u32> = at.iter().filter(|a| at.iter().any(|b| b.1 == a.1 && pads[b.0].num != pads[a.0].num)).map(|a| a.1).collect();+            if shared.is_empty() { continue; }+            for (i, l) in &at { if shared.contains(l) { refuse.insert((*i, fi)); } }+            let mut isl: Vec<u32> = shared.into_iter().collect(); isl.sort();+            rows.push(json!({"part": part, "face": fi, "islands": isl}));+        }+    }+    (refuse, rows, seen)+}++/// The (pad, face) joins to refuse because one island holds over two thirds of the pins of a part with 16 or more: `copperchecks` fails a run on such an island ("several+/// conductors read as one"), and the wrong net it makes is worse than the missing ones. None of that part's pads is joined to that island; the rest of the face stands.+pub fn big_parts_on_one_island(pads: &[Pad], faces: usize, on: &dyn Fn(usize, usize) -> Option<u32>) -> (HashSet<(usize, usize)>, Vec<Value>) {+    let mut by_part: BTreeMap<&str, Vec<usize>> = BTreeMap::new();+    for (i, p) in pads.iter().enumerate() { if !p.num.is_empty() { by_part.entry(p.part.as_str()).or_default().push(i); } }+    let (mut refuse, mut rows) = (HashSet::new(), vec![]);+    for (part, idx) in by_part {+        let all = idx.iter().map(|i| pads[*i].num.as_str()).collect::<HashSet<_>>().len();+        if all < crate::copperchecks::BIG_PART { continue; }+        for fi in 0..faces {+            let mut per: BTreeMap<u32, HashSet<&str>> = BTreeMap::new();+            for i in &idx { if let Some(l) = on(*i, fi) { per.entry(l).or_default().insert(pads[*i].num.as_str()); } }+            for (l, nums) in per { if nums.len() as f64 > crate::copperchecks::PART_SHARE * all as f64 {+                for i in &idx { if on(*i, fi) == Some(l) { refuse.insert((*i, fi)); } }+                rows.push(json!({"part": part, "face": fi, "island": l, "pins": nums.len(), "of": all})); } }+        }+    }+    (refuse, rows)+}++#[cfg(test)]+mod tests {+    use super::*;+    fn pad(part: &str, num: &str) -> Pad { Pad { part: part.into(), num: num.into(), x: 0.0, y: 0.0, w: 1.0, h: 1.0, rot: 0.0, round: false, drill: 0.0, layers: vec!["F.Cu".into()], net: String::new() } }+    #[test]+    fn both_ends_of_a_two_pad_part_on_one_island_join_nothing() {+        // D1 has both pads on island 7; R1 has its pads on 7 and 9; U1, with three pads, has two of them on 7+        let pads = vec![pad("D1", "1"), pad("D1", "2"), pad("R1", "1"), pad("R1", "2"), pad("U1", "1"), pad("U1", "2"), pad("U1", "3")];+        let on = |i: usize, _f: usize| -> Option<u32> { [Some(7), Some(7), Some(7), Some(9), Some(7), Some(7), None][i] };+        let (refuse, rows, seen) = pairs_on_one_island(&pads, 1, &on);+        assert_eq!(seen, vec![2], "D1 and R1 have both pads on copper");+        assert!(refuse.contains(&(0, 0)) && refuse.contains(&(1, 0)), "{refuse:?}");+        assert_eq!(refuse.len(), 2, "a resistor across two islands and a chip keep their joins: {refuse:?}");+        assert_eq!(rows.len(), 1); assert_eq!(rows[0]["part"], json!("D1"));+    }+    #[test]+    fn most_pins_of_a_large_part_on_one_island_join_nothing_and_the_rest_stand() {+        let mut pads: Vec<Pad> = (1..=20).map(|k| pad("U1", &k.to_string())).collect(); pads.push(pad("R1", "1"));+        // 15 of U1's 20 pins on island 5, the others each on their own, and a resistor pad on island 5 too+        let on = |i: usize, _f: usize| -> Option<u32> { if i < 15 || i == 20 { Some(5) } else { Some(100 + i as u32) } };+        let (refuse, rows) = big_parts_on_one_island(&pads, 1, &on);+        assert_eq!(refuse.len(), 15, "{refuse:?}"); assert!(!refuse.contains(&(20, 0)) && !refuse.contains(&(16, 0)));+        assert_eq!(rows[0]["pins"], json!(15));+        let few = |i: usize, _f: usize| -> Option<u32> { if i < 12 { Some(5) } else { None } };+        assert!(big_parts_on_one_island(&pads, 1, &few).0.is_empty(), "12 of 20 is no more than two thirds");+    }+    #[test]+    fn a_part_with_two_pads_of_one_number_and_one_of_another_is_two_pad_and_only_the_clash_counts() {+        // a diode drawn with two pads numbered 2: both of those on one island is no short+        let pads = vec![pad("D2", "1"), pad("D2", "2"), pad("D2", "2")];+        let on = |i: usize, _f: usize| -> Option<u32> { [Some(3), Some(4), Some(4)][i] };+        assert!(pairs_on_one_island(&pads, 1, &on).0.is_empty());+    }+}
src/facereg.rsadded+649
@@ -0,0 +1,649 @@+//! Laying the bottom face on the top face through what goes through the board. A plated hole, a test point, a through-hole pad and its solder+//! joint sit at one place on both faces, whatever each photo's outline came out as. The old way assumed the two outlines coincide and looked+//! for holes that already agreed: with a bottom outline 2.3 mm too tall (MOSFET board, 2026-11-10) nothing agreed, either way round.+//! Here both faces give a point set, both ways of turning the board over are tried, and the bottom-to-top map is the similarity most point+//! pairs agree on (consensus over every pair of pairs, then least squares, then an affine fit when the pairs span the board).+//! Nothing here uses the outlines, so a bad outline cannot move the answer; the outlines are then compared through the map.+use crate::geom::{self, P};+use crate::raster;+use image::RgbImage;+use serde_json::{json, Value};++/// How close a bottom point must land to a top point to count, in photo pixels, before the scale is known: about 0.2 mm at the 65 to 77 px/mm of the+/// 2026-11-10 close-up sets. A solder joint's middle and its pad's middle on the other face differ by up to 0.15 mm on those photos.+pub const TOL_PX: f64 = 14.0;+/// Fewer matched points than this is not a registration: two are matched by construction and three or four more turn up by chance among a hundred points.+pub const MIN_MATCH: usize = 6;+/// The faces' outlines agree when each edge lies within this of the other face's, mm. A board's edge reads to about 0.2 mm; shadow errors seen were 0.9 and 2.3 mm.+pub const OUTLINE_TOL_MM: f64 = 0.5;++/// A pixel is mask, for finding pads, when its saturation is at least this share of the 10th percentile of the mask's own. Mask saturation at its 10th percentile+/// was 0.45 to 0.78 on the 2026-11-10 photos; pale tinned rings read 0.2 to 0.3.+pub const PAD_SAT_SHARE: f32 = 0.6;++fn f(v: &Value, k: &str) -> f64 { v[k].as_f64().unwrap_or(0.0) }++/// Round things that are not solder mask and stand alone in it: pad rings, test points, solder joints, via rings. A ring whose opening is full of solder+/// or holds a pin is not a "hole" to `prep::holes`, which found 12 of some 90 through features on the MOSFET board's bottom. Returns centre and diameter in the image's pixels.+pub fn pads(img: &RgbImage, min_d: f64, max_d: f64) -> Vec<Value> {+    let tone = crate::prep::mask_tone(img);+    // mask here is this photo's own: near its hue and at least `PAD_SAT_SHARE` of its own 10th-percentile saturation. A tinned ring under a green cast reads+    // as pale green (saturation 0.25 where the mask round it has 0.8), which the wide green test takes for mask.+    let near = |h: f32| (h - tone.hue).abs() < crate::outline::HUE_REACH;+    let step = ((img.width() as usize * img.height() as usize) / 400_000).max(1);+    let mut sats: Vec<f32> = img.pixels().step_by(step).filter_map(|p| { let (h, s, v) = raster::hsv(p); (near(h) && s > 0.18 && v > 0.07 && v < tone.v_hi).then_some(s) }).collect();+    sats.sort_by(|a, b| a.partial_cmp(b).unwrap());+    let s_floor = if sats.is_empty() { 0.18 } else { (PAD_SAT_SHARE * sats[(sats.len() - 1) / 10]).max(0.18) };+    let m = raster::mask_from(img, |p| { let (h, s, v) = raster::hsv(p); !(near(h) && s > s_floor && v > 0.07 && v < tone.v_hi) });+    let (_, cs) = raster::components(&m);+    let mut out = vec![];+    for c in &cs {+        let (bw, bh) = ((c.x1 - c.x0 + 1) as f64, (c.y1 - c.y0 + 1) as f64);+        if bw < min_d || bh < min_d || bw > max_d || bh > max_d || bw / bh > 1.3 || bh / bw > 1.3 { continue; }+        // touching the picture's edge it is not enclosed by mask+        if c.x0 == 0 || c.y0 == 0 || c.x1 + 1 >= m.w || c.y1 + 1 >= m.h { continue; }+        let fill = c.area as f64 / (std::f64::consts::FRAC_PI_4 * bw * bh);+        if !(0.72..1.12).contains(&fill) { continue; }+        out.push(json!({"x": (c.x0 + c.x1) as f64 / 2.0 + 0.5, "y": (c.y0 + c.y1) as f64 / 2.0 + 0.5, "d": (bw + bh) / 2.0}));+    }+    out+}++/// What one photo offers for registration, in the photo's own pixels: (x, y, diameter). Pads found on the whole photo, and the ringed holes `prep` found on the+/// straightened picture taken back through `to_photo`. The photo and not the straightened picture, because a bad outline shears the straightened one.+pub fn through(pads: &[Value], holes: &[Value], to_photo: &[f64; 9]) -> Vec<Value> {+    let mut out: Vec<(P, f64)> = vec![];+    for h in holes { let p = geom::apply(to_photo, (f(h, "x"), f(h, "y"))); if !out.iter().any(|q| geom::dist(q.0, p) < TOL_PX) { out.push((p, f(h, "outer"))); } }+    for h in pads { let p = (f(h, "x"), f(h, "y")); if !out.iter().any(|q| geom::dist(q.0, p) < TOL_PX) { out.push((p, f(h, "d"))); } }+    out.sort_by(|a, b| (a.0 .1, a.0 .0).partial_cmp(&(b.0 .1, b.0 .0)).unwrap());+    out.iter().map(|(p, d)| json!([r3(p.0), r3(p.1), r3(*d)])).collect()+}++/// A record's registration points and their diameters.+pub fn points(rec: &Value) -> (Vec<P>, Vec<f64>) {+    rec["through"].as_array().into_iter().flatten().map(|t| ((t[0].as_f64().unwrap_or(0.0), t[1].as_f64().unwrap_or(0.0)), t[2].as_f64().unwrap_or(0.0))).unzip()+}++/// The bottom photo's point as the top view would show it, before any fit: turned over left-right ("x") or top-bottom ("y"). Its own inverse.+pub fn mirror(axis: &str, p: P, wh: P) -> P { if axis == "y" { (p.0, wh.1 - p.1) } else { (wh.0 - p.0, p.1) } }++fn to9(m: &[f64; 6]) -> [f64; 9] { [m[0], m[1], m[2], m[3], m[4], m[5], 0.0, 0.0, 1.0] }+fn apply6(m: &[f64; 6], p: P) -> P { (m[0] * p.0 + m[1] * p.1 + m[2], m[3] * p.0 + m[4] * p.1 + m[5]) }+fn mul(a: &[f64; 9], b: &[f64; 9]) -> [f64; 9] { let mut o = [0.0; 9]; for i in 0..3 { for j in 0..3 { for k in 0..3 { o[i * 3 + j] += a[i * 3 + k] * b[k * 3 + j]; } } } o }++#[derive(Clone, Debug)]+pub struct Fit {+    pub axis: String,+    /// pairs within the tolerance, each point used once+    pub matched: usize,+    /// RMS and largest distance between the paired points after the fit, in the top photo's pixels+    pub rms: f64, pub max: f64,+    /// mirrored bottom photo pixels to top photo pixels, a homography (row-major)+    pub m: [f64; 9],+    pub kind: &'static str,+    /// (top point, mirrored bottom point)+    pub pairs: Vec<(P, P)>,+}+impl Fit {+    /// The scale and the turn at the middle of the matched points.+    pub fn scale_turn(&self) -> (f64, f64) {+        let n = self.pairs.len().max(1) as f64;+        let c = self.pairs.iter().fold((0.0, 0.0), |s, p| (s.0 + p.1 .0 / n, s.1 + p.1 .1 / n));+        let (o, x) = (geom::apply(&self.m, c), geom::apply(&self.m, (c.0 + 100.0, c.1)));+        (geom::dist(o, x) / 100.0, (x.1 - o.1).atan2(x.0 - o.0).to_degrees())+    }+}++/// Every k-th point, so that at most `n` are left, spread as the list is (it is sorted down the picture).+fn thin(p: &[P], n: usize) -> Vec<P> { if p.len() <= n { return p.to_vec(); } let step = (p.len() + n - 1) / n; p.iter().step_by(step).copied().collect() }++/// Where the top points are, as a grid of cells half the tolerance wide: a cell is set when a top point lies within the tolerance of it.+struct Near { w: usize, h: usize, cell: f64, x0: f64, y0: f64, d: Vec<bool> }+impl Near {+    fn new(a: &[P], tol: f64) -> Near {+        let cell = tol / 2.0;+        let (x0, y0) = (a.iter().map(|p| p.0).fold(f64::MAX, f64::min) - 2.0 * tol, a.iter().map(|p| p.1).fold(f64::MAX, f64::min) - 2.0 * tol);+        let (x1, y1) = (a.iter().map(|p| p.0).fold(f64::MIN, f64::max) + 2.0 * tol, a.iter().map(|p| p.1).fold(f64::MIN, f64::max) + 2.0 * tol);+        let (w, h) = (((x1 - x0) / cell).ceil() as usize + 1, ((y1 - y0) / cell).ceil() as usize + 1);+        let mut d = vec![false; w * h];+        for p in a { let (cx, cy) = (((p.0 - x0) / cell) as i64, ((p.1 - y0) / cell) as i64);+            for dy in -2i64..=2 { for dx in -2i64..=2 { let (u, v) = (cx + dx, cy + dy); if u >= 0 && v >= 0 && (u as usize) < w && (v as usize) < h { d[v as usize * w + u as usize] = true; } } } }+        Near { w, h, cell, x0, y0, d }+    }+    #[inline] fn has(&self, p: P) -> bool { let (u, v) = ((p.0 - self.x0) / self.cell, (p.1 - self.y0) / self.cell); u >= 0.0 && v >= 0.0 && (u as usize) < self.w && (v as usize) < self.h && self.d[v as usize * self.w + u as usize] }+}++/// Pairs under a map: each bottom point with its nearest top point within `within`, each top point kept for its closest bottom point only.+fn pair_up(a: &[P], b: &[P], m: &[f64; 9], within: f64) -> Vec<(P, P)> {+    let mut best: Vec<Option<(f64, usize)>> = vec![None; a.len()];+    for (j, q) in b.iter().enumerate() {+        let t = geom::apply(m, *q);+        let Some((i, d)) = a.iter().enumerate().map(|(i, p)| (i, geom::dist(*p, t))).min_by(|x, y| x.1.partial_cmp(&y.1).unwrap()) else { continue };+        if d < within && best[i].map_or(true, |o| d < o.0) { best[i] = Some((d, j)); }+    }+    best.iter().enumerate().filter_map(|(i, o)| o.map(|(_, j)| (a[i], b[j]))).collect()+}++/// Least-squares similarity (scale, turn, shift) taking each pair's second point to its first.+fn similarity(prs: &[(P, P)]) -> Option<[f64; 9]> {+    if prs.len() < 2 { return None; }+    let n = prs.len() as f64;+    let (ma, mb) = (prs.iter().fold((0.0, 0.0), |s, p| (s.0 + p.0 .0 / n, s.1 + p.0 .1 / n)), prs.iter().fold((0.0, 0.0), |s, p| (s.0 + p.1 .0 / n, s.1 + p.1 .1 / n)));+    let (mut dot, mut cross, mut bb) = (0.0, 0.0, 0.0);+    for (a, b) in prs { let (ax, ay, bx, by) = (a.0 - ma.0, a.1 - ma.1, b.0 - mb.0, b.1 - mb.1); dot += bx * ax + by * ay; cross += bx * ay - by * ax; bb += bx * bx + by * by; }+    if bb < 1e-9 { return None; }+    let (c, s) = (dot / bb, cross / bb);+    Some(to9(&[c, -s, ma.0 - (c * mb.0 - s * mb.1), s, c, ma.1 - (s * mb.0 + c * mb.1)]))+}++fn solve_n<const N: usize>(mut a: [[f64; N]; N], mut b: [f64; N]) -> Option<[f64; N]> {+    for c in 0..N {+        let p = (c..N).max_by(|i, j| a[*i][c].abs().partial_cmp(&a[*j][c].abs()).unwrap())?;+        if a[p][c].abs() < 1e-9 { return None; }+        a.swap(c, p); b.swap(c, p);+        for r in 0..N { if r != c { let k = a[r][c] / a[c][c]; for q in c..N { a[r][q] -= k * a[c][q]; } b[r] -= k * b[c]; } }+    }+    let mut x = [0.0; N]; for i in 0..N { x[i] = b[i] / a[i][i]; } Some(x)+}++/// Least-squares affine map, the same way round. None when the pairs do not span two directions.+fn affine(prs: &[(P, P)]) -> Option<[f64; 9]> {+    if prs.len() < 3 { return None; }+    let mut ata = [[0f64; 3]; 3]; let (mut atx, mut aty) = ([0f64; 3], [0f64; 3]);+    let n = prs.len() as f64;+    let mb = prs.iter().fold((0.0, 0.0), |s, p| (s.0 + p.1 .0 / n, s.1 + p.1 .1 / n));+    for (a, b) in prs { let r = [(b.0 - mb.0) / 1000.0, (b.1 - mb.1) / 1000.0, 1.0]; for i in 0..3 { for j in 0..3 { ata[i][j] += r[i] * r[j]; } atx[i] += r[i] * a.0; aty[i] += r[i] * a.1; } }+    let (x, y) = (solve_n(ata, atx)?, solve_n(ata, aty)?);+    let (x0, x1, y0, y1) = (x[0] / 1000.0, x[1] / 1000.0, y[0] / 1000.0, y[1] / 1000.0);+    Some(to9(&[x0, x1, x[2] - x0 * mb.0 - x1 * mb.1, y0, y1, y[2] - y0 * mb.0 - y1 * mb.1]))+}++/// Least-squares homography, the same way round, on coordinates centred and scaled so that the sums stay well conditioned.+fn homography(prs: &[(P, P)]) -> Option<[f64; 9]> {+    if prs.len() < 4 { return None; }+    let n = prs.len() as f64;+    let (ma, mb) = (prs.iter().fold((0.0, 0.0), |s, p| (s.0 + p.0 .0 / n, s.1 + p.0 .1 / n)), prs.iter().fold((0.0, 0.0), |s, p| (s.0 + p.1 .0 / n, s.1 + p.1 .1 / n)));+    let sc = prs.iter().map(|p| geom::dist(p.1, mb)).fold(1.0, f64::max);+    let mut ata = [[0f64; 8]; 8]; let mut atb = [0f64; 8];+    for (a, b) in prs {+        let (x, y, u, v) = ((b.0 - mb.0) / sc, (b.1 - mb.1) / sc, (a.0 - ma.0) / sc, (a.1 - ma.1) / sc);+        for (row, rhs) in [([x, y, 1.0, 0.0, 0.0, 0.0, -u * x, -u * y], u), ([0.0, 0.0, 0.0, x, y, 1.0, -v * x, -v * y], v)] {+            for i in 0..8 { for j in 0..8 { ata[i][j] += row[i] * row[j]; } atb[i] += row[i] * rhs; }+        }+    }+    let h = solve_n(ata, atb)?;+    let hn = [h[0], h[1], h[2], h[3], h[4], h[5], h[6], h[7], 1.0];+    let tb = [1.0 / sc, 0.0, -mb.0 / sc, 0.0, 1.0 / sc, -mb.1 / sc, 0.0, 0.0, 1.0];+    let ta_inv = [sc, 0.0, ma.0, 0.0, sc, ma.1, 0.0, 0.0, 1.0];+    let m = mul(&ta_inv, &mul(&hn, &tb));+    if m[8].abs() < 1e-12 { return None; }+    Some(m.map(|v| v / m[8]))+}++/// The map from the mirrored bottom points `b` to the top points `a` that most points agree on. Both photos show the board square to the picture, so the turn+/// between them is small (6 degrees allowed) and the scale between them is near one (0.75 to 1.33 allowed: the two faces need not be photographed from one height).+/// Consensus is counted at twice the tolerance, because two hand-held photos differ by a little perspective that a similarity cannot take up; the affine fit and the+/// homography after it can, and the count reported is at the tolerance.+pub fn consensus(a: &[P], b: &[P], tol: f64) -> Option<Fit> {+    if a.len() < 2 || b.len() < 2 { return None; }+    let near = Near::new(a, 2.0 * tol);+    let (ah, bh) = (thin(a, 60), thin(b, 220));+    let span = { let (x0, x1) = a.iter().fold((f64::MAX, f64::MIN), |s, p| (s.0.min(p.0), s.1.max(p.0))); let (y0, y1) = a.iter().fold((f64::MAX, f64::MIN), |s, p| (s.0.min(p.1), s.1.max(p.1))); (x1 - x0).max(y1 - y0) };+    let min_len = 0.2 * span;+    let sin_max = 6f64.to_radians().sin();+    let mut best: (usize, [f64; 6]) = (0, [1.0, 0.0, 0.0, 0.0, 1.0, 0.0]);+    for i in 0..ah.len() { for j in i + 1..ah.len() {+        let va = (ah[j].0 - ah[i].0, ah[j].1 - ah[i].1); let la = (va.0 * va.0 + va.1 * va.1).sqrt();+        if la < min_len { continue; }+        for k in 0..bh.len() { for l in 0..bh.len() {+            if k == l { continue; }+            let vb = (bh[l].0 - bh[k].0, bh[l].1 - bh[k].1); let lb2 = vb.0 * vb.0 + vb.1 * vb.1;+            let s2 = la * la / lb2.max(1e-9);+            if !(0.5625..1.7689).contains(&s2) { continue; }+            let lb = lb2.sqrt();+            let (co, si) = ((va.0 * vb.0 + va.1 * vb.1) / (la * lb), (vb.0 * va.1 - vb.1 * va.0) / (la * lb));+            if co <= 0.0 || si.abs() > sin_max { continue; }+            let s = la / lb; let (c, sn) = (s * co, s * si);+            let m = [c, -sn, ah[i].0 - (c * bh[k].0 - sn * bh[k].1), sn, c, ah[i].1 - (sn * bh[k].0 + c * bh[k].1)];+            let n = b.iter().filter(|q| near.has(apply6(&m, **q))).count();+            if n > best.0 { best = (n, m); }+        } }+    } }+    if best.0 < 2 { return None; }+    let mut m = to9(&best.1); let mut kind = "similarity";+    for reach in [2.0, 2.0, 1.5] { match similarity(&pair_up(a, b, &m, reach * tol)) { Some(n) => m = n, None => break } }+    let spans = |prs: &[(P, P)]| { let (x0, x1) = prs.iter().fold((f64::MAX, f64::MIN), |s, p| (s.0.min(p.0 .0), s.1.max(p.0 .0))); let (y0, y1) = prs.iter().fold((f64::MAX, f64::MIN), |s, p| (s.0.min(p.0 .1), s.1.max(p.0 .1))); (x1 - x0).min(y1 - y0) };+    // a richer map only when the pairs span the board both ways and it keeps at least as many+    let wide = pair_up(a, b, &m, 2.0 * tol);+    if wide.len() >= 6 && spans(&wide) > 0.3 * span {+        let mut ma = m; let mut ok = true;+        for reach in [2.0, 1.5, 1.0] { match affine(&pair_up(a, b, &ma, reach * tol)) { Some(n) => ma = n, None => { ok = false; break } } }+        if ok && pair_up(a, b, &ma, tol).len() >= pair_up(a, b, &m, tol).len() { m = ma; kind = "affine"; }+        let wide = pair_up(a, b, &m, 1.5 * tol);+        if kind == "affine" && wide.len() >= 12 && spans(&wide) > 0.4 * span {+            let mut mh = m; let mut ok = true;+            for reach in [1.5, 1.0, 1.0] { match homography(&pair_up(a, b, &mh, reach * tol)) { Some(n) => mh = n, None => { ok = false; break } } }+            if ok && pair_up(a, b, &mh, tol).len() >= pair_up(a, b, &m, tol).len() { m = mh; kind = "homography"; }+        }+    } else { for _ in 0..2 { if let Some(n) = similarity(&pair_up(a, b, &m, tol)) { m = n; } } }+    let prs = pair_up(a, b, &m, tol);+    let res: Vec<f64> = prs.iter().map(|(t, o)| geom::dist(*t, geom::apply(&m, *o))).collect();+    let rms = if res.is_empty() { f64::NAN } else { (res.iter().map(|x| x * x).sum::<f64>() / res.len() as f64).sqrt() };+    Some(Fit { axis: String::new(), matched: prs.len(), rms, max: res.iter().cloned().fold(0.0, f64::max), m, kind, pairs: prs })+}++/// The fit for one way of turning the board over. `bot_wh` is the bottom photo's size.+pub fn fit_axis(top: &[P], bot: &[P], bot_wh: P, axis: &str, tol: f64) -> Option<Fit> {+    let b: Vec<P> = bot.iter().map(|p| mirror(axis, *p, bot_wh)).collect();+    consensus(top, &b, tol).map(|mut x| { x.axis = axis.to_string(); x })+}++/// The few largest round openings of a face (mounting holes), for a board that shares little else: at least `BIG_SHARE` of the largest diameter, eight at most.+fn big(p: &[P], d: &[f64]) -> Vec<(P, f64)> {+    let top = d.iter().cloned().fold(0.0, f64::max);+    let mut v: Vec<(P, f64)> = p.iter().cloned().zip(d.iter().cloned()).filter(|x| x.1 >= BIG_SHARE * top).collect();+    v.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap()); v.truncate(10);+    v+}+/// Two openings are of one size when their diameters, the map's scale taken out, agree this closely (natural log of the ratio): 8 percent. A ring reads to a few+/// pixels in 340, and the test is what ties the fit's scale to something measured: a fit that scales one face by 1.2 fails it on every opening.+pub const SIZE_AGREE: f64 = 0.08;+/// Large openings paired under a map: within `tol`, and of one size once the map's scale is taken out (within a fifth), each used once.+fn big_pairs(la: &[(P, f64)], lb: &[(P, f64)], m: &[f64; 9], tol: f64) -> Vec<(P, P)> {+    let s = (m[0] * m[0] + m[3] * m[3]).sqrt();+    let mut used = vec![false; la.len()]; let mut out = vec![];+    for (q, dq) in lb { let t = geom::apply(m, *q);+        if let Some((i, _)) = la.iter().enumerate().filter(|(i, a)| !used[*i] && geom::dist(a.0, t) < tol && (a.1 / (dq * s)).ln().abs() < SIZE_AGREE).min_by(|x, y| geom::dist(x.1 .0, t).partial_cmp(&geom::dist(y.1 .0, t)).unwrap()) { used[i] = true; out.push((la[i].0, *q)); } }+    out+}+/// A feature counts as large from this share of the face's largest. Mounting holes are 2.5 to 4 times a pin pad across on the 2026-11-10 boards.+pub const BIG_SHARE: f64 = 0.6;+/// Large openings of one size matched, at least: two fix a similarity, the third must then land within the tolerance and be of that size too.+pub const MIN_BIG: usize = 3;++/// An edge of one face's own outline agrees with the other's when both ends of the other's, brought over, lie within this of its line, photo pixels: about 0.6 mm+/// at the 65 to 77 px/mm of the 2026-11-10 sets. A tight outline reads to about 0.2 mm; an edge hidden under parts is off by many millimetres.+pub const EDGE_TOL_PX: f64 = 40.0;+/// From this many matched points the outlines are not asked to confirm the fit: rows of holes at one pitch reached 10 the wrong way round on the MOSFET board.+pub const SURE_MATCH: usize = 12;++/// How many of the four edges of the top's own outline the bottom's own outline lies on, under a map.+fn edges_agree(tc: &[P; 4], bc: &[P; 4], m: &[f64; 9], axis: &str, bwh: P) -> usize {+    let pm = perm(axis); let mut bo = [(0.0, 0.0); 4]; for i in 0..4 { bo[pm[i]] = geom::apply(m, mirror(axis, bc[i], bwh)); }+    let off = |p: P, a: P, b: P| ((b.0 - a.0) * (p.1 - a.1) - (b.1 - a.1) * (p.0 - a.0)).abs() / geom::dist(a, b).max(1e-9);+    (0..4).filter(|i| { let j = (i + 1) % 4; off(bo[*i], tc[*i], tc[j]) < EDGE_TOL_PX && off(bo[j], tc[*i], tc[j]) < EDGE_TOL_PX }).count()+}++/// Every similarity that lays two large openings of one face on two of the other, far apart (0.4 of the photo), of one size, within the scale and turn two photos of+/// one board can differ by. Fits that put the photo's corners within four tolerances of each other count once.+fn two_hole_fits(la: &[(P, f64)], lb: &[(P, f64)], bwh: P) -> Vec<[f64; 9]> {+    let tol = 2.0 * TOL_PX; let mut out: Vec<[f64; 9]> = vec![];+    for i in 0..la.len() { for j in i + 1..la.len() { if geom::dist(la[i].0, la[j].0) <= 0.4 * bwh.0.max(bwh.1) { continue; } for k in 0..lb.len() { for l in 0..lb.len() {+        if k == l { continue; }+        let Some(m) = similarity(&[(la[i].0, lb[k].0), (la[j].0, lb[l].0)]) else { continue };+        let s2 = m[0] * m[0] + m[3] * m[3];+        if !(0.5625..1.7689).contains(&s2) || m[0] <= 0.0 || (m[3] / s2.sqrt()).abs() > 6f64.to_radians().sin() { continue; }+        if big_pairs(la, lb, &m, tol).len() < 2 { continue; }+        if !out.iter().any(|q| geom::dist(geom::apply(q, (0.0, 0.0)), geom::apply(&m, (0.0, 0.0))) < 4.0 * tol && geom::dist(geom::apply(q, bwh), geom::apply(&m, bwh)) < 4.0 * tol) { out.push(m); }+    } } } }+    out+}++/// The tiers for one axis. Every through feature first: `MIN_MATCH` of them with two outline edges agreeing, or `SURE_MATCH` on their own. Failing that the large+/// openings (mounting holes): every similarity two pairs of them give, judged by how many large openings and how many outline edges agree: four openings, or three+/// and two edges, or two and three edges. Returns the fit and whether it registers the faces.+pub fn register(top: &Value, bot: &Value, axis: &str) -> (Option<Fit>, bool) {+    let ((a, da), (b, db)) = (points(top), points(bot));+    let bwh = photo_wh(bot);+    let outl = own_corners(top).zip(own_corners(bot));+    let edges = |m: &[f64; 9]| outl.as_ref().map_or(0, |(t, o)| edges_agree(t, o, m, axis, bwh));+    let all = fit_axis(&a, &b, bwh, axis, TOL_PX);+    let la = big(&a, &da);+    let lb: Vec<(P, f64)> = big(&b, &db).into_iter().map(|(p, d)| (mirror(axis, p, bwh), d)).collect();+    let tol = 2.0 * TOL_PX;+    let bm: Vec<P> = b.iter().map(|p| mirror(axis, *p, bwh)).collect();+    // six to eleven matches want a second witness: two outline edges, or two large openings of one size that the same fit lays on each other+    // (a board with terminal blocks over its edges has no outline edge to offer: test board 6, 2026-11-11)+    if all.as_ref().map_or(false, |x| x.matched >= SURE_MATCH || (x.matched >= MIN_MATCH && (edges(&x.m) >= 2 || big_pairs(&la, &lb, &x.m, tol).len() >= 2))) { return (all, true); }+    let mut best: Option<(usize, usize, [f64; 9])> = None;+    let mut twos: Vec<[f64; 9]> = vec![];+    for i in 0..la.len() { for j in i + 1..la.len() { for k in 0..lb.len() { for l in 0..lb.len() {+        if k == l { continue; }+        let Some(m) = similarity(&[(la[i].0, lb[k].0), (la[j].0, lb[l].0)]) else { continue };+        let s2 = m[0] * m[0] + m[3] * m[3];+        if !(0.5625..1.7689).contains(&s2) || m[0] <= 0.0 || (m[3] / s2.sqrt()).abs() > 6f64.to_radians().sin() { continue; }+        // how many of all the through features the same fit lays on each other counts with the openings: rows of pins at one pitch can mislead a+        // consensus of small features alone, and two mounting holes alone prove nothing+        let (n, e) = (big_pairs(&la, &lb, &m, tol).len(), edges(&m) + pair_up(&a, &bm, &m, tol).len());+        if best.as_ref().map_or(true, |q| (n, e) > (q.0, q.1)) { best = Some((n, e, m)); }+        // two openings far apart: kept to see whether exactly one such fit exists+        if n == 2 && geom::dist(la[i].0, la[j].0) > 0.4 * bwh.0.max(bwh.1) && !twos.iter().any(|q: &[f64; 9]| geom::dist(geom::apply(q, (0.0, 0.0)), geom::apply(&m, (0.0, 0.0))) < 4.0 * tol && geom::dist(geom::apply(q, bwh), geom::apply(&m, bwh)) < 4.0 * tol) { twos.push(m); }+    } } } }+    // two mounting holes of one size, far apart, and no other pair of openings that fits another way: a similarity, weak, said as such+    let two_alone = best.as_ref().map_or(false, |q| q.0 == 2) && twos.len() == 1;+    if two_alone { best = best.map(|q| (q.0, q.1, twos[0])); }+    // three openings of one size at one triangle are two more agreements than the two that made the fit; two need the outlines to confirm+    if let Some((n, _, m0)) = best { if n >= 3 || (n >= 2 && (edges(&m0) >= 3 || two_alone)) {+        let prs = big_pairs(&la, &lb, &m0, tol);+        let mut m = similarity(&prs).unwrap_or(m0);+        // every other through feature that lands on one joins the fit+        let mut all_prs = prs.clone();+        // other through features join only where three or more openings already hold the fit+        if n >= 3 { for reach in [1.5, 1.0] { let more = pair_up(&a, &bm, &m, reach * TOL_PX); if more.len() > all_prs.len() { if let Some(n2) = similarity(&more) { m = n2; all_prs = more; } } } }+        let res: Vec<f64> = all_prs.iter().map(|(t, o)| geom::dist(*t, geom::apply(&m, *o))).collect();+        let rms = (res.iter().map(|x| x * x).sum::<f64>() / res.len().max(1) as f64).sqrt();+        let kind = if n >= 3 { "similarity through three or more large openings of one size" } else { "similarity through two large openings of one size" };+        return (Some(Fit { axis: axis.to_string(), matched: all_prs.len(), rms, max: res.iter().cloned().fold(0.0, f64::max), m, kind, pairs: all_prs }), true);+    } }+    (all, false)+}++fn photo_wh(rec: &Value) -> P { (rec["photoPx"][0].as_f64().unwrap_or(0.0), rec["photoPx"][1].as_f64().unwrap_or(0.0)) }+fn r3(x: f64) -> f64 { (x * 1000.0).round() / 1000.0 }+fn corners_of(rec: &Value, key: &str) -> Option<[P; 4]> { let c = rec[key].as_array()?; if c.len() != 4 { return None; } Some([0, 1, 2, 3].map(|i| (c[i][0].as_f64().unwrap_or(0.0), c[i][1].as_f64().unwrap_or(0.0)))) }+/// The outline fitted to this photo alone: kept as `cornersOwn` when the photo was straightened through the shared outline, as `cornersFitted` when `scale` has since moved the corners.+fn own_corners(rec: &Value) -> Option<[P; 4]> { corners_of(rec, "cornersOwn").or(corners_of(rec, "cornersFitted")).or(corners_of(rec, "corners")) }+/// Which corner of the top photo each corner of the bottom photo (TL, TR, BR, BL in its own view) is, for a flip: the order `board::frame_corners` lays the bottom by.+fn perm(axis: &str) -> [usize; 4] { if axis == "y" { [3, 2, 1, 0] } else { [1, 0, 3, 2] } }+/// The top photo's corners seen in the bottom photo.+fn to_bottom(top_c: &[P; 4], fit: &Fit, bwh: P) -> Option<[P; 4]> { let inv = geom::invert(&fit.m)?; let pm = perm(&fit.axis); Some([0, 1, 2, 3].map(|i| mirror(&fit.axis, geom::apply(&inv, top_c[pm[i]]), bwh))) }+/// The bottom photo's corners seen in the top photo, in the top's order.+fn to_top(bot_c: &[P; 4], fit: &Fit, bwh: P) -> [P; 4] { let pm = perm(&fit.axis); let mut o = [(0.0, 0.0); 4]; for i in 0..4 { o[pm[i]] = geom::apply(&fit.m, mirror(&fit.axis, bot_c[i], bwh)); } o }+fn quad_size(c: &[P; 4]) -> P { ((geom::dist(c[0], c[1]) + geom::dist(c[3], c[2])) / 2.0, (geom::dist(c[0], c[3]) + geom::dist(c[1], c[2])) / 2.0) }+fn pick(d: &Value, side: &str) -> Option<Value> { let name = d["primary"][side].as_str()?.to_string(); d["photos"].as_array()?.iter().find(|p| p["file"].as_str() == Some(name.as_str())).cloned() }++/// The top edge (corner e to e + 1, the top's order) that each edge of the bottom photo's outline is, for a flip.+fn top_edge(axis: &str, i: usize) -> usize { let pm = perm(axis); let (a, b) = (pm[i], pm[(i + 1) % 4]); if (a + 1) % 4 == b { a } else { b } }++/// The faces laid together through their own outlines, for a board that shares no through feature the pads test finds. An edge is the board's when no solder mask+/// lies beyond it (`outline::mask_beyond`). All four true on both faces: the four corners, a homography. One edge hidden on either face (parts over it): a similarity+/// through the opposite edge, which that edge and its two neighbours fix. More hidden: None.+pub fn outline_fit(top: &Value, bot: &Value, axis: &str) -> Option<Fit> {+    let (tc, bc) = (own_corners(top)?, own_corners(bot)?);+    let bwh = photo_wh(bot);+    let open = |rec: &Value, i: usize| rec["maskBeyondOwnEdges"][i].as_f64().map_or(true, |v| v > crate::outline::OPEN_EDGE_SHARE);+    let mut bad = [false; 4];+    for i in 0..4 { if open(top, i) { bad[i] = true; } if open(bot, i) { bad[top_edge(axis, i)] = true; } }+    let pm = perm(axis); let mut src = [(0.0, 0.0); 4]; for i in 0..4 { src[pm[i]] = mirror(axis, bc[i], bwh); }+    let pairs: Vec<(P, P)> = match bad.iter().filter(|b| **b).count() {+        0 => (0..4).map(|i| (tc[i], src[i])).collect(),+        1 => { let e = (bad.iter().position(|b| *b)? + 2) % 4; vec![(tc[e], src[e]), (tc[(e + 1) % 4], src[(e + 1) % 4])] }+        _ => return None };+    let (m, kind) = if pairs.len() == 4 { (homography(&pairs)?, "the outlines: all four edges of both faces are the board's") } else { (similarity(&pairs)?, "the outlines: a similarity through the edge opposite the one parts hide") };+    let fit = Fit { axis: axis.to_string(), matched: 0, rms: f64::NAN, max: f64::NAN, m, kind, pairs };+    // both faces show large openings (mounting holes): an outline fit that lays fewer than two of them on each other is wrong, whatever the outlines say+    let ((a, da), (b, db)) = (points(top), points(bot));+    let (la, lb) = (big(&a, &da), big(&b, &db).into_iter().map(|(p, d)| (mirror(axis, p, bwh), d)).collect::<Vec<_>>());+    let holes = two_hole_fits(&la, &lb, bwh);+    let same = |q: &[f64; 9]| geom::dist(geom::apply(q, (0.0, 0.0)), geom::apply(&fit.m, (0.0, 0.0))) < 8.0 * TOL_PX && geom::dist(geom::apply(q, bwh), geom::apply(&fit.m, bwh)) < 8.0 * TOL_PX;+    if !holes.is_empty() && !holes.iter().any(same) { return None; }+    let (s, turn) = fit.scale_turn();+    ((0.75..1.33).contains(&s) && turn.abs() < 6.0).then_some(fit)+}++/// The largest piece of a board mask with what it encloses.+fn solid(m: &raster::Mask) -> raster::Mask {+    let (lab, cs) = raster::components(m);+    let Some(big) = cs.iter().max_by_key(|c| c.area) else { return m.clone() };+    raster::Mask { w: m.w, h: m.h, d: lab.iter().map(|l| *l == big.label).collect() }.fill_holes()+}++/// How well the two faces' boards coincide under a fit: intersection over union of the solid masks (both at 1/`k` of their photos), on every third pixel.+fn overlap(mt: &raster::Mask, mb: &raster::Mask, fit: &Fit, bwh: P, k: f64) -> f64 {+    let Some(inv) = geom::invert(&fit.m) else { return 0.0 };+    let (mut i, mut u) = (0usize, 0usize);+    for y in (0..mt.h + mb.h).step_by(3) { for x in (0..mt.w + mb.w).step_by(3) {+        let t = x < mt.w && y < mt.h && mt.d[y * mt.w + x];+        let q = mirror(&fit.axis, geom::apply(&inv, ((x as f64 + 0.5) * k, (y as f64 + 0.5) * k)), bwh);+        let (a, b) = ((q.0 / k).floor() as i64, (q.1 / k).floor() as i64);+        let o = a >= 0 && b >= 0 && (a as usize) < mb.w && (b as usize) < mb.h && mb.d[b as usize * mb.w + a as usize];+        if t && o { i += 1; } if t || o { u += 1; }+    } }+    if u == 0 { 0.0 } else { i as f64 / u as f64 }+}+/// The boards of the two faces must coincide at least this well for the outlines alone to register them. A top face with a quarter hidden under terminal blocks+/// and a connector over one edge coincided 0.57 with its bare bottom (2026-11-10). A sanity gate only: which edge to trust is decided by the mask beyond each edge.+pub const OUTLINE_IOU: f64 = 0.5;++/// `prep`, once both faces are measured: which way the board was turned over, and one outline for both faces.+/// The board is wherever either face shows solder mask: a face with parts over an edge loses that edge (six terminal blocks hid a quarter of one top face, 2026-11-10)+/// and the bare face shows it. So the two tight masks are laid together through the fit and the outline is fitted to their union. Returns the flip record for prep.json+/// and, when the faces register, the corners each photo should be straightened through (top, bottom) with a note of where the outline came from.+pub fn unify(doc: &Value, top_img: &RgbImage, bot_img: &RgbImage) -> (Option<Value>, Option<([P; 4], [P; 4], String)>, Value) {+    let none = json!(null);+    let (Some(top), Some(bot)) = (pick(doc, "top"), pick(doc, "bottom")) else { return (None, None, none) };+    let (fx, rx) = register(&top, &bot, "x");+    let (fy, ry) = register(&top, &bot, "y");+    let n = |o: &Option<Fit>| o.as_ref().map_or(0, |x| x.matched);+    let k = 4u32;+    let (mt, _) = crate::outline::board_mask(top_img, k);+    let (mb, _) = crate::outline::board_mask(bot_img, k);+    let bwh = (bot_img.width() as f64, bot_img.height() as f64);+    let ((win, wr), lose) = if (ry, n(&fy)) > (rx, n(&fx)) { ((fy, ry), fx) } else { ((fx, rx), fy) };+    let (w, flip, how, tier) = match (win, wr) {+        (Some(w), true) => {+            let (s, turn) = w.scale_turn();+            // the fit itself settles the flip when the other way does not register, or registers on clearly fewer+            let clear = !(if w.axis == "y" { rx } else { ry }) || (w.matched >= n(&lose) + 4 && 2 * w.matched >= 3 * n(&lose));+            let mut flip = json!({"axis": w.axis, "matches": w.matched, "other": n(&lose), "by": format!("through features matched between the faces ({} fit over {} top and {} bottom points)", w.kind, points(&top).0.len(), points(&bot).0.len()),+                                  "residualPx": r3(w.rms), "bottomToTopScale": r3(s), "turnDeg": r3(turn)});+            if clear { flip["how"] = json!(format!("through features matched between the faces ({} this way, {} the other)", w.matched, n(&lose))); }+            let how = format!("the solder mask of both faces laid together through {} through features", w.matched);+            let t = json!({"tier": "through features", "axis": w.axis, "m": w.m, "kind": w.kind, "matched": w.matched, "rms": w.rms, "max": w.max, "pairs": w.pairs.iter().map(|(a, b)| json!([a.0, a.1, b.0, b.1])).collect::<Vec<_>>()});+            (w, Some(flip), how, t)+        }+        _ => {+            // nothing through the board is shared that the pads test finds: the outlines themselves, where the boards then coincide+            let (st, sb) = (solid(&mt), solid(&mb));+            let mut best: Option<(f64, Fit)> = None;+            for axis in ["x", "y"] { if let Some(fit) = outline_fit(&top, &bot, axis) {+                let v = overlap(&st, &sb, &fit, bwh, k as f64);+                if best.as_ref().map_or(true, |q| v > q.0) { best = Some((v, fit)); }+            } }+            match best { Some((v, fit)) if v >= OUTLINE_IOU => { let how = format!("the solder mask of both faces laid together through their outlines ({}; the boards coincide {:.2})", fit.kind, v); (fit, None, how, json!({"tier": "outline", "overlap": r3(v)})) }+                         _ => return (None, None, none) }+        }+    };+    // the union of the two masks in the top photo, at a quarter of its size+    let Some(inv) = geom::invert(&w.m) else { return (flip, None, none) };+    let mut un = mt.clone();+    for y in 0..un.h { for x in 0..un.w { if un.d[y * un.w + x] { continue; }+        let q = mirror(&w.axis, geom::apply(&inv, ((x as f64 + 0.5) * k as f64, (y as f64 + 0.5) * k as f64)), bwh);+        let (u, v) = ((q.0 / k as f64).floor() as i64, (q.1 / k as f64).floor() as i64);+        if u >= 0 && v >= 0 && (u as usize) < mb.w && (v as usize) < mb.h && mb.d[v as usize * mb.w + u as usize] { un.d[y * un.w + x] = true; }+    } }+    let Ok(tc) = crate::prep::corners_of_mask(&un, k) else { return (flip, None, none) };+    let Some(bc) = to_bottom(&tc, &w, bwh) else { return (flip, None, none) };+    (flip, Some((tc, bc, how)), tier)+}++/// `scale`: lay the bottom photo on the top's frame for the flip `axis`. On a registration the bottom photo's corners in prep.json become the places in that photo+/// where the top's corners lie, so every later step that warps the bottom through its corners gets the registered picture; otherwise they are left as they are.+/// Returns the report for scale.json: `registered`, the matches each way, the residual before (each face straightened through the outline fitted to itself, the outlines+/// taken to coincide) and after, and the outlines compared.+pub fn lay(prep_doc: &mut Value, axis: &str, px_per_mm: f64) -> Value {+    let (Some(top), Some(bot)) = (pick(prep_doc, "top"), pick(prep_doc, "bottom")) else { return json!({"registered": false, "why": "one face only"}) };+    let other_axis = if axis == "y" { "x" } else { "y" };+    let (mut fit, mut ok) = register(&top, &bot, axis);+    // the fit `prep` made the shared outline with stands for the same flip: measuring the photos again through that outline must not change the answer+    let kept = &prep_doc["faceFit"];+    if kept["axis"].as_str() == Some(axis) { if let Some(a) = kept["m"].as_array().filter(|a| a.len() == 9) {+        let mut m = [0.0; 9]; for i in 0..9 { m[i] = a[i].as_f64().unwrap_or(0.0); }+        let kind: &'static str = match kept["kind"].as_str().unwrap_or("") { "similarity" => "similarity", "affine" => "affine", "homography" => "homography", k if k.contains("large openings") => "similarity through large openings of one size", _ => "the fit made by prep" };+        let pairs: Vec<(P, P)> = kept["pairs"].as_array().into_iter().flatten().map(|q| ((q[0].as_f64().unwrap_or(0.0), q[1].as_f64().unwrap_or(0.0)), (q[2].as_f64().unwrap_or(0.0), q[3].as_f64().unwrap_or(0.0)))).collect();+        fit = Some(Fit { axis: axis.to_string(), matched: kept["matched"].as_u64().unwrap_or(0) as usize, rms: kept["rms"].as_f64().unwrap_or(f64::NAN), max: kept["max"].as_f64().unwrap_or(f64::NAN), m, kind, pairs }); ok = true;+    } }+    let other = register(&top, &bot, other_axis).0.map_or(0, |x| x.matched);+    let mut weak = Value::Null;+    if ok && fit.as_ref().map_or(false, |x| x.matched <= 4 && x.kind.contains("large openings")) { weak = json!(format!("registered through {} mounting holes only ({}): nothing else that goes through the board was found on both faces, so these few points carry the whole fit and nothing checks it between them; with two, the faces can be off by a millimetre at the far side of the board", fit.as_ref().map_or(0, |x| x.matched), fit.as_ref().map_or("", |x| x.kind))); }+    if !ok { if let Some(o) = outline_fit(&top, &bot, axis) {+        fit = Some(o); ok = true;+        weak = json!("registered through the outlines only: no through feature that both faces show confirms it, and an outline cannot say which way the board was turned over. The faces can be off by what an outline reads to (about 0.3 mm) until vias pair them");+    } }+    let k = px_per_mm.max(1e-9);+    let mut rep = json!({"registered": false, "axis": axis, "points": {"top": points(&top).0.len(), "bottom": points(&bot).0.len()}, "matched": fit.as_ref().map_or(0, |x| x.matched), "otherAxisMatched": other, "tolerance_mm": r3(TOL_PX / k)});+    let (Some(x), true, Some(tc), Some(tf), Some(bf)) = (fit.as_ref(), ok, corners_of(&top, "corners"), own_corners(&top), own_corners(&bot)) else {+        rep["why"] = json!(format!("too few through features agree between the faces ({} this way, {} the other; {} needed, or {} large openings): the bottom rests on its own outline, which a shadow, the table or parts over an edge can move by millimetres", fit.as_ref().map_or(0, |x| x.matched), other, MIN_MATCH, MIN_BIG));+        return rep;+    };+    let bwh = photo_wh(&bot);+    let Some(bc) = to_bottom(&tc, x, bwh) else { rep["why"] = json!("the fit could not be inverted"); return rep; };+    // before: the same pairs when each face is straightened through its own fitted outline and the two are taken to coincide+    let unit = [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)];+    let pm = perm(axis); let mut tdst = [(0.0, 0.0); 4]; for i in 0..4 { tdst[i] = tf[pm[i]]; }+    let before: Vec<f64> = match geom::homography(&bf, &unit).and_then(|a| geom::homography(&unit, &tdst).map(|b| (a, b))) {+        Some((a, b)) => x.pairs.iter().map(|(t, o)| geom::dist(*t, geom::apply(&b, geom::apply(&a, mirror(axis, *o, bwh)))) / k).collect(),+        None => vec![] };+    let rms = |v: &[f64]| (v.iter().map(|q| q * q).sum::<f64>() / v.len().max(1) as f64).sqrt();+    // the outlines: each face's own, and the one both now use, as sizes in the top photo's scale; and how far each face's own corners lie from the shared ones+    let bo = to_top(&bf, x, bwh);+    let off = |c: &[P; 4]| (0..4).map(|i| geom::dist(c[i], tc[i]) / k).fold(0.0, f64::max);+    let (st, sb, su) = (quad_size(&tf), quad_size(&bo), quad_size(&tc));+    let (ot, ob) = (off(&tf), off(&bo));+    let agree = ot <= OUTLINE_TOL_MM && ob <= OUTLINE_TOL_MM;+    let trusted = if agree { format!("each face's own outline lies within {OUTLINE_TOL_MM} mm of the shared one at every corner") }+        else { format!("the shared outline, fitted to the solder mask of both faces laid together: board is wherever either face shows mask, and parts over an edge hide it on one face only. The {} face's own outline is the nearer ({:.2} mm at its worst corner against {:.2} mm)", if ot <= ob { "top" } else { "bottom" }, ot.min(ob), ot.max(ob)) };+    rep["outline"] = json!({"shared_mm": [r3(su.0 / k), r3(su.1 / k)], "topOwn_mm": [r3(st.0 / k), r3(st.1 / k)], "bottomOwn_mm": [r3(sb.0 / k), r3(sb.1 / k)], "topOwnCornerOff_mm": r3(ot), "bottomOwnCornerOff_mm": r3(ob),+                            "tolerance_mm": OUTLINE_TOL_MM, "agree": agree, "trusted": trusted, "notBoardEdge": {"top": top["notBoardEdge"], "bottom": bot["notBoardEdge"]}});+    for ph in prep_doc["photos"].as_array_mut().into_iter().flatten() { if ph["file"] == bot["file"] {+        if ph["cornersFitted"].is_null() { ph["cornersFitted"] = ph["corners"].clone(); }+        ph["corners"] = json!(bc.iter().map(|q| json!([q.0, q.1])).collect::<Vec<_>>());+    } }+    let (s, turn) = x.scale_turn();+    rep["registered"] = json!(true);+    if !weak.is_null() { rep["weak"] = weak; }+    rep["fit"] = json!(x.kind);+    rep["residual_mm"] = json!(r3(x.rms / k)); rep["residualMax_mm"] = json!(r3(x.max / k));+    rep["ownOutlinesAssumedEqual_mm"] = json!({"rms": r3(rms(&before)), "max": r3(before.iter().cloned().fold(0.0, f64::max))});+    rep["bottomToTopScale"] = json!(r3(s)); rep["turnDeg"] = json!(r3(turn));+    rep+}++/// One line for the hints of `scale`.+pub fn say(rep: &Value) -> String {+    let cut: Vec<String> = ["top", "bottom"].iter().filter_map(|s| { let v: Vec<&str> = rep["outline"]["notBoardEdge"][*s].as_array().into_iter().flatten().filter_map(|x| x.as_str()).collect(); (!v.is_empty()).then(|| format!("{s} photo: {}", v.join(", "))) }).collect();+    let cut = if cut.is_empty() { String::new() } else { format!(". NOT the board's edge, the photo stops there ({}): the frame is the photographed window on those sides", cut.join("; ")) };+    say_fit(rep) + &cut+}+fn say_fit(rep: &Value) -> String {+    let way = |a: &str| if a == "y" { "top-to-bottom" } else { "left-to-right" };+    if rep["registered"] == true && rep["weak"].is_string() {+        format!("WARNING, faces {} Outline {} x {} mm (top's own {} x {}, bottom's own {} x {}); trusted: {}", rep["weak"].as_str().unwrap_or(""),+            rep["outline"]["shared_mm"][0], rep["outline"]["shared_mm"][1], rep["outline"]["topOwn_mm"][0], rep["outline"]["topOwn_mm"][1], rep["outline"]["bottomOwn_mm"][0], rep["outline"]["bottomOwn_mm"][1], rep["outline"]["trusted"].as_str().unwrap_or(""))+    } else if rep["registered"] == true {+        format!("faces registered through {} through features (turned over {}; {} the other way; {}): residual {} mm RMS, {} mm worst; with each face on its own outline the same pairs were {} mm RMS, {} mm worst apart. Outline {} x {} mm (top's own {} x {}, bottom's own {} x {}); trusted: {}",+            rep["matched"], way(rep["axis"].as_str().unwrap_or("x")), rep["otherAxisMatched"], rep["fit"].as_str().unwrap_or(""), rep["residual_mm"], rep["residualMax_mm"], rep["ownOutlinesAssumedEqual_mm"]["rms"], rep["ownOutlinesAssumedEqual_mm"]["max"],+            rep["outline"]["shared_mm"][0], rep["outline"]["shared_mm"][1], rep["outline"]["topOwn_mm"][0], rep["outline"]["topOwn_mm"][1], rep["outline"]["bottomOwn_mm"][0], rep["outline"]["bottomOwn_mm"][1], rep["outline"]["trusted"].as_str().unwrap_or(""))+    } else { format!("WARNING, the faces are NOT registered: {}", rep["why"].as_str().unwrap_or("no fit")) }+}++/// For `check prep` once scale.json exists: a sentence when nothing holds the bottom face to the top, None when holes or vias do.+/// Vias count when the relay paired 40 or more and holds 0.15 mm on pairs left out of its fit.+pub fn unregistered(scale: &Value) -> Option<String> {+    let al = &scale["alignment"];+    let faces = &al["faces"];+    if !faces.is_object() || faces["registered"] == true || faces["why"] == "one face only" { return None; }+    let reg = &al["registration"];+    if reg["pairs"].as_u64().unwrap_or(0) >= 40 && reg["leaveOneOutRmsMm"].as_f64().map_or(false, |x| x <= 0.15) { return None; }+    Some(format!("the bottom face is not registered to the top: {} through features match one way and {} the other ({} needed), {} hole pairs after the outline fit (residual {} mm), and no via relay holds them",+        faces["matched"], faces["otherAxisMatched"], MIN_MATCH, al["pairs"], al["residual_mm"]))+}++#[cfg(test)]+mod tests {+    use super::*;++    fn scatter(n: usize, seed0: u64, w: f64, h: f64) -> Vec<P> {+        let mut seed = seed0; let mut next = move || { seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); (seed >> 33) as f64 };+        (0..n).map(|_| (50.0 + next() % (w - 100.0), 50.0 + next() % (h - 100.0))).collect()+    }++    /// The bottom photo's points for a board turned over about `axis`: 3 percent larger, a little turned and shifted, with strays on both faces.+    fn faces(axis: &str) -> (Vec<P>, Vec<P>, P, [f64; 9]) {+        let (w, h) = (6000.0, 4000.0);+        let through = scatter(40, 7, w, h);+        let mut top = through.clone(); top.extend(scatter(60, 99, w, h));+        // truth: top = m * mirrored bottom+        let m = to9(&[0.97, 0.004, -35.0, -0.004, 0.97, 61.0]);+        let inv = geom::invert(&m).unwrap();+        let bwh = (6200.0, 4300.0);+        let mut bot: Vec<P> = through.iter().map(|p| mirror(axis, geom::apply(&inv, *p), bwh)).collect();+        bot.extend(scatter(70, 1234, bwh.0, bwh.1));+        (top, bot, bwh, m)+    }+    fn rec(p: &[P], file: &str, wh: P, corners: [P; 4]) -> Value {+        json!({"file": file, "photoPx": [wh.0, wh.1], "corners": corners.iter().map(|q| json!([q.0, q.1])).collect::<Vec<_>>(), "through": p.iter().map(|q| json!([q.0, q.1, 60.0])).collect::<Vec<_>>()})+    }++    #[test]+    fn the_flip_and_the_map_come_from_the_points_not_the_outline() {+        for axis in ["x", "y"] {+            let (top, bot, bwh, m) = faces(axis);+            let right = fit_axis(&top, &bot, bwh, axis, TOL_PX).unwrap();+            let wrong = fit_axis(&top, &bot, bwh, if axis == "x" { "y" } else { "x" }, TOL_PX).map_or(0, |x| x.matched);+            assert!(right.matched >= 38 && wrong * 4 < right.matched, "{axis}: {} right, {wrong} wrong", right.matched);+            assert!(right.rms < 1.0, "{}", right.rms);+            for p in [(0.0, 0.0), (6200.0, 4300.0)] { assert!(geom::dist(geom::apply(&right.m, p), geom::apply(&m, p)) < 2.0, "{:?} vs {:?}", right.m, m); }+        }+    }++    #[test]+    fn a_tilted_photo_is_taken_up_by_the_homography() {+        // the bottom photographed at a tilt: 2 percent of perspective across the board, which no similarity fits within the tolerance+        let top = scatter(60, 21, 6000.0, 4000.0);+        let tilt = [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 4e-6, 2e-6, 1.0];+        let bot: Vec<P> = top.iter().map(|p| geom::apply(&tilt, *p)).collect();+        let fit = consensus(&top, &bot, TOL_PX).unwrap();+        assert_eq!(fit.kind, "homography");+        assert!(fit.matched >= 58 && fit.rms < 1.0, "{} matched, rms {}", fit.matched, fit.rms);+    }++    #[test]+    fn two_faces_that_share_nothing_do_not_register() {+        let (top, bot) = (scatter(90, 5, 6000.0, 4000.0), scatter(90, 6, 6000.0, 4000.0));+        let c = [(0.0, 0.0), (6000.0, 0.0), (6000.0, 4000.0), (0.0, 4000.0)];+        let mut doc = json!({"photos": [rec(&top, "top.jpg", (6000.0, 4000.0), c), rec(&bot, "bottom.jpg", (6000.0, 4000.0), c)], "primary": {"top": "top.jpg", "bottom": "bottom.jpg"}});+        let rep = lay(&mut doc, "x", 60.0);+        assert_eq!(rep["registered"], false, "{rep}");+        assert!(say(&rep).starts_with("WARNING"));+        assert!(doc["photos"][1]["cornersFitted"].is_null());+        let scale = json!({"alignment": {"pairs": 1, "residual_mm": 0.0, "faces": rep}});+        assert!(unregistered(&scale).is_some());+    }++    #[test]+    fn a_registered_bottom_gets_the_tops_outline_as_its_corners() {+        let (top, bot, bwh, m) = faces("y");+        let tc = [(100.0, 100.0), (5900.0, 100.0), (5900.0, 3900.0), (100.0, 3900.0)];+        // the bottom's own outline is 200 px too tall+        let bc = [(0.0, 0.0), (bwh.0, 0.0), (bwh.0, bwh.1), (0.0, bwh.1)];+        let mut doc = json!({"photos": [rec(&top, "top.jpg", (6000.0, 4000.0), tc), rec(&bot, "bottom.jpg", bwh, bc)], "primary": {"top": "top.jpg", "bottom": "bottom.jpg"}});+        let rep = lay(&mut doc, "y", 60.0);+        assert_eq!(rep["registered"], true, "{rep}");+        assert_eq!(rep["outline"]["agree"], false, "{rep}");+        // turned over top-to-bottom, the bottom photo's first corner is the top's lower left+        let c = corners_of(&doc["photos"][1], "corners").unwrap();+        let want = mirror("y", geom::apply(&geom::invert(&m).unwrap(), tc[3]), bwh);+        assert!(geom::dist(c[0], want) < 3.0, "{c:?} vs {want:?}");+        assert!(doc["photos"][1]["cornersFitted"].is_array());+        // run again: the answer does not drift+        let again = lay(&mut doc, "y", 60.0);+        assert_eq!(again["matched"], rep["matched"]);+        assert!(geom::dist(corners_of(&doc["photos"][1], "corners").unwrap()[0], c[0]) < 1e-6);+        assert!(unregistered(&json!({"alignment": {"faces": rep}})).is_none());+    }++    #[test]+    fn four_mounting_holes_register_a_board_that_shares_nothing_else() {+        // four large openings at the corners, small strays on both faces that share nothing+        let holes = [(300.0, 300.0), (5700.0, 320.0), (5690.0, 3700.0), (310.0, 3690.0)];+        let m = to9(&[1.02, 0.0, 40.0, 0.0, 1.02, -25.0]);+        let inv = geom::invert(&m).unwrap();+        let bwh = (6000.0, 4000.0);+        let mk = |big: Vec<P>, small: Vec<P>, file: &str| { let mut v = rec(&small, file, bwh, [(0.0, 0.0), (6000.0, 0.0), (6000.0, 4000.0), (0.0, 4000.0)]); for q in big { v["through"].as_array_mut().unwrap().push(json!([q.0, q.1, 220.0])); } v };+        let top = mk(holes.to_vec(), scatter(30, 3, 6000.0, 4000.0), "top.jpg");+        let bot = mk(holes.iter().map(|p| mirror("x", geom::apply(&inv, *p), bwh)).collect(), scatter(30, 4, 6000.0, 4000.0), "bottom.jpg");+        let (fit, ok) = register(&top, &bot, "x");+        assert!(ok && fit.unwrap().matched >= 4);+    }+}
src/flipdecide.rsadded+36
@@ -0,0 +1,36 @@+//! Which way the board was turned over, for the scale paths that do not frame through mounting holes (`scale --ruler`, `scale --body`).+//!+//! `prep` reads the flip from plated holes both faces show. A board whose header rows are all populated shows pins on one face and solder or sockets on the other,+//! and no hole agrees either way (test boards 7 and 8, 2026-11-10: 0 and 0). Then the flip is the one stated with `--flip`, or the one under which what shows through+//! the board on both faces (via rims, copper edges on plain mask) correlates, as `scale --holes` has always decided it.+use image::RgbImage;+use serde_json::Value;++/// Some((axis, how)) when the flip is settled here: stated, or read from through features. None leaves what `prep` found from the holes.+/// `frame` builds the bottom face in the board frame for a given axis.+pub fn decide(prep_doc: &Value, stated: Option<&str>, top: &RgbImage, frame: &dyn Fn(&str) -> RgbImage) -> Option<(String, &'static str)> {+    if let Some(a) = stated { if a == "x" || a == "y" { return Some((a.to_string(), "stated with --flip")); } }+    let (m, o) = (prep_doc["flip"]["matches"].as_u64().unwrap_or(0), prep_doc["flip"]["other"].as_u64().unwrap_or(0));+    if holes_decide(m, o) { return None; }+    let (bx, by) = (frame("x"), frame("y"));+    let (axis, cx, cy) = crate::board::flip_by_detail(top, &bx, &by);+    eprintln!("note: the holes do not say which way the board was turned over ({m} agree one way, {o} the other); plain mask detail correlates {cx:.3} mirrored in x, {cy:.3} in y");+    axis.map(|a| (a, "through features: the holes cannot say"))+}++/// The holes decide when one way has at least three and three times the other: the rule `check prep` applies.+pub fn holes_decide(m: u64, o: u64) -> bool { m >= 3 && m >= 3 * o.max(1) }++#[cfg(test)]+mod tests {+    use super::*;+    use serde_json::json;+    #[test]+    fn a_stated_flip_stands_and_clear_holes_are_left_alone() {+        let top = RgbImage::new(64, 64);+        let never = |_: &str| -> RgbImage { panic!("the bottom is not framed when the answer is known") };+        assert_eq!(decide(&json!({"flip": {"axis": "x", "matches": 0, "other": 0}}), Some("y"), &top, &never), Some(("y".to_string(), "stated with --flip")));+        assert_eq!(decide(&json!({"flip": {"axis": "x", "matches": 9, "other": 1}}), None, &top, &never), None);+        assert!(holes_decide(3, 0) && holes_decide(6, 2) && !holes_decide(0, 0) && !holes_decide(2, 0) && !holes_decide(4, 2));+    }+}
src/intake.rsadded+333
@@ -0,0 +1,333 @@+//! Photo intake for a set nobody sorted: `init --photos <folder or files>` records the photos as they came, and `stitch`+//! (run by `prep` when it has not been run) sorts them into faces, close-up tiles and overviews, picks the top, and+//! builds one mosaic per face at the close-ups' own resolution. The image registration is Python with OpenCV: the script+//! is `tools/stitch.py`, compiled into the binary, written to `reverse-flow/stitch/stitch.py` and run from there, and+//! the call (command line, script hash, exit status) goes into the run ledger. Its results land where the flow already+//! looks: the mosaics become the run's photos (`photos/top.jpg`, `photos/bottom.jpg`), so `prep`, `scale` and every later+//! step run on them unchanged. `stitch.json` and the evidence pictures say what was done and what is missing.+use crate::ledger;+use serde_json::{json, Value};+use std::path::{Path, PathBuf};++/// The stitcher, as shipped in tools/.+pub const SCRIPT: &str = include_str!("../tools/stitch.py");+/// Where the unsorted photos are kept, and where the stitcher works, inside the flow folder.+pub const RAW_DIR: &str = "photos-raw";+pub const WORK_DIR: &str = "stitch";++fn is_photo(p: &Path) -> bool {+    matches!(p.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase()).as_deref(), Some("jpg") | Some("jpeg") | Some("png"))+}++/// A file name split into text and number runs, so that Board2.10 sorts after Board2.9.+fn natural_key(name: &str) -> Vec<(String, u64)> {+    let mut out = vec![]; let (mut text, mut num, mut in_num) = (String::new(), String::new(), false);+    for c in name.chars() {+        if c.is_ascii_digit() { in_num = true; num.push(c); }+        else { if in_num { out.push((std::mem::take(&mut text), num.parse().unwrap_or(0))); num.clear(); in_num = false; } text.push(c.to_ascii_lowercase()); }+    }+    out.push((text, num.parse().unwrap_or(0)));+    out+}++/// The photos behind `--photos`: each argument is a photo or a folder of photos (jpg, jpeg, png; not searched deeper).+/// Sorted by name with numbers in order, no file twice.+pub fn collect(args: &[String]) -> Result<Vec<PathBuf>, String> {+    let mut out: Vec<PathBuf> = vec![];+    for a in args {+        let p = Path::new(a);+        if p.is_dir() {+            let mut found: Vec<PathBuf> = std::fs::read_dir(p).map_err(|e| format!("{a}: {e}"))?.flatten().map(|e| e.path()).filter(|q| q.is_file() && is_photo(q)).collect();+            if found.is_empty() { return Err(format!("{a}: no jpg, jpeg or png photos in this folder")); }+            out.append(&mut found);+        } else if p.is_file() {+            if !is_photo(p) { return Err(format!("{a}: not a jpg, jpeg or png")); }+            out.push(p.to_path_buf());+        } else { return Err(format!("{a}: not a file or folder")); }+    }+    out.sort_by_key(|p| natural_key(&p.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default()));+    out.dedup();+    let mut names = std::collections::HashSet::new();+    for p in &out { if !names.insert(p.file_name().map(|n| n.to_os_string())) { return Err(format!("two photos are both named {}: rename one", p.file_name().unwrap().to_string_lossy())); } }+    Ok(out)+}++/// `init --photos`: copy the photos as they came and return the state entry that says they are not sorted yet.+pub fn record(flow_dir: &Path, files: &[PathBuf]) -> Result<Value, String> {+    let raw = flow_dir.join(RAW_DIR);+    std::fs::create_dir_all(&raw).map_err(|e| e.to_string())?;+    let mut recs = vec![];+    for src in files {+        let name = src.file_name().unwrap().to_string_lossy().to_string();+        let dst = raw.join(&name);+        std::fs::copy(src, &dst).map_err(|e| format!("{}: {e}", src.display()))?;+        recs.push(json!({"file": name, "source": src.display().to_string(), "sha256": crate::sha256(&dst)}));+    }+    Ok(json!({"unsorted": recs, "stitched": false}))+}++/// The run was started with unsorted photos and they have not been stitched yet.+pub fn pending(state: &Value) -> bool {+    state["intake"]["unsorted"].as_array().map(|a| !a.is_empty()).unwrap_or(false) && state["intake"]["stitched"] != json!(true)+}++/// What `next` adds while the photos wait to be sorted.+pub fn hint(flow_dir: &Path) -> Vec<String> {+    let state: Value = std::fs::read_to_string(flow_dir.join("state.json")).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(json!({}));+    if pending(&state) {+        vec![format!("{} photos are not sorted into faces yet: `prep` does it first (or run `stitch` alone), then read stitch.json and stitch/*-sources.jpg, *-seams.jpg", state["intake"]["unsorted"].as_array().map(|a| a.len()).unwrap_or(0))]+    } else { vec![] }+}++/// The run's photo list after stitching: one mosaic per face that was built.+pub fn photos_from_report(report: &Value) -> Vec<Value> {+    let mut out = vec![];+    for side in ["top", "bottom"] {+        let f = &report["faces"][side];+        if !f.is_object() { continue; }+        let from: Vec<Value> = f["tiles"].as_array().into_iter().flatten().map(|t| t["file"].clone()).collect();+        out.push(json!({"file": format!("{side}.jpg"), "side": side, "source": format!("{WORK_DIR}/{side}.jpg"), "mosaicOf": from, "overview": f["overview"]}));+    }+    out+}++/// One line per face for the hints, and what the report says is missing.+pub fn summary(report: &Value) -> Vec<String> {+    let mut h = vec![];+    for side in ["top", "bottom"] {+        let f = &report["faces"][side];+        if !f.is_object() { continue; }+        let n = f["tiles"].as_array().map(|a| a.len()).unwrap_or(0);+        let px = |v: &Value| v.as_f64().map(|x| format!("{x} px")).unwrap_or("not measured".into());+        h.push(format!("{side}: {n} close-up tile(s){}, {} x {} px; close-ups cover {}% of the board, overview fill (low resolution) {}%, not seen {}%; tiles disagree in their overlaps by {} at the median, {} at the 95th percentile ({} cells)",+            f["overview"].as_str().map(|o| format!(" and overview {o}")).unwrap_or_default(), f["size"][0], f["size"][1],+            f["coverage"]["closeUpsPct"], f["coverage"]["overviewLowResolutionPct"], f["coverage"]["notSeenPct"], px(&f["overlap"]["medPx"]), px(&f["overlap"]["p95Px"]), f["overlap"]["cells"]));+        for n in f["notes"].as_array().into_iter().flatten() { if let Some(s) = n.as_str() { h.push(format!("{side}: {s}")); } }+    }+    if let Some(d) = report["topDecision"].as_object() {+        h.push(format!("top chosen by: {}{}", d["rule"].as_str().unwrap_or(""), d.get("confidence").and_then(|c| c.as_str()).map(|c| format!(" ({c})")).unwrap_or_default()));+    }+    for u in report["unplaced"].as_array().into_iter().flatten() {+        h.push(format!("left out: {} ({})", u["photos"].as_array().into_iter().flatten().filter_map(|x| x.as_str()).collect::<Vec<_>>().join(", "), u["why"].as_str().unwrap_or("")));+    }+    for m in report["missing"].as_array().into_iter().flatten() { if let Some(s) = m.as_str() { h.push(format!("missing: {s}")); } }+    if let Some(s) = report["facesAssumed"].as_str() { h.push(format!("assumed: {s}")); }+    h+}++/// The board's outline in a mosaic, as `stitch` recorded it (stitch.json next to photos/, `boardBoxPx` of that face):+/// the four corners TL, TR, BR, BL in the mosaic's pixels. `prep` falls back on it when its own colour test finds no+/// green board, which is every red or blue one. It is a box round the outline cut from the overview, so it can sit a+/// millimetre or two outside a board that lies turned in the mosaic.+pub fn board_box(photo: &Path, side: &str) -> Option<[crate::geom::P; 4]> {+    let report = crate::items::read_json(&photo.parent()?.parent()?.join("stitch.json")).ok()?;+    let face = &report["faces"][side];+    if face["file"].as_str() != photo.file_name()?.to_str() { return None; }+    let b: Vec<f64> = face["boardBoxPx"].as_array()?.iter().filter_map(|v| v.as_f64()).collect();+    if b.len() != 4 || b[2] - b[0] < 16.0 || b[3] - b[1] < 16.0 { return None; }+    Some([(b[0], b[1]), (b[2], b[1]), (b[2], b[3]), (b[0], b[3])])+}++fn python() -> String { std::env::var("ADOM_REVERSE_PYTHON").unwrap_or_else(|_| "python3".into()) }++/// Sort and stitch the run's unsorted photos. Writes stitch/ (mosaics, stitch.json, evidence), copies the mosaics into+/// photos/, sets the run's photo list, and logs the call. Returns the hints for the caller's answer.+pub fn run(p: &mut ledger::Project, thread: &str) -> Result<Vec<String>, String> {+    let f = p.flow_dir.clone();+    let unsorted: Vec<String> = p.state["intake"]["unsorted"].as_array().into_iter().flatten().filter_map(|r| r["file"].as_str().map(str::to_string)).collect();+    if unsorted.is_empty() { return Err("this run was not started with `init --photos`: there is nothing to sort".into()); }+    let work = f.join(WORK_DIR);+    std::fs::create_dir_all(&work).map_err(|e| e.to_string())?;+    let script = work.join("stitch.py");+    std::fs::write(&script, SCRIPT).map_err(|e| format!("{}: {e}", script.display()))?;+    let mut cmd = std::process::Command::new(python());+    cmd.arg(&script).arg("--out").arg(&work);+    let top_photo = p.state["intake"]["topPhoto"].as_str().map(str::to_string);+    if let Some(t) = &top_photo { cmd.arg("--top-photo").arg(t); }+    for name in &unsorted { cmd.arg(f.join(RAW_DIR).join(name)); }+    cmd.env("STITCH_THREADS", "4");+    let line = format!("{} {}/stitch.py --out {}{} {}", python(), WORK_DIR, WORK_DIR, top_photo.as_ref().map(|t| format!(" --top-photo {t}")).unwrap_or_default(), unsorted.iter().map(|n| format!("{RAW_DIR}/{n}")).collect::<Vec<_>>().join(" "));+    let started = ledger::now();+    let out = cmd.output().map_err(|e| format!("could not start {}: {e}. The stitcher needs python3 with numpy and OpenCV (cv2 with SIFT); set ADOM_REVERSE_PYTHON to choose the interpreter", python()))?;+    std::fs::write(work.join("stitch.log"), [out.stdout.as_slice(), b"\n--- stderr ---\n", out.stderr.as_slice()].concat()).ok();+    let call = json!({"command": line, "cwd": "reverse-flow", "script": format!("{WORK_DIR}/stitch.py"), "scriptSha256": crate::sha256(&script), "started": started, "ended": ledger::now(),+                      "exit": out.status.code(), "log": format!("{WORK_DIR}/stitch.log")});+    if !out.status.success() {+        p.log(thread, "stitch", json!({"call": call, "ok": false}));+        let tail: Vec<String> = String::from_utf8_lossy(&out.stderr).lines().rev().take(4).map(str::to_string).collect::<Vec<_>>().into_iter().rev().collect();+        return Err(format!("the stitcher failed (exit {:?}): {}. Full output in {}/{WORK_DIR}/stitch.log", out.status.code(), tail.join(" | "), f.display()));+    }+    let report = crate::items::read_json(&work.join("stitch.json"))?;+    let mut photos = photos_from_report(&report);+    if photos.is_empty() { return Err("the stitcher built no face: see stitch/stitch.json".into()); }+    std::fs::create_dir_all(f.join("photos")).ok();+    for ph in photos.iter_mut() {+        let name = ph["file"].as_str().unwrap().to_string();+        let dst = f.join("photos").join(&name);+        std::fs::copy(work.join(&name), &dst).map_err(|e| format!("{name}: {e}"))?;+        ph["sha256"] = json!(crate::sha256(&dst));+    }+    std::fs::copy(work.join("stitch.json"), f.join("stitch.json")).map_err(|e| e.to_string())?;+    p.state["photos"] = json!(photos);+    p.state["intake"]["stitched"] = json!(true);+    p.state["intake"]["report"] = json!("stitch.json");+    p.save().map_err(|e| e.to_string())?;+    p.log(thread, "stitch", json!({"call": call, "ok": true, "photos": p.state["photos"], "topDecision": report["topDecision"], "unplaced": report["unplaced"]}));+    let mut h = summary(&report);+    h.push(format!("stitched by `{line}` (Python with OpenCV; the script is the one shipped in tools/stitch.py, its hash is in the ledger)"));+    h.push("Look before going on: stitch/top-sources.jpg and bottom-sources.jpg (which photo each part of the mosaic came from), stitch/*-seams.jpg (the joins at full size), stitch/*-overlap.jpg (where the tiles disagree).".into());+    if photos.len() == 1 { h.push("Only one face was built: bottom copper, via rings and layer evidence will be unknown. Ask for photos of the other face.".into()); }+    Ok(h)+}++#[cfg(test)]+mod tests {+    use super::*;++    fn tmp(name: &str) -> PathBuf { let d = std::env::temp_dir().join(format!("intake-{name}-{}", std::process::id())); std::fs::remove_dir_all(&d).ok(); std::fs::create_dir_all(&d).unwrap(); d }++    #[test]+    fn photos_come_from_folders_and_lists_in_number_order() {+        let d = tmp("collect");+        for n in ["Board2.10.jpeg", "Board2.9.JPG", "Board2.1.JPG", "notes.txt", "Board2.11.png"] { std::fs::write(d.join(n), "x").unwrap(); }+        let got: Vec<String> = collect(&[d.display().to_string()]).unwrap().iter().map(|p| p.file_name().unwrap().to_string_lossy().to_string()).collect();+        assert_eq!(got, ["Board2.1.JPG", "Board2.9.JPG", "Board2.10.jpeg", "Board2.11.png"]);+        // a list of files gives the same, and a file named twice counts once+        let list: Vec<String> = ["Board2.10.jpeg", "Board2.1.JPG", "Board2.1.JPG"].iter().map(|n| d.join(n).display().to_string()).collect();+        assert_eq!(collect(&list).unwrap().len(), 2);+        assert!(collect(&[d.join("notes.txt").display().to_string()]).is_err());+        assert!(collect(&[d.join("missing.jpg").display().to_string()]).is_err());+        std::fs::remove_dir_all(&d).ok();+    }++    #[test]+    fn an_empty_folder_is_an_error_not_an_empty_run() {+        let d = tmp("empty");+        assert!(collect(&[d.display().to_string()]).unwrap_err().contains("no jpg"));+        std::fs::remove_dir_all(&d).ok();+    }++    #[test]+    fn unsorted_photos_wait_until_they_are_stitched() {+        assert!(!pending(&json!({"photos": [{"file": "a.jpg", "side": "top"}]})));        // a run started with --top+        let mut st = json!({"photos": [], "intake": {"unsorted": [{"file": "a.jpg"}], "stitched": false}});+        assert!(pending(&st));+        st["intake"]["stitched"] = json!(true);+        assert!(!pending(&st));+    }++    #[test]+    fn next_says_so_while_the_photos_wait() {+        let d = tmp("hint");+        std::fs::write(d.join("state.json"), json!({"intake": {"unsorted": [{"file": "a.jpg"}, {"file": "b.jpg"}], "stitched": false}}).to_string()).unwrap();+        let h = hint(&d);+        assert_eq!(h.len(), 1);+        assert!(h[0].starts_with("2 photos are not sorted"));+        std::fs::write(d.join("state.json"), json!({"photos": [{"file": "a.jpg", "side": "top"}]}).to_string()).unwrap();+        assert!(hint(&d).is_empty());+        std::fs::remove_dir_all(&d).ok();+    }++    #[test]+    fn the_mosaics_become_the_runs_photos_with_their_faces() {+        let rep = json!({"faces": {"bottom": {"tiles": [{"file": "b1.jpg"}], "overview": null}, "top": {"tiles": [{"file": "t1.jpg"}, {"file": "t2.jpg"}], "overview": "o.jpg"}}});+        let ph = photos_from_report(&rep);+        assert_eq!(ph.len(), 2);+        assert_eq!(ph[0]["file"], "top.jpg"); assert_eq!(ph[0]["side"], "top"); assert_eq!(ph[0]["mosaicOf"], json!(["t1.jpg", "t2.jpg"])); assert_eq!(ph[0]["overview"], "o.jpg");+        assert_eq!(ph[1]["file"], "bottom.jpg"); assert_eq!(ph[1]["side"], "bottom");+        // one face only: one photo, and it is the top+        let one = photos_from_report(&json!({"faces": {"top": {"tiles": [{"file": "t.jpg"}]}}}));+        assert_eq!(one.len(), 1); assert_eq!(one[0]["side"], "top");+    }++    #[test]+    fn the_summary_says_what_is_missing_and_never_hides_low_resolution() {+        let rep = json!({"faces": {"top": {"tiles": [{"file": "t1.jpg"}], "overview": null, "size": [4032, 3024],+            "coverage": {"closeUpsPct": 100.0, "overviewLowResolutionPct": 0.0, "notSeenPct": 0.0}, "overlap": {"cells": 0, "medPx": null, "p95Px": null},+            "notes": ["the board runs out of this photo on the left side(s)"]}},+            "topDecision": {"rule": "only one face was photographed: it is written as the top", "confidence": "none"}, "missing": ["no photo of the other face"],+            "unplaced": [{"photos": ["x.jpg"], "why": "matches no other photo"}]});+        let h = summary(&rep).join("\n");+        assert!(h.contains("not measured"));+        assert!(h.contains("runs out of this photo"));+        assert!(h.contains("missing: no photo of the other face"));+        assert!(h.contains("left out: x.jpg"));+    }++    #[test]+    fn stitch_is_the_first_thing_next_and_auto_run_on_unsorted_photos() {+        let d = tmp("auto");+        std::fs::write(d.join("state.json"), json!({"photos": [], "intake": {"unsorted": [{"file": "a.jpg"}], "stitched": false}}).to_string()).unwrap();+        let fl = crate::flow::load();+        let now = crate::auto::now_lines(&d, &fl);+        assert!(now[0].starts_with("now: `stitch`"), "{now:?}");+        // once stitched, the photos are measured+        std::fs::write(d.join("state.json"), json!({"photos": [{"file": "top.jpg", "side": "top"}], "intake": {"unsorted": [{"file": "a.jpg"}], "stitched": true}}).to_string()).unwrap();+        assert!(crate::auto::now_lines(&d, &fl)[0].starts_with("now: `prep`"));+        std::fs::remove_dir_all(&d).ok();+    }++    #[test]+    fn a_mosaic_hands_its_board_box_to_prep() {+        let d = tmp("box");+        std::fs::create_dir_all(d.join("photos")).unwrap();+        std::fs::write(d.join("stitch.json"), json!({"faces": {"top": {"file": "top.jpg", "boardBoxPx": [300, 200, 7000, 5000]}, "bottom": {"file": "bottom.jpg"}}}).to_string()).unwrap();+        let c = board_box(&d.join("photos/top.jpg"), "top").unwrap();+        assert_eq!(c, [(300.0, 200.0), (7000.0, 200.0), (7000.0, 5000.0), (300.0, 5000.0)]);+        assert!(board_box(&d.join("photos/bottom.jpg"), "bottom").is_none());       // no box recorded for that face+        assert!(board_box(&d.join("photos/other.jpg"), "top").is_none());           // not the mosaic+        std::fs::remove_dir_all(&d).ok();+    }++    #[test]+    fn the_shipped_script_is_the_one_compiled_in() {+        assert!(SCRIPT.starts_with("#!/usr/bin/env python3"));+        assert!(SCRIPT.contains("def main():"));+        // the measurement must copy each cell: OpenCV 5.0.0's phaseCorrelate writes into its inputs+        assert!(SCRIPT.contains(".copy(), gb[y:y + S, x:x + S].copy()"));+    }++    /// Whether this machine can run the stitcher at all.+    fn have_cv() -> bool {+        std::process::Command::new(python()).args(["-c", "import cv2, numpy; cv2.SIFT_create()"]).output().map(|o| o.status.success()).unwrap_or(false)+    }++    /// End to end on a made-up board: four overlapping tiles and a smaller overview of each face, no labels. The stitcher+    /// must put the tiles of each face together, call the face with the dark bodies the top, and agree in the overlaps.+    #[test]+    fn a_made_up_board_is_sorted_and_stitched_with_no_labels() {+        if !have_cv() { eprintln!("skipped: no python3 with OpenCV here"); return; }+        let d = tmp("e2e");+        let gen = d.join("gen.py");+        std::fs::write(&gen, include_str!("../tools/stitch_fixture.py")).unwrap();+        let o = std::process::Command::new(python()).arg(&gen).arg(d.join("in")).output().unwrap();+        assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));+        let files = collect(&[d.join("in").display().to_string()]).unwrap();+        assert_eq!(files.len(), 10);+        let flow_dir = d.join("reverse-flow");+        std::fs::create_dir_all(&flow_dir).unwrap();+        let intake = record(&flow_dir, &files).unwrap();+        let mut p = ledger::Project::create(&d, "reverse-flow", json!({"photos": [], "intake": intake})).unwrap();+        assert!(pending(&p.state));+        let hints = run(&mut p, "test").unwrap_or_else(|e| panic!("{e}"));+        assert!(!pending(&p.state));+        assert!(hints.iter().any(|h| h.starts_with("top: 4 close-up tile(s)")), "{hints:?}");+        let rep = crate::items::read_json(&flow_dir.join("stitch.json")).unwrap();+        // the fixture's names say which face each tile is: the stitcher never saw that+        for (side, want) in [("top", "a"), ("bottom", "b")] {+            let f = &rep["faces"][side];+            let tiles: Vec<&str> = f["tiles"].as_array().unwrap().iter().map(|t| t["file"].as_str().unwrap()).collect();+            assert_eq!(tiles.len(), 4, "{side}: {tiles:?}");+            assert!(tiles.iter().all(|t| t.starts_with(want)), "{side}: {tiles:?}");+            assert!(f["overview"].as_str().unwrap().starts_with(want));+            assert!(f["overlap"]["cells"].as_u64().unwrap() > 20, "{side}: {}", f["overlap"]);+            assert!(f["overlap"]["medPx"].as_f64().unwrap() < 1.0, "{side}: {}", f["overlap"]);+            assert!(f["coverage"]["closeUpsPct"].as_f64().unwrap() > 90.0, "{side}: {}", f["coverage"]);+            assert!(flow_dir.join("photos").join(format!("{side}.jpg")).is_file());+        }+        assert_eq!(p.state["photos"].as_array().unwrap().len(), 2);+        let ledger_text = std::fs::read_to_string(flow_dir.join("run.jsonl")).unwrap();+        assert!(ledger_text.contains("\"ev\":\"stitch\"") && ledger_text.contains("stitch/stitch.py --out stitch"));+        std::fs::remove_dir_all(&d).ok();+    }+}
src/islands.rs+17−3
@@ -129,6 +129,10 @@ pub fn gap_share(t: &Traced, p: &Pad) -> f64 {  /// The island a pad connects to on this face: the copper around it, when no closed gap stands between them. pub fn pad_island(t: &Traced, p: &Pad) -> Option<(u32, f64)> {+    // the island that covers the pad itself: `padjoin` gives a pad's own area to the copper it reaches, and that says more than a ring round it+    { let mut votes: HashMap<u32, u32> = HashMap::new(); let mut all = 0u32;+      for q in ring(p, -0.3 * p.w.min(p.h), 24).into_iter().chain(std::iter::once((p.x, p.y))) { let (u, v) = board::to_px(q.0, q.1); if u < 0.0 || v < 0.0 || u >= t.w as f64 || v >= t.h as f64 { continue; } all += 1; let l = t.labels[v as usize * t.w + u as usize]; if l != 0 { *votes.entry(l).or_default() += 1; } }+      if let Some((l, c)) = votes.into_iter().max_by_key(|x| (x.1, x.0)) { if c * 2 >= all.max(1) { return Some((l, 0.0)); } } }     let mut totals: HashMap<u32, u32> = HashMap::new();     for d in [0.16, 0.24, 0.32, 0.40, 0.48] { for q in ring(p, d, 40) {         let (u, v) = board::to_px(q.0, q.1);@@ -233,11 +237,17 @@ pub fn measure(faces: &mut [Face], pads: &[Pad], domes: &[SeenDome], pin_net: &H     // pins onto islands, face by face     let mut profile = vec![];     let mut joins = vec![]; let mut pad_on: Vec<Vec<usize>> = vec![vec![]; pads.len()];+    // both pads of a two-pad part on one island: the island is two conductors read as one there, and neither pad is joined to it (`faceread::pairs_on_one_island`)+    let pad_on_island = |pi: usize, fi: usize| -> Option<u32> { if pads[pi].on(faces[fi].layer) { pad_island(&faces[fi].t, &pads[pi]).map(|x| x.0) } else { None } };+    let (mut pair_refused, pair_rows, pairs_seen) = crate::faceread::pairs_on_one_island(pads, faces.len(), &pad_on_island);+    // and an island with most of the pins of a large part joins none of them (`faceread::big_parts_on_one_island`)+    let (big_refused, big_rows) = crate::faceread::big_parts_on_one_island(pads, faces.len(), &pad_on_island);+    pair_refused.extend(big_refused);     for (pi, p) in pads.iter().enumerate() {         for (fi, f) in faces.iter().enumerate() {             if !p.on(f.layer) { continue; }             profile.push(json!({"pin": format!("{}.{}", p.part, p.num), "side": f.side, "plated": p.drill > 0.0, "net": pin_net.get(&format!("{}.{}", p.part, p.num)), "gap": (gap_share(&f.t, p) * 100.0).round() / 100.0}));-            if let Some((l, share)) = pad_island(&f.t, p) {+            if let Some((l, share)) = pad_island(&f.t, p).filter(|_| !pair_refused.contains(&(pi, fi))) {                 let k = index[&(fi, l)]; isl[k].pins.push(pi); pad_on[pi].push(k);                 joins.push(json!({"pin": format!("{}.{}", p.part, p.num), "side": f.side, "island": l, "gap": (share * 100.0).round() / 100.0}));                 if let Some(n) = pin_net.get(&format!("{}.{}", p.part, p.num)) {@@ -487,7 +497,9 @@ pub fn measure(faces: &mut [Face], pads: &[Pad], domes: &[SeenDome], pin_net: &H     let both = via_out.iter().filter(|v| v["seen"] == json!("both-faces")).count();     let report = json!({"islands": isl.len(), "absorbedDimples": 0, "tooSmallToDraw": small, "pours": zones.len(), "tracks": tracks.len(), "fragmentsDropped": tracks_dropped, "domes": {"top": dome_total.0, "bottom": dome_total.1}, "vias": {"total": via_out.len(), "bothFaces": both, "named": named, "inner": inner, "closedOnEveryFace": ringed, "facesShowVias": opt.faces_show_vias},         "islandsWithNet": isl.iter().filter(|i| i.net.is_some()).count(), "pinsJoined": joins.len(), "pinAdds": pin_adds.len(), "pinClashes": pin_clash.len(), "viaClashes": clashes.len(), "mixedIslands": mixed.len(), "trackPadConflicts": pad_conflicts.len(), "accounting": accounting["totals"]});-    json!({"zones": zones, "traces": tracks, "vias": via_out, "groups": groups, "report": report, "pinAdds": pin_adds, "pinClashes": pin_clash, "viaClashes": clashes, "mixed": mixed, "joins": joins, "profile": profile, "accounting": accounting, "trackPadConflicts": pad_conflicts, "viasDropped": via_drops})+    json!({"zones": zones, "traces": tracks, "vias": via_out, "groups": groups, "report": report, "pinAdds": pin_adds, "pinClashes": pin_clash, "viaClashes": clashes, "mixed": mixed, "joins": joins, "profile": profile, "accounting": accounting, "trackPadConflicts": pad_conflicts, "viasDropped": via_drops, "padPairsRefused": pair_rows.iter().map(|r| { let mut r = r.clone(); r["side"] = json!(faces[r["face"].as_u64().unwrap_or(0) as usize].side); r }).collect::<Vec<_>>(),+        "partPinsRefused": big_rows.iter().map(|r| { let mut r = r.clone(); r["side"] = json!(faces[r["face"].as_u64().unwrap_or(0) as usize].side); r }).collect::<Vec<_>>(),+        "padPairsSeen": pairs_seen.iter().enumerate().map(|(fi, n)| json!({"side": faces[fi].side, "parts": n})).collect::<Vec<_>>()}) }  /// One centre line down a thin island: points in sub-image pixels, and the measured width in pixels at each.@@ -587,7 +599,9 @@ fn account(faces: &[Face], zone_of: &HashMap<usize, String>, track_of: &HashMap<             rows.push(row);         }         kept += fk; merged += fm; not_copper += fn_;-        per_face.push(json!({"side": f.side, "read": fk + fm + fn_, "kept": fk, "merged": fm, "notCopper": fn_, "crumbs": t.steps.crumbs, "necksCut": t.steps.necks_cut, "pinchesCut": t.steps.pinches_cut, "joins": t.steps.joined, "joinsRefused": t.steps.refused_joins}));+        per_face.push(json!({"side": f.side, "read": fk + fm + fn_, "kept": fk, "merged": fm, "notCopper": fn_, "crumbs": t.steps.crumbs, "necksCut": t.steps.necks_cut, "pinchesCut": t.steps.pinches_cut, "joins": t.steps.joined, "joinsRefused": t.steps.refused_joins,+            "copperShare": (t.steps.copper_share as f64 * 1000.0).round() / 1000.0, "padsJoined": t.steps.pads_joined, "trackPiecesBridged": t.steps.bridged, "levelsFromEdges": t.steps.levels_from_edges,+            "levelsAgainstEdges": t.steps.edge_check.map(|a| json!({"edges": a.edges, "modalStep": a.modal_step, "straddle": a.straddle, "bothCopper": a.both_copper, "bothLaminate": a.both_laminate}))}));     }     let on_parts = rows.iter().filter(|r| r["reason"].as_str().is_some_and(|w| w.starts_with("on the body of "))).count();     json!({"totals": {"read": kept + merged + not_copper, "kept": kept, "merged": merged, "notCopper": not_copper, "onPartBodies": on_parts}, "perFace": per_face, "islands": rows})
src/kicad_out.rs+39−5
@@ -412,7 +412,13 @@ fn photo_gaps(copper: &Value, pads: &[Pad], shaped: &HashMap<usize, Vec<(f64, f6     out } -pub struct Board { pub text: String, pub pads: Vec<Pad>, pub fills: Vec<(String, String, Mask)>, pub grid: Grid, pub warnings: Vec<String> }+/// `fills` holds one mask per copper layer: everything filled on it (the net is empty). Until 2026-11-11 it held one whole-board mask+/// per zone, which on a board with 1201 read islands (test board 11, 72 x 175 mm) was 6 GB and killed `build`. `zones_filled` counts the zones.+/// `no_fill`: read pours drawn with an outline and no fill, each with its area and what covers it (`{"id", "layer", "net", "areaMm2", "why"}`).+pub struct Board { pub text: String, pub pads: Vec<Pad>, pub fills: Vec<(String, String, Mask)>, pub zones_filled: usize, pub no_fill: Vec<Value>, pub grid: Grid, pub warnings: Vec<String> }++/// A patch of exposed metal that no footprint explains, drawn from the photo into PHOTO1 as a custom pad: no net, no drill, and a `Pad` that is only its box.+pub fn is_photo_metal(p: &Pad) -> bool { p.part == "PHOTO1" && p.num.starts_with('M') && p.drill <= 0.0 && p.net.is_empty() }  /// Write the board. `layers` is the copper layer count (2, 4, 6). pub fn board(cache: &Path, parts: &[Value], copper: &Value, nets_doc: &Value, layers: usize, title: &str) -> Result<Board, String> { board_opt(cache, parts, copper, nets_doc, layers, title, true) }@@ -444,7 +450,8 @@ pub fn board_opt(cache: &Path, parts: &[Value], copper: &Value, nets_doc: &Value                 if let Some(doc) = &photo { crate::photolayers::strip_silk(&mut n); crate::photolayers::apply_pads(doc, &mut n, &mut ps, p, &mut photo_polys); }                 fps.push(n); pads.extend(ps);             }-            Err(e) => warnings.push(e),+            // said with the part's name, so `build` and `auto` can name the footprint that is wanted (main.rs writes LEFT_OUT from these)+            Err(e) => warnings.push(format!("{}{}: {e}", LEFT_OUT_MARK, p["ref"].as_str().unwrap_or("?"))),         }     }     let extras = photo.as_ref().and_then(crate::photolayers::extras_footprint);@@ -467,7 +474,7 @@ pub fn board_opt(cache: &Path, parts: &[Value], copper: &Value, nets_doc: &Value     let mut zones: Vec<Value> = copper["zones"].as_array().cloned().unwrap_or_default();     zones.sort_by(|a, b| fv(b, "priority").partial_cmp(&fv(a, "priority")).unwrap());     let photo_clearance = if std::env::var("ZONE_RULE_CLEARANCE").is_ok() { HashMap::new() } else { photo_gaps(copper, &pads, &shaped, ((bx1 - crate::board::ORIGIN) * crate::board::RES).ceil() as usize + 2, ((by1 - crate::board::ORIGIN) * crate::board::RES).ceil() as usize + 2) };-    let mut fills: Vec<(String, String, Mask)> = vec![];+    let mut fills: Vec<(String, String, Mask)> = vec![]; let mut zones_filled = 0usize; let mut no_fill: Vec<Value> = vec![];     let mut dilated: Vec<(String, String, Sparse)> = vec![];     let mut zone_nodes = vec![];     for z in &zones {@@ -488,7 +495,14 @@ pub fn board_opt(cache: &Path, parts: &[Value], copper: &Value, nets_doc: &Value         // only what is near this zone matters         let zb = { let (mut a, mut b, mut c, mut d) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN); for q in &poly { a = a.min(q.0); b = b.min(q.1); c = c.max(q.0); d = d.max(q.1); } (a - 2.0, b - 2.0, c + 2.0, d + 2.0) };         let nearby = |x: f64, y: f64| x >= zb.0 && x <= zb.2 && y >= zb.1 && y <= zb.3;+        let mut near_other: Vec<String> = vec![];         for (i, p) in pads.iter().enumerate().filter(|(_, p)| nearby(p.x, p.y)) { if p.on(&layer) {+            // Exposed metal no footprint explains (PHOTO1.M<n>, `photolayers::extras_footprint`) is known here only by the box round its+            // outline, which can be many times its area: on test board 4 an 11.4 x 9.4 mm box round one such patch wiped the whole of+            // isl-top-765 (8 mm2, a terminal's land and its track arm) out of its own fill, and `check mimic` then stopped the run on+            // "zones with no fill". It carries no net and is the same metal the photo shows, so a pour read from the photo is not cut by it.+            if photo && is_photo_metal(p) { continue; }+            if !(p.net == net && !net.is_empty()) { near_other.push(format!("{}.{}", p.part, p.num)); }             if p.net == net && !net.is_empty() { grid.pad(&mut same, p, 0.0); }             else if let Some(poly) = shaped.get(&i) { grid.poly(&mut other, poly); for s in 0..poly.len() { grid.seg(&mut other, poly[s], poly[(s + 1) % poly.len()], 2.0 * clr); } } // a photographed pad clears by its own shape             else { grid.pad(&mut other, p, clr); }@@ -518,6 +532,11 @@ pub fn board_opt(cache: &Path, parts: &[Value], copper: &Value, nets_doc: &Value         else if cs.len() > 1 { warnings.push(format!("zone {} ({net} on {layer}): no {net} copper inside it on this layer, so every island is kept", z["id"])); }         // one zone per connected piece: KiCad reads the first polygon of a zone as its outline and every later one as a hole in it         let pieces: Vec<(Vec<Vec<(f64, f64)>>, Vec<Vec<(f64, f64)>>)> = if photo { photo_pieces(&grid, z, &other).into_iter().map(|p| { let mut o = vec![p.outer]; o.extend(p.holes); (o, p.rings) }).collect() } else { vec![(vec![poly.clone()], vectorize(&grid, &fill))] };+        // a read pour that other-net copper and its clearance cover whole is drawn with its outline and no fill: said, with what covers it, not left for `check mimic` to find+        if photo && filled && !pieces.is_empty() && pieces.iter().all(|p| p.1.is_empty()) {+            near_other.sort(); near_other.dedup();+            no_fill.push(serde_json::json!({"id": z["id"], "layer": layer, "net": net, "areaMm2": z["areaMm2"], "why": if near_other.is_empty() { "every part of it lies inside copper of another net, or its clearance, on this layer".to_string() } else { format!("every part of it lies inside pads of another net, or of no net, and their clearance: {}{}", near_other.iter().take(6).cloned().collect::<Vec<_>>().join(", "), if near_other.len() > 6 { format!(" and {} more", near_other.len() - 6) } else { String::new() }) }}));+        }         for (k, (outline, rings)) in pieces.into_iter().enumerate() {             let id = z["id"].as_str().unwrap_or("").to_string(); let id = if k == 0 { id } else { format!("{id}#{k}") };             let mut zn = vec![a("zone"), l(vec![a("net"), a(net_idx.get(&net).copied().unwrap_or(0))]), l(vec![a("net_name"), q(&net)]), l(vec![a("layer"), q(&layer)]),@@ -535,7 +554,8 @@ pub fn board_opt(cache: &Path, parts: &[Value], copper: &Value, nets_doc: &Value             zone_nodes.push(l(zn));         }         dilated.push((layer.clone(), net.clone(), Sparse::of(&fill.dilate((clr * grid.res) as f32))));-        fills.push((layer, net, fill));+        zones_filled += 1;+        match fills.iter_mut().find(|x| x.0 == layer) { Some(x) => { for (a_, b_) in x.2.d.iter_mut().zip(fill.d.iter()) { *a_ |= *b_; } } None => fills.push((layer, String::new(), fill)) }     }     // the file     let mut out = String::from("(kicad_pcb\n\t(version 20241229)\n\t(generator \"adom-aiflow-reverse\")\n\t(generator_version \"9.0\")\n\t(general (thickness 1.6) (legacy_teardrops no))\n\t(paper \"A4\")\n");@@ -574,7 +594,7 @@ pub fn board_opt(cache: &Path, parts: &[Value], copper: &Value, nets_doc: &Value     }     for z in &zone_nodes { out += "\t"; out += &z.to_string(); out += "\n"; }     out += ")\n";-    Ok(Board { text: out, pads, fills, grid, warnings })+    Ok(Board { text: out, pads, fills, zones_filled, no_fill, grid, warnings }) }  /// A zone's copper: its exact runs when it was traced from a photo, otherwise everything inside its outline.@@ -604,6 +624,20 @@ pub fn board_inside(g: &Grid, outline: &[(f64, f64)], radius: f64) -> Mask {  pub fn cache_dir(flow: &Path) -> PathBuf { flow.join("lib").join("fp") } +/// A build warning that starts with this is a part the board does not carry, because its footprint did not load.+pub const LEFT_OUT_MARK: &str = "LEFT OUT of the board, ";+/// What the last build left out, written by `build` on every run (empty when nothing was): `{"leftOut": [{"ref", "footprint", "why"}]}`.+pub const LEFT_OUT: &str = "build-left-out.json";++/// The parts a build's warnings say it left out, as rows for `LEFT_OUT`.+pub fn left_out_rows(warnings: &[String], parts: &[Value]) -> Vec<Value> {+    warnings.iter().filter_map(|w| w.strip_prefix(LEFT_OUT_MARK)).filter_map(|w| w.split_once(": ")).map(|(r, why)| {+        let fp = parts.iter().find(|p| p["ref"].as_str() == Some(r)).and_then(|p| p["footprint"].as_str()).unwrap_or("");+        let file = fp.split_once(':').map(|(lib, name)| format!("; a file of your own goes at reverse-flow/lib/fp/{lib}/{name}.kicad_mod")).unwrap_or_default();+        serde_json::json!({"ref": r, "footprint": fp, "why": format!("its footprint {fp} did not load ({why}){file}")})+    }).collect()+}+ #[cfg(test)] mod flip_tests {     use super::*;
src/layerstate.rsadded+278
@@ -0,0 +1,278 @@+//! The layer count, stated without routing. `layers state` writes layers.json from what is known and nothing else:+//!+//! - a count from outside evidence, given with `--count` and either `--source` (a document) or `--evidence <file> --kind ...`+//!   (a measurement on the board itself: an edge micrograph, a cross-section, an X-ray or a CT scan);+//! - otherwise the count is `UNKNOWN`, with its test, and beside it the minimum the photos prove or suggest.+//!+//! Photos show two faces and never an inner layer, so they can only bound the count from below. Routing difficulty is no+//! evidence of a layer count either (see docs/method.md), which is why the default flow no longer routes to find one.+use crate::checks::{Outcome, Pass};+use crate::items;+use serde_json::{json, Value};+use std::path::Path;++/// A via counts as ending inside the board only when the other face shows no candidate of any kind this close to it, mm.+/// The via detectors place a dome within about 0.1 mm of its pair on the other face (`domes::pits_confirmed` uses 0.1 mm), so+/// three times that leaves no room for a pair that was read a little off. Policy, not fitted to a board.+const OTHER_FACE_CLEAR_MM: f64 = 0.3;+/// Fewer one-face vias than this prove nothing: a handful can be detector misses. Policy, not fitted to a board.+const ONE_FACE_MIN: usize = 10;+/// And the two faces must show at least this many vias in the same place, or the photos have not shown that a through via+/// appears on both. Policy, not fitted to a board.+const BOTH_FACES_MIN: usize = 10;++/// Kinds of outside evidence that are a measurement on the board itself. Anything else is a document.+pub const MEASURED_KINDS: [&str; 4] = ["edge-micrograph", "cross-section", "xray", "ct"];++fn st<'a>(v: &'a Value, k: &str) -> &'a str { v[k].as_str().unwrap_or("") }+fn xy(v: &Value) -> Option<(f64, f64)> { Some((v.get(0)?.as_f64()?, v.get(1)?.as_f64()?)) }++/// What the photos say about the layer count: counts, and the minimum that follows.+pub fn photo_evidence(flow: &Path) -> Value {+    let copper = items::read_json(&flow.join("copper.json")).unwrap_or(json!({}));+    let on = |layer: &str| copper["zones"].as_array().into_iter().flatten().chain(copper["traces"].as_array().into_iter().flatten()).any(|z| st(z, "layer") == layer);+    let faces = usize::from(on("F.Cu")) + usize::from(on("B.Cu"));+    let vias: Vec<&Value> = copper["vias"].as_array().into_iter().flatten().collect();+    let inner = vias.iter().filter(|v| st(v, "kind") == "inner").count();+    let both = vias.iter().filter(|v| st(v, "seen") == "both-faces").count();+    let hidden = vias.iter().filter(|v| st(v, "seen").contains("hidden")).count();+    // every candidate either detector looked at, kept or not, by face+    let mut cands: Vec<(String, f64, f64)> = vec![];+    for f in ["detect/dome-candidates.json", "detect/pit-candidates.json"] {+        if let Ok(d) = items::read_json(&flow.join(f)) { for c in d["candidates"].as_array().into_iter().flatten() { if let Some((x, y)) = xy(&c["pos_mm"]) { cands.push((st(c, "side").to_string(), x, y)); } } }+    }+    let have_cands = !cands.is_empty();+    let (mut one, mut clear) = (0usize, 0usize);+    for v in vias.iter().filter(|v| matches!(st(v, "seen"), "top" | "bottom")) {+        one += 1;+        let Some((x, y)) = xy(&v["pos_mm"]) else { continue };+        let other = if st(v, "seen") == "top" { "bottom" } else { "top" };+        if have_cands && !cands.iter().any(|c| c.0 == other && (c.1 - x).abs() <= OTHER_FACE_CLEAR_MM && (c.2 - y).abs() <= OTHER_FACE_CLEAR_MM) { clear += 1; }+    }+    let (layers, tag, why) = if inner > 0 {+        (4, "MEASURED", format!("{inner} via(s) are ringed on both faces with no copper leaving on either, so they continue on an inner layer: at least 4 layers"))+    } else if clear >= ONE_FACE_MIN && both >= BOTH_FACES_MIN {+        (4, "INFERRED", format!("{clear} vias show on one face while the other face is in view there and shows no via candidate within {OTHER_FACE_CLEAR_MM} mm, and {both} other vias show on both faces, so the photos do show through vias: a via that ends inside the board needs an inner layer, so more than 2 layers (4 in practice) is likely. Inferred, not proven: a via plugged or tented flat on one face reads the same"))+    } else if faces >= 2 {+        (2, "SEEN", "copper was read on both faces; nothing in the photos shows a via ending inside the board".to_string())+    } else {+        (1, "SEEN", "copper was read on one face only: the other face was not photographed or shows none".to_string())+    };+    json!({"facesWithCopper": faces, "vias": vias.len(), "viasOnBothFaces": both, "viasWithTheOtherFaceHidden": hidden, "viasOnOneFaceOnly": one, "viasOnOneFaceOtherFaceClear": clear, "innerVias": inner,+        "rule": {"otherFaceClearMm": OTHER_FACE_CLEAR_MM, "oneFaceMin": ONE_FACE_MIN, "bothFacesMin": BOTH_FACES_MIN},+        "minimum": {"layers": layers, "tag": tag, "why": why}})+}++/// An outside file, by hash. It must not be one of the run's own photos or products.+fn outside_file(flow: &Path, path: &str) -> Result<Value, String> {+    let p = items::resolve(flow, path).ok_or(format!("evidence file {path} is not there"))?;+    let full = std::fs::canonicalize(&p).map_err(|e| e.to_string())?;+    for d in ["photos", "board", "evidence", "audits", "detect"] {+        if let Ok(x) = std::fs::canonicalize(flow.join(d)) { if full.starts_with(x) { return Err(format!("{path} is this run's own {d}: outside evidence means an edge micrograph, a cross-section, an X-ray, a CT scan or a document")); } }+    }+    Ok(json!({"file": full.display().to_string(), "sha256": crate::sha256(&full)}))+}++/// What `layers state` was asked: a count with where it comes from, or nothing.+pub struct Ask<'a> { pub count: Option<usize>, pub source: Option<&'a str>, pub evidence: Option<&'a str>, pub kind: Option<&'a str> }++/// Write layers.json as a statement. With no count asked, a count the file already holds from outside evidence is kept; a+/// count that came from routing is not, and the routing record moves under `routing`.+pub fn state(flow: &Path, ask: &Ask) -> Result<(Value, Vec<String>), String> {+    let old = items::read_json(&flow.join("layers.json")).unwrap_or(json!({}));+    let ev = photo_evidence(flow);+    let min = ev["minimum"].clone();+    let min_n = min["layers"].as_u64().unwrap_or(2) as usize;+    let mut hints = vec![format!("photos: {}", st(&min, "why"))];+    // what is stated: asked now, or kept from an earlier statement that rests on outside evidence+    let kept = old["proposed"].as_u64().filter(|_| matches!(st(&old, "tag"), "DATASHEET" | "MEASURED") && (!st(&old, "source").is_empty() || old["evidenceFile"].is_object()));+    let mut doc = json!({"method": "stated", "tests": ["T-layers"], "planes": [], "photoEvidence": ev, "minimum": min});+    match (ask.count, kept) {+        (Some(0), _) | (None, None) => {+            doc["proposed"] = Value::Null; doc["tag"] = json!("UNKNOWN");+            doc["why"] = json!(format!("The layer count is unknown: photos show two faces and never an inner layer, and no outside evidence was given. From the photos: {}. T-layers settles it: count the copper layers on the board edge under a microscope, or X-ray the board, then `layers state --count <n> --evidence <file> --kind edge-micrograph`", st(&min, "why")));+            hints.push("layer count UNKNOWN, with test T-layers. Outside evidence states it: `layers state --count <n> --source \"<document>\"` or `--evidence <file> --kind edge-micrograph|cross-section|xray|ct`".into());+        }+        (Some(n), _) => {+            if n % 2 == 1 && n > 1 { hints.push(format!("{n} is an odd layer count: unusual, check the source")); }+            if n < min_n && st(&min, "tag") != "INFERRED" { return Err(format!("{n} layers stated, but the photos prove at least {min_n}: {}", st(&min, "why"))); }+            if n < min_n { hints.push(format!("{n} layers stated, but the photos suggest at least {min_n}: {}", st(&min, "why"))); }+            let file = match ask.evidence { Some(p) => Some(outside_file(flow, p)?), None => None };+            let kind = ask.kind.unwrap_or(if file.is_some() { "" } else { "document" });+            let measured = MEASURED_KINDS.contains(&kind);+            if measured && file.is_none() { return Err(format!("--kind {kind} is a measurement on the board: attach it with --evidence <file>")); }+            if file.is_some() && kind.is_empty() { return Err(format!("--evidence needs --kind: {} (a measurement on the board) or document", MEASURED_KINDS.join(", "))); }+            let source = ask.source.unwrap_or("").trim();+            if !measured && source.is_empty() { return Err("a count from a document needs --source \"<document, where in it, when fetched>\"".into()); }+            doc["proposed"] = json!(n); doc["tag"] = json!(if measured { "MEASURED" } else { "DATASHEET" });+            doc["source"] = json!(if source.is_empty() { format!("{kind} of this board") } else { source.to_string() });+            if let Some(f) = &file { doc["evidenceFile"] = json!({"file": f["file"], "sha256": f["sha256"], "kind": kind}); doc["evidence"] = json!([f["file"]]); }+            doc["why"] = json!(format!("{n} layers, stated from outside evidence ({}), not from the photos and not from routing. From the photos: {}", st(&doc, "source"), st(&min, "why")));+            hints.push(format!("{n} layers stated, tag {}. The layers score stays capped until an independent verdict with that evidence attached: `audit draw --category layers`, then `audit verdict --category layers ... --evidence <file> --kind <kind>`", st(&doc, "tag")));+        }+        (None, Some(n)) => {+            for k in ["proposed", "tag", "source", "evidenceFile", "evidence"] { if !old[k].is_null() { doc[k] = old[k].clone(); } }+            doc["why"] = json!(format!("{n} layers, stated from outside evidence ({}), not from the photos and not from routing. From the photos: {}", st(&old, "source"), st(&min, "why")));+            hints.push(format!("kept the count already stated from outside evidence: {n} layers ({}). `layers state --count 0` withdraws it", st(&old, "tag")));+        }+    }+    // what an earlier optional routing recorded is kept, out of the statement's way+    let had = old["candidates"].as_array().map(|a| !a.is_empty()).unwrap_or(false);+    doc["routing"] = if had { json!({"ran": true, "note": "an earlier optional `connect route`; its stackups are kept here and no longer set the count", "candidates": old["candidates"], "trail": old["trail"], "escalation": old["escalation"], "said": if old["routingSaid"].is_null() { old["why"].clone() } else { old["routingSaid"].clone() }}) }+        else if old["routing"]["ran"] == json!(true) { old["routing"].clone() }+        else { json!({"ran": false, "note": "hidden connections are left unrouted (unrouted.json): `connect route` is optional and proves no layer count"}) };+    std::fs::write(flow.join("layers.json"), serde_json::to_string_pretty(&doc).map_err(|e| e.to_string())? + "\n").map_err(|e| e.to_string())?;+    Ok((doc, hints))+}++/// Whether layers.json is a statement that still matches the photos' evidence.+pub fn current(flow: &Path) -> bool {+    let Ok(d) = items::read_json(&flow.join("layers.json")) else { return false };+    d["method"] == json!("stated") && d["photoEvidence"] == photo_evidence(flow)+}++/// `check layers` on a statement: honest about what is known, and nothing typed past it.+pub fn check(flow: &Path, d: &Value, pcb: Option<&Path>) -> Outcome {+    let mut errs = vec![];+    for it in items::items_of("layers.json", d) { items::validate(flow, &it, &mut errs); }+    let ev = photo_evidence(flow);+    if d["photoEvidence"] != ev { errs.push("the photos' evidence in layers.json is not what copper.json gives now: run `layers state` again (it keeps a count stated from outside evidence)".into()); }+    let min_n = ev["minimum"]["layers"].as_u64().unwrap_or(2);+    if st(d, "why").is_empty() { errs.push("`why` missing: run `layers state`".into()); }+    match d["proposed"].as_u64() {+        None => {+            if st(d, "tag") != "UNKNOWN" { errs.push(format!("no layer count is stated, so the tag must be UNKNOWN, not {:?}: run `layers state`", st(d, "tag"))); }+            if d["tests"].as_array().map(|a| a.is_empty()).unwrap_or(true) { errs.push("an unknown layer count needs its test (T-layers): run `layers state`".into()); }+        }+        Some(n) => {+            match st(d, "tag") {+                "DATASHEET" => if st(d, "source").is_empty() { errs.push(format!("{n} layers tagged DATASHEET with no source: `layers state --count {n} --source \"<document>\"`")); },+                "MEASURED" => match d["evidenceFile"]["file"].as_str() {+                    Some(f) if crate::sha256(Path::new(f)) == st(&d["evidenceFile"], "sha256") => {}+                    Some(f) => errs.push(format!("the evidence file {f} is missing or changed since it was stated: `layers state --count {n} --evidence <file> --kind <kind>` again")),+                    None => errs.push(format!("{n} layers tagged MEASURED with no evidence file: `layers state --count {n} --evidence <file> --kind edge-micrograph|cross-section|xray|ct`")),+                },+                t => errs.push(format!("{n} layers stated with tag {t:?}: without routing a count comes from outside evidence only (DATASHEET or MEASURED). `layers state --count {n} --source ...`, or `layers state --count 0` for UNKNOWN")),+            }+            if n < min_n && st(&ev["minimum"], "tag") != "INFERRED" { errs.push(format!("{n} layers stated, but the photos prove at least {min_n}: {}", st(&ev["minimum"], "why"))); }+            if let Some(p) = pcb {+                match std::fs::read_to_string(p) {+                    Ok(t) => { let have = crate::kicad_in::copper_layers(&t); if have as u64 != n { errs.push(format!("{} has {have} copper layers, {n} stated: run `build` (it takes the stated count) and check again", p.display())); } }+                    Err(e) => errs.push(format!("{}: {e}", p.display())),+                }+            }+        }+    }+    if !errs.is_empty() { return Err(errs); }+    let nets: Vec<Value> = items::read_json(&flow.join("nets.json")).ok().and_then(|n| n["nets"].as_array().cloned()).unwrap_or_default();+    let hidden = crate::unrouted::count(&nets);+    let mut hints = vec![format!("photos: {}", st(&ev["minimum"], "why"))];+    hints.push(match d["proposed"].as_u64() { Some(n) => format!("{n} layers stated ({}): {}", st(d, "tag"), st(d, "source")), None => "layer count UNKNOWN, settled by T-layers".to_string() });+    hints.push(format!("{hidden} hidden connection(s) left unrouted as ratsnest, each with its evidence tag: unrouted.json. `connect route` is optional"));+    Ok(Pass { summary: json!({"proposed": d["proposed"], "tag": d["tag"], "minimum": ev["minimum"], "routed": false, "hiddenConnections": hidden}), hints, files: vec!["layers.json".into(), "nets.json".into(), "copper.json".into()] })+}++#[cfg(test)]+mod tests {+    use super::*;++    fn dir(name: &str) -> std::path::PathBuf {+        let d = std::env::temp_dir().join(format!("layerstate-{name}-{}", std::process::id()));+        let _ = std::fs::remove_dir_all(&d);+        std::fs::create_dir_all(d.join("detect")).unwrap();+        d+    }+    fn copper(d: &Path, vias: Value) {+        std::fs::write(d.join("copper.json"), json!({"outline": {"points_mm": [[100, 100], [140, 100], [140, 140]], "tag": "MEASURED", "evidence": ["copper.json"]},+            "zones": [{"id": "a", "layer": "F.Cu", "net": "GND"}, {"id": "b", "layer": "B.Cu", "net": "GND"}], "traces": [], "vias": vias}).to_string()).unwrap();+        std::fs::write(d.join("nets.json"), json!({"nets": [{"name": "GND", "pins": ["R1.1", "R2.1"], "copperGroups": []}]}).to_string()).unwrap();+    }++    #[test]+    fn with_nothing_given_the_count_is_unknown_with_its_test_and_the_check_passes() {+        let d = dir("unknown");+        copper(&d, json!([{"id": "v1", "pos_mm": [110, 110], "seen": "both-faces", "kind": "seen"}]));+        let (doc, _) = state(&d, &Ask { count: None, source: None, evidence: None, kind: None }).unwrap();+        assert!(doc["proposed"].is_null());+        assert_eq!(doc["tag"], "UNKNOWN");+        assert_eq!(doc["tests"], json!(["T-layers"]));+        assert_eq!(doc["minimum"]["layers"], 2);+        assert_eq!(doc["minimum"]["tag"], "SEEN");+        let pass = check(&d, &doc, None).unwrap();+        assert_eq!(pass.summary["routed"], json!(false));+        assert_eq!(pass.summary["hiddenConnections"], json!(1));+        // the layers item is open, so a test is generated for it+        assert!(items::items_of("layers.json", &doc).iter().any(|i| i.id == "layers" && i.open()));+        std::fs::remove_dir_all(&d).ok();+    }++    #[test]+    fn a_count_needs_a_document_or_a_measurement_and_cannot_go_under_what_the_photos_prove() {+        let d = dir("count");+        copper(&d, json!([{"id": "v1", "pos_mm": [110, 110], "seen": "both-faces", "kind": "inner"}]));+        assert!(state(&d, &Ask { count: Some(6), source: None, evidence: None, kind: None }).is_err());+        assert!(state(&d, &Ask { count: Some(6), source: None, evidence: None, kind: Some("xray") }).is_err());+        assert!(state(&d, &Ask { count: Some(2), source: Some("maker's page"), evidence: None, kind: None }).err().unwrap().contains("at least 4"));+        let (doc, _) = state(&d, &Ask { count: Some(6), source: Some("maker's page, fetched today"), evidence: None, kind: None }).unwrap();+        assert_eq!(doc["tag"], "DATASHEET");+        assert_eq!(doc["minimum"]["layers"], 4);+        assert!(check(&d, &doc, None).is_ok());+        // stated again with nothing asked, the count from outside evidence is kept; `--count 0` withdraws it+        let (again, _) = state(&d, &Ask { count: None, source: None, evidence: None, kind: None }).unwrap();+        assert_eq!(again["proposed"], 6);+        let (gone, _) = state(&d, &Ask { count: Some(0), source: None, evidence: None, kind: None }).unwrap();+        assert!(gone["proposed"].is_null());+        // a micrograph of the board is a measurement, held by hash+        std::fs::write(d.join("edge.jpg"), "x").unwrap();+        let (m, _) = state(&d, &Ask { count: Some(6), source: None, evidence: Some("edge.jpg"), kind: Some("edge-micrograph") }).unwrap();+        assert_eq!(m["tag"], "MEASURED");+        assert!(check(&d, &m, None).is_ok());+        std::fs::write(d.join("edge.jpg"), "changed").unwrap();+        assert!(check(&d, &m, None).is_err());+        std::fs::remove_dir_all(&d).ok();+    }++    #[test]+    fn a_count_from_routing_is_not_kept_and_a_typed_one_fails_the_check() {+        let d = dir("routed");+        copper(&d, json!([]));+        std::fs::write(d.join("layers.json"), json!({"proposed": 6, "tag": "INFERRED", "why": "routing", "tests": ["T-layers"], "candidates": [{"layers": 6, "open": 3, "run": "routes/L6"}]}).to_string()).unwrap();+        let (doc, _) = state(&d, &Ask { count: None, source: None, evidence: None, kind: None }).unwrap();+        assert!(doc["proposed"].is_null());+        assert_eq!(doc["routing"]["ran"], json!(true));+        assert_eq!(doc["routing"]["candidates"][0]["layers"], 6);+        assert!(doc["candidates"].is_null());+        let mut typed = doc.clone(); typed["proposed"] = json!(4); typed["tag"] = json!("INFERRED");+        assert!(check(&d, &typed, None).is_err());+        // and evidence that changed under the statement asks for a new one+        copper(&d, json!([{"id": "v1", "pos_mm": [110, 110], "seen": "both-faces", "kind": "inner"}]));+        assert!(!current(&d));+        assert!(check(&d, &doc, None).err().unwrap()[0].contains("layers state"));+        std::fs::remove_dir_all(&d).ok();+    }++    #[test]+    fn vias_that_end_on_one_face_suggest_inner_layers_only_when_the_other_face_is_clear() {+        let d = dir("oneface");+        let mut vias: Vec<Value> = (0..12).map(|i| json!({"id": format!("b{i}"), "pos_mm": [101.0 + i as f64, 101.0], "seen": "both-faces", "kind": "seen"})).collect();+        vias.extend((0..12).map(|i| json!({"id": format!("t{i}"), "pos_mm": [101.0 + i as f64, 110.0], "seen": "top", "kind": "seen"})));+        vias.extend((0..5).map(|i| json!({"id": format!("h{i}"), "pos_mm": [101.0 + i as f64, 120.0], "seen": "bottom, top hidden by a part", "kind": "seen"})));+        copper(&d, json!(vias));+        // no candidate list at all: nothing can be called clear+        assert_eq!(photo_evidence(&d)["minimum"]["layers"], 2);+        // a rejected candidate on the bottom under every one-face via: the other face is not clear+        let under: Vec<Value> = (0..12).map(|i| json!({"pos_mm": [101.1 + i as f64, 110.1], "side": "bottom"})).collect();+        std::fs::write(d.join("detect/dome-candidates.json"), json!({"candidates": under}).to_string()).unwrap();+        assert_eq!(photo_evidence(&d)["viasOnOneFaceOtherFaceClear"], 0);+        assert_eq!(photo_evidence(&d)["minimum"]["layers"], 2);+        // candidates elsewhere only: the bottom is clear under them, and the minimum is inferred, never measured+        let away: Vec<Value> = (0..12).map(|i| json!({"pos_mm": [101.0 + i as f64, 130.0], "side": "bottom"})).collect();+        std::fs::write(d.join("detect/dome-candidates.json"), json!({"candidates": away}).to_string()).unwrap();+        let ev = photo_evidence(&d);+        assert_eq!(ev["viasOnOneFaceOtherFaceClear"], 12);+        assert_eq!(ev["viasWithTheOtherFaceHidden"], 5);+        assert_eq!(ev["minimum"]["layers"], 4);+        assert_eq!(ev["minimum"]["tag"], "INFERRED");+        std::fs::remove_dir_all(&d).ok();+    }+}
src/locallevels.rsadded+218
@@ -0,0 +1,218 @@+//! The copper and laminate levels judged place by place against the face's own step edges, and grain kept out of those edges.+//!+//! `edgelevels` tests a pair of levels against the face's copper edges over the face as a whole, and replaces them only when over half of all edges lie wholly in+//! one class. A stitched face of a larger board is two faces in one: on test board 1's bottom (2026-11-10) the left half is laminate with thin tracks and the right+//! half is wide pours. The two-class fit put the copper level on the laminate over the left half, the pours on the right straddled their levels as they should, and+//! the face as a whole passed. One island then held 117 of the 148 pins joined on that face. The same was written down for the hub board's bottom right corner.+//!+//! Two things here, both decided by the face's own evidence:+//!+//! - `without_grain`: on a stitched face the dim, soft ends of a tile carry sensor grain whose crests join into one network, so the test for a run of crest pixels+//!   keeps them, and there are more of them than copper edges. The step that occurs most often was then the grain's (0.06 on board 1's bottom, where copper over+//!   laminate steps 0.15 to 0.35). A copper edge goes on in line: its crest is found again a little further along its own direction, on both sides, with the gradient+//!   facing the same way. When the commonest step among such edges is not the commonest step among all of them, the straight ones are the face's copper edges.+//! - `correct`: the shares of `edgelevels::agreement`, counted in a window round every place. Where a majority of the edges near a place lie wholly in one class,+//!   the levels there are taken from those edges; where the edges straddle the levels, nothing changes.+use crate::edgelevels::StepEdges;+use crate::raster::{blur, Plane};++/// How far along its own direction a crest must be found again, each way, frame pixels: 6 px, 0.15 mm at 40 px/mm. Not fitted: longer than the 1 to 3 px over which+/// grain keeps a direction, shorter than the straight part of any track the frame resolves.+const ALONG_PX: f64 = 6.0;+/// The gradient at the crest found again must face the same way: within 26 degrees (cosine 0.9). Not fitted. A track bends by 45 degrees at a corner, and the crest+/// either side of the corner is then dropped, which costs a few samples per corner.+const SAME_WAY: f32 = 0.9;+/// The bins of `edgelevels`' own histogram of steps.+const BIN: f32 = 0.025;+/// Window of the local test, half width in frame pixels: 6 mm at 40 px/mm, the middle of the three windows `edgelevels::levels` averages over.+const WINDOW_PX: usize = 240;+/// Fewer in-band edge samples than this in the window and the place is left alone: the count `edgelevels` asks of a window before it trusts its mean.+const MIN_SAMPLES: f32 = 60.0;+/// A majority of the edges near a place wholly in one class: the same line `edgelevels::Agreement::levels_are_wrong` draws for a face.+const WRONG_SHARE: f32 = 0.5;++/// The step that occurs most often, as `edgelevels::step_edges` finds it.+pub fn modal(all: &[(u32, u32, f32, f32)]) -> Option<f32> {+    let mut hist = [0f32; 40];+    for e in all { let s = e.2 - e.3; if s > 0.0 && s < 1.0 { hist[(s / BIN) as usize] += 1.0; } }+    let sm: Vec<f32> = (0..40).map(|i| { let (a, b) = (i.max(1) - 1, (i + 2).min(40)); hist[a..b].iter().sum::<f32>() }).collect();+    let peak = (0..40).max_by(|a, b| sm[*a].partial_cmp(&sm[*b]).unwrap())?;+    Some((peak as f32 + 0.5) * BIN)+}++/// The edges that go on in line, and whether each of `all` is one. `gx`, `gy`: the gradient the crests were found on.+pub fn straight(all: &[(u32, u32, f32, f32)], gx: &Plane, gy: &Plane) -> Vec<bool> {+    let (w, h) = (gx.w, gx.h);+    let mut at = vec![false; w * h]; for e in all { at[e.1 as usize * w + e.0 as usize] = true; }+    let unit = |i: usize| -> (f32, f32) { let g = (gx.d[i] * gx.d[i] + gy.d[i] * gy.d[i]).sqrt().max(1e-9); (gx.d[i] / g, gy.d[i] / g) };+    all.iter().map(|e| {+        let i = e.1 as usize * w + e.0 as usize; let (ux, uy) = unit(i);+        [1.0f64, -1.0].iter().all(|s| {+            let (px, py) = ((e.0 as f64 - s * uy as f64 * ALONG_PX).round() as i64, (e.1 as f64 + s * ux as f64 * ALONG_PX).round() as i64);+            (-1i64..=1).any(|dy| (-1i64..=1).any(|dx| { let (x, y) = (px + dx, py + dy); if x < 0 || y < 0 || x >= w as i64 || y >= h as i64 { return false; } let j = y as usize * w + x as usize; at[j] && { let (vx, vy) = unit(j); ux * vx + uy * vy > SAME_WAY } }))+        })+    }).collect()+}++/// `all` with its commonest step `modal_all`, or only its straight edges with theirs when the two steps differ by more than one bin: then the commonest step was grain's.+/// A face whose two steps agree keeps every edge, exactly as before this test existed.+pub fn without_grain(all: Vec<(u32, u32, f32, f32)>, modal_all: f32, gx: &Plane, gy: &Plane) -> (Vec<(u32, u32, f32, f32)>, f32, bool) {+    let keep = straight(&all, gx, gy);+    let line: Vec<(u32, u32, f32, f32)> = all.iter().zip(&keep).filter(|x| *x.1).map(|x| *x.0).collect();+    if line.len() < 500 { return (all, modal_all, false); }+    match modal(&line) { Some(m) if (m - modal_all).abs() > BIN * 1.01 => (line, m, true), _ => (all, modal_all, false) }+}++/// How far each side of an edge is looked at to say which side a place lies on, frame pixels: inside the 2 to 7 px `edgelevels` samples a step over.+const SIDE_REACH: f32 = 5.0;+/// The window a level is averaged over once every place gives a sample, half width in frame pixels: 1.5 mm at 40 px/mm. Wide enough that a track the edge finder+/// missed (0.15 mm) does not carry the laminate level up with it, narrow against the 8 mm tiles `trace::light_spread` measures the light over. Not fitted.+const SIDE_WINDOW: usize = 60;+/// A place counts as across an edge, and not along it, when the line to it stands at least 30 degrees off the edge's own line (the cosine to the direction of rise is 0.5 or more). Not fitted.+const ACROSS_MIN: f32 = 0.5;+/// How far from a hole, a part or exposed metal an edge must lie to say which side of it is copper, frame pixels: 0.5 mm at 40 px/mm. The shadow rim round the solder joints+/// of test board 1's bottom is 5 to 10 px wide and its outer edge blurs over 4 more; at 12 px the rims still passed. From that photo.+const SOLID_CLEAR_PX: f32 = 20.0;+/// A window needs this share of its pixels seen to give a level; under it the level is the edges' own window mean.+const SIDE_MIN: f32 = 0.02;++/// The two levels over the face with every seen place giving a sample, for a face whose light moves.+///+/// `edgelevels::levels` averages the two sides of the edges over windows of 3 to 12 mm. Under a light with a hot spot that is too coarse: on test board 1's bottom the bare+/// laminate under the hot spot, 5 mm from the nearest edge, is brighter than the copper level averaged from the edges around it, so it read as copper and joined three+/// pours. But the edges say more than two numbers each: every place lies on one side of its nearest step edge, the bright side or the dark side, and nothing lies between+/// a place and its nearest edge. So the place is of that side's class, and its own brightness is a sample of that class's level right where it is. The levels are those+/// samples, each moved half the edges' step towards the middle, averaged over 1.5 mm, which follows the light as closely as the photo shows it. Where the edge finder missed a boundary the places beyond it take the wrong+/// side; that is the same error as before, no worse.+///+/// `solid`: holes, parts, exposed metal and wide print. An edge within `SOLID_CLEAR_PX` of one does not say which side is copper: a solder joint and a hole sit in a dark rim of shadow,+/// whose outer edge has bare laminate on its bright side (test board 1's bottom: the laminate inside a ring of header pins took the pins' rims for copper edges and read as a pour).+pub fn levels_by_side(e: &StepEdges, lg: &Plane, unseen: &crate::raster::Mask, solid: &crate::raster::Mask) -> (Plane, Plane) {+    let (w, h) = (e.w, e.h);+    let (ec, el) = crate::edgelevels::levels(e);+    if e.at.is_empty() { return (ec, el); }+    // the nearest edge sample to every pixel (two-pass chamfer sweep)+    let mut d = vec![f32::MAX; w * h]; let mut ix = vec![u32::MAX; w * h];+    let beside = solid.dilate(SOLID_CLEAR_PX);+    for (k, s) in e.at.iter().enumerate() { let i = s.1 as usize * w + s.0 as usize; if beside.d[i] { continue; } d[i] = 0.0; ix[i] = k as u32; }+    let relax = |i: usize, j: usize, cost: f32, d: &mut Vec<f32>, ix: &mut Vec<u32>| { if d[j] + cost < d[i] { d[i] = d[j] + cost; ix[i] = ix[j]; } };+    for y in 0..h { for x in 0..w { let i = y * w + x;+        if x > 0 { relax(i, i - 1, 3.0, &mut d, &mut ix); }+        if y > 0 { relax(i, i - w, 3.0, &mut d, &mut ix); if x > 0 { relax(i, i - w - 1, 4.0, &mut d, &mut ix); } if x + 1 < w { relax(i, i - w + 1, 4.0, &mut d, &mut ix); } } } }+    for y in (0..h).rev() { for x in (0..w).rev() { let i = y * w + x;+        if x + 1 < w { relax(i, i + 1, 3.0, &mut d, &mut ix); }+        if y + 1 < h { relax(i, i + w, 3.0, &mut d, &mut ix); if x + 1 < w { relax(i, i + w + 1, 4.0, &mut d, &mut ix); } if x > 0 { relax(i, i + w - 1, 4.0, &mut d, &mut ix); } } } }+    // Each place gives one sample of the level half way between the two: its own brightness less half the step when it lies on the bright side of its nearest edge, plus half the step+    // on the dark side. The step itself is the edges' own (`edgelevels::levels`). Averaging both sides into one mid level, and not each side into its own, is what keeps a false edge+    // harmless: a scratch across bare laminate puts a few places on the "bright side" with the laminate's brightness, and they move the mid level by their share of the window,+    // where a level of their own would have sat on the laminate and split its grain in two.+    let floor = 0.5 * e.modal;+    let at = |x: f32, y: f32| -> f32 { lg.d[(y.round().clamp(0.0, h as f32 - 1.0) as usize) * w + x.round().clamp(0.0, w as f32 - 1.0) as usize] };+    let (mut sm, mut wm) = (Plane::new(w, h, 0.0), Plane::new(w, h, 0.0));+    // the direction brightness rises in across each edge sample, a unit vector, from the smoothed brightness `SIDE_REACH` either way+    let rise: Vec<(f32, f32)> = e.at.iter().map(|s| { let (x, y) = (s.0 as f32, s.1 as f32); let (a, b) = (at(x + SIDE_REACH, y) - at(x - SIDE_REACH, y), at(x, y + SIDE_REACH) - at(x, y - SIDE_REACH)); let n = (a * a + b * b).sqrt().max(1e-9); (a / n, b / n) }).collect();+    for y in 0..h { for x in 0..w { let i = y * w + x; if unseen.d[i] || ix[i] == u32::MAX { continue; }+        let s = e.at[ix[i] as usize]; let (dx, dy) = (x as f32 - s.0 as f32, y as f32 - s.1 as f32); let n = (dx * dx + dy * dy).sqrt();+        // on the edge itself a place is of neither side+        if n < 2.0 { continue; }+        // Which side: by the edge's own direction of rise. A place that lies along the edge's line and not across it, past the end of a broken edge, is of neither side: the+        // nearest sample to such a place is the edge's end, and what lies beyond an end the edge does not say (first tried without this: fans of bare laminate off the ends of+        // faint edges read as copper on test board 1's bottom).+        let (gx, gy) = rise[ix[i] as usize]; let across = (dx * gx + dy * gy) / n;+        if across.abs() < ACROSS_MIN { continue; }+        let half = (ec.d[i] - el.d[i]).max(floor) / 2.0;+        sm.d[i] = if across > 0.0 { lg.d[i] - half } else { lg.d[i] + half }; wm.d[i] = 1.0; } }+    if let Ok(dir) = std::env::var("TRACE_DUMP") { let im = image::RgbImage::from_fn(w as u32, h as u32, |x, y| { let i = y as usize * w + x as usize; if d[i] == 0.0 { image::Rgb([255, 255, 255]) } else if wm.d[i] == 0.0 { image::Rgb([0, 0, 0]) } else if sm.d[i] < lg.d[i] { image::Rgb([230, 150, 40]) } else { image::Rgb([30, 90, 60]) } }); let _ = im.save(format!("{dir}/sides.png")); }+    let (bm, bw) = (blur(&sm, SIDE_WINDOW, 2), blur(&wm, SIDE_WINDOW, 2));+    let mut mid = Plane::new(w, h, 0.0);+    for i in 0..w * h { mid.d[i] = if bw.d[i] > SIDE_MIN { bm.d[i] / bw.d[i] } else { (ec.d[i] + el.d[i]) / 2.0 }; }+    let mid = blur(&mid, 20, 2);+    let (mut cop, mut lam) = (ec.clone(), el.clone());+    for i in 0..w * h { let half = (ec.d[i] - el.d[i]).max(floor) / 2.0; cop.d[i] = mid.d[i] + half; lam.d[i] = mid.d[i] - half; }+    (cop, lam)+}++/// The share of a face's edges that must straddle the levels averaged from them before the side of an edge is used. From four stitched faces and one whole-board photo, 2026-11-11: 0.94 and+/// 0.80 on test board 1 (bottom, top), 0.89 on board 4's bottom and 0.92 on the hub board's top, where levels by side read as well or better by eye; 0.56 on board 4's top, a face of+/// pours under print, parts and lint, where they turned 4 of 13 labelled places of pour into laminate. The line is set between, nearer the faces that work.+pub const SIDES_NEED: f32 = 0.75;++pub struct Local { pub cop: Plane, pub lam: Plane, /// the share of the face whose levels were taken from its edges+    pub share: f32 }++/// The levels with every place whose own edges contradict them taken from those edges. None when no place does.+pub fn correct(e: &StepEdges, cop: &Plane, lam: &Plane, at: f32, from_edges: &dyn Fn() -> (Plane, Plane)) -> Option<Local> {+    let (w, h) = (e.w, e.h);+    let (mut one, mut n) = (Plane::new(w, h, 0.0), Plane::new(w, h, 0.0));+    let (mut up, mut dn) = (Plane::new(w, h, 0.0), Plane::new(w, h, 0.0));+    for (x, y, hi, lo) in &e.at { let i = *y as usize * w + *x as usize; let (c, l) = (cop.d[i], lam.d[i]); let d = (c - l).max(0.02);+        let (vh, vl) = ((hi - l) / d, (lo - l) / d);+        n.d[i] += 1.0; if vl >= at { up.d[i] += 1.0; one.d[i] += 1.0; } else if vh <= at { dn.d[i] += 1.0; one.d[i] += 1.0; } }+    let _ = one;+    let (bu, bd, bn) = (blur(&up, WINDOW_PX, 2), blur(&dn, WINDOW_PX, 2), blur(&n, WINDOW_PX, 2));+    let area = ((2 * WINDOW_PX + 1) * (2 * WINDOW_PX + 1)) as f32;+    let wrong = Plane { w, h, d: (0..w * h).map(|i| if bn.d[i] * area >= MIN_SAMPLES && bu.d[i].max(bd.d[i]) > WRONG_SHARE * bn.d[i] { 1.0 } else { 0.0 }).collect() };+    let hit = wrong.d.iter().filter(|v| **v > 0.0).count();+    if hit == 0 { return None; }+    // the hand-over from one pair of levels to the other must not step: half a window of blend+    let wt = blur(&wrong, WINDOW_PX / 2, 2);+    let (ec, el) = from_edges();+    let (mut c2, mut l2) = (cop.clone(), lam.clone());+    for i in 0..w * h { let t = wt.d[i]; if t > 0.0 { c2.d[i] = t * ec.d[i] + (1.0 - t) * cop.d[i]; l2.d[i] = t * el.d[i] + (1.0 - t) * lam.d[i]; } }+    Some(Local { cop: c2, lam: l2, share: hit as f32 / (w * h) as f32 })+}++#[cfg(test)]+mod tests {+    use super::*;+    use crate::raster::Mask;+    /// The left half laminate at 100 with thin tracks at 130, the right half pour at 130 with lanes at 100.+    fn two_halves() -> Plane {+        let (w, h) = (1600usize, 700usize);+        let mut p = Plane::new(w, h, 0.0);+        for y in 0..h { for x in 0..w { let track = y % 60 < 8; let v = if x < w / 2 { if track { 130.0 } else { 100.0 } } else if track { 100.0 } else { 130.0 }; let n = ((x * 7919) ^ (y * 104729)) % 5; p.d[y * w + x] = v + n as f32; } }+        p+    }+    #[test]+    fn levels_wrong_over_half_a_face_are_put_right_there_and_left_alone_elsewhere() {+        let p = two_halves(); let e = crate::edgelevels::step_edges(&p, &Mask::new(p.w, p.h)).unwrap();+        // what the fit did on board 1's bottom: right for the pours, and the copper level on the laminate over most of the half with thin tracks+        let (mut c, mut l) = (Plane::new(p.w, p.h, (130.0f32).ln()), Plane::new(p.w, p.h, (100.0f32).ln()));+        for y in 0..p.h { for x in 0..p.w * 2 / 5 { c.d[y * p.w + x] = (104.0f32).ln(); l.d[y * p.w + x] = (80.0f32).ln(); } }+        let a = crate::edgelevels::agreement(&e, &c, &l, 0.5);+        assert!(!a.levels_are_wrong(), "the face as a whole passes: {a:?}");+        let fixed = correct(&e, &c, &l, 0.5, &|| crate::edgelevels::levels(&e)).expect("the left half's edges contradict its levels");+        let v = |x: usize, y: usize| { let i = y * p.w + x; (p.d[i].ln() - fixed.lam.d[i]) / (fixed.cop.d[i] - fixed.lam.d[i]) };+        assert!(v(200, 330) < 0.5, "laminate on the left reads as laminate: {}", v(200, 330));+        assert!(v(200, 303) > 0.5, "a track on the left reads as copper: {}", v(200, 303));+        assert!(v(1400, 330) > 0.5 && v(1400, 303) < 0.5, "the right half reads as before");+        let i = 330 * p.w + 1500; assert!((fixed.cop.d[i] - c.d[i]).abs() < 1e-4, "far from the fault the levels are untouched");+    }+    #[test]+    fn bare_board_under_a_hot_spot_far_from_any_edge_stays_bare() {+        // laminate at 100 with a pour at 130 along the top, under a light with a hot spot in the middle of the laminate that lifts it by half+        let (w, h) = (1200usize, 800usize); let mut p = Plane::new(w, h, 0.0);+        for y in 0..h { for x in 0..w { let base = if y < 200 || x % 300 < 6 { 130.0 } else { 100.0 }; let r2 = ((x as f32 - 600.0).powi(2) + (y as f32 - 520.0).powi(2)) / (220.0f32 * 220.0); let light = 1.0 + 0.5 * (-r2).exp(); let n = ((x * 7919) ^ (y * 104729)) % 5; p.d[y * w + x] = base * light + n as f32; } }+        let e = crate::edgelevels::step_edges(&p, &Mask::new(w, h)).unwrap();+        let lg = Plane { w, h, d: p.d.iter().map(|v| v.ln()).collect() };+        let (c, l) = levels_by_side(&e, &lg, &Mask::new(w, h), &Mask::new(w, h));+        let v = |x: usize, y: usize| { let i = y * w + x; (lg.d[i] - l.d[i]) / (c.d[i] - l.d[i]) };+        assert!(v(650, 520) < 0.5, "the hot spot on laminate reads as laminate: {}", v(650, 520));+        assert!(v(650, 100) > 0.5, "the pour reads as copper: {}", v(650, 100));+        assert!(v(603, 520) > 0.5, "a track through the hot spot reads as copper: {}", v(603, 520));+    }+    #[test]+    fn levels_that_fit_are_not_touched() {+        let p = two_halves(); let e = crate::edgelevels::step_edges(&p, &Mask::new(p.w, p.h)).unwrap();+        let (c, l) = (Plane::new(p.w, p.h, (130.0f32).ln()), Plane::new(p.w, p.h, (100.0f32).ln()));+        assert!(correct(&e, &c, &l, 0.5, &|| crate::edgelevels::levels(&e)).is_none());+    }+    #[test]+    fn a_face_with_more_grain_than_copper_edges_still_finds_the_copper_step() {+        // tracks at 135 on laminate at 100, every 120 px, and over all of it a coarse grain of 10 percent that outnumbers the track edges+        let (w, h) = (1200usize, 600usize); let mut p = Plane::new(w, h, 0.0);+        for y in 0..h { for x in 0..w { let base = if y % 120 < 8 { 135.0 } else { 100.0 }; let cell = ((x / 5) * 7919) ^ ((y / 5) * 104729); let n = if cell % 3 == 0 { 1.10 } else { 1.0 }; p.d[y * w + x] = base * n; } }+        let e = crate::edgelevels::step_edges(&p, &Mask::new(w, h)).unwrap();+        assert!((e.modal - (135.0f32 / 100.0).ln()).abs() < 0.1, "modal step {}", e.modal);+    }+}
src/main.rs+607−118
@@ -12,24 +12,45 @@ mod confidence; mod connect; mod route; mod trace;+mod trackbridge; mod islands; mod copper;+mod copperchecks;+mod notread;+mod ercfind;+mod window; mod domes;+mod edgelevels; mod locallevels; mod faceread;+mod maskcolour;+mod maskread;+mod flipdecide; mod draw;+mod dspins; mod fidelity; mod findings; mod flow; mod geom; mod gerber;+mod intake; mod items;+mod netconf;+mod netdraw;+mod netpaint;+mod netsections;+mod netview; mod kicad_in; mod kicad_out; mod ledger; mod out;+mod padjoin; mod parts; mod passives;+mod tone;+mod candconf; mod photolayers; mod photomat;+mod pinnets;+mod printgaps; mod prep; mod raster; mod regframe;@@ -37,11 +58,24 @@ mod report; mod rules; mod server; mod attrib;+mod auto;+mod bench;+mod layerstate;+mod twoscores;+mod unrouted;+mod video; mod sch;+mod schcheck;+mod schsym; mod sexp; mod snap; mod timeline; mod views;+mod witness;+mod viasig;+mod facereg;+mod outline;+mod reframe;  use clap::{Parser, Subcommand}; use out::{err, ok};@@ -67,9 +101,17 @@ struct Cli {  #[derive(Subcommand)] enum Cmd {-    /// Start: `init --top <photo...> --bottom <photo...>` (photos are copied into reverse-flow/photos)+    /// Start: `init --photos <folder or photo...>` (every photo of one board, no face labels: `prep` sorts and stitches them first)+    /// or `init --top <photo...> --bottom <photo...>` (one photo per face is the board image; copied into reverse-flow/photos)     /// `--rules kicad-default|jlc-4l|<file.json>`: the design rules every later step uses (default kicad-default)-    Init { #[arg(long, num_args = 1..)] top: Vec<String>, #[arg(long, num_args = 0..)] bottom: Vec<String>, #[arg(long)] target: Option<String>, #[arg(long, default_value = "kicad-default")] rules: String },+    Init { #[arg(long, num_args = 0..)] top: Vec<String>, #[arg(long, num_args = 0..)] bottom: Vec<String>,+        /// Every photo of the board, unsorted: files or a folder. Close-up tiles, whole-board overviews, both faces, in any order+        #[arg(long, num_args = 1..)] photos: Vec<String>,+        #[arg(long)] target: Option<String>, #[arg(long, default_value = "kicad-default")] rules: String },+    /// Sort the photos given to `init --photos` into faces, tiles and overviews, and build one mosaic per face (run by `prep` when needed); writes stitch.json and stitch/+    Stitch {+        /// When stitch.json says the choice of top is close: the name of one photo that shows the top face. Stitches again with that face as the top+        #[arg(long)] top: Option<String> },     /// Show or set the design rules: `rules` (show) or `rules --set <profile|file>` (only before the first build)     Rules { #[arg(long)] set: Option<String> },     /// What to do now@@ -82,11 +124,21 @@ enum Cmd {     Prep,     /// Pixels per mm from hole pitches, confirmed by a datasheet body: `scale --body <id>=<L>x<W> --source "..."`, or through four mounting holes at a stated spacing: `scale --holes <x>x<y> --board <x>x<y> --source "..."`; then the board frame     Scale { #[arg(long)] body: Option<String>, #[arg(long)] source: Option<String>,+        /// A known distance pointed out on the straightened top photo, `<x1>,<y1>,<x2>,<y2>=<mm>` in that photo's pixels (read them off `scale --crop`): for example the centres of the first and last lead of a chip's row, (leads - 1) x the datasheet pitch apart. Repeat for more rulers; the longer the ruler the finer the scale. Needs --source+        #[arg(long)] ruler: Vec<String>,+        /// Write a crop of the straightened top photo with pixel coordinates along its edges, to read ruler points from: `scale --crop <x>,<y>[,<size>]` (centre and size in pixels of evidence/prep-<top photo>.jpg; size 600 by default)+        #[arg(long)] crop: Option<String>,         /// The mounting holes' centre spacing in mm, `<x>x<y>`, for a board with no rows of holes: four holes frame each photo. Needs --board and --source         #[arg(long)] holes: Option<String>,         /// The board's size in mm, `<x>x<y>`, from the same source; the hole pattern is taken as centred on it         #[arg(long)] board: Option<String>,         /// Which way the board was turned over, `x` or `y`, when neither holes nor through features can say+        #[arg(long)] flip: Option<String>,+        /// Written by `reframe`: the pixels per mm of the straightened top photo, carried over from the scale this run was given on an older frame+        #[arg(long)] carried: Option<f64> },+    /// Move a run made on an older frame onto the frame this build makes, keeping what was read: runs `prep`, carries the scale and the flip, carries every part of parts.json through the photo of its face, detects candidates anew and matches the old ids, and sets aside what `auto` must make again+    Reframe {+        /// Which way the board was turned over, `x` or `y`, when the old run never settled it and this build cannot tell         #[arg(long)] flip: Option<String> },     /// `detect parts [--bottom]`: part candidates with package matches, crops and contact sheets (--bottom: the bottom face has parts too)     Detect { what: String, #[arg(long)] bottom: bool },@@ -100,15 +152,18 @@ enum Cmd {     Copper { what: String },     /// Write the KiCad project (footprints, outline, fitted and filled pours, traces, vias): `build [--layers N]`     Build { #[arg(long)] layers: Option<usize> },-    /// Check a step: prep | parts | blocks | copper | mimic --pcb <board> | nets | layers [--pcb <board>] | fidelity [--pcb <board>] | review | tests+    /// Write the schematic again from parts.json, blocks.json, nets.json and net-evidence.json without rebuilding the board (after naming a part's `symbol` or `pins`); `build` does this too+    Schematic,+    /// Check a step: prep | parts | blocks | copper | mimic --pcb <board> | nets | layers [--pcb <board>] | schematic | fidelity [--pcb <board>] | review | tests     Check { what: String, #[arg(long)] pcb: Option<String>,         /// Run this check though earlier steps are not done, for testing a stage on its own; say why. The check is logged as such and does not count as passing that step         #[arg(long)] skip_earlier: Option<String> },-    /// `layers trace`: silk, mask openings, exposed pads, holes and outline read from the photos into photo-layers.json; the next `build` draws them-    Layers { what: String },+    /// `layers trace`: silk, mask openings, exposed pads, holes and outline read from the photos into photo-layers.json; the next `build` draws them.+    /// `layers state [--count <n> --source "<document>" | --count <n> --evidence <file> --kind edge-micrograph|cross-section|xray|ct]`: the layer count stated without routing: from outside evidence when given, otherwise UNKNOWN with its test and the minimum the photos prove (`--count 0` withdraws a stated count)+    Layers { what: String, #[arg(long)] count: Option<usize>, #[arg(long)] source: Option<String>, #[arg(long)] evidence: Option<String>, #[arg(long)] kind: Option<String> },     /// Save the KiCad project as it stands: `snapshot --name mimic --dir <project folder>`. Goes stale when copper.json or parts.json change; run it again then, and the old one is kept     Snapshot { #[arg(long)] name: String, #[arg(long)] dir: String },-    /// `connect measure` (read the photos once for attribution), `connect attribute [--vias <file>]` (every pour, track and via gets a net with graded evidence, no photo read), `connect extract` (connectivity measured on the photos) or `connect route` (the router on each stackup, jumping layers until every connection closes and the DRC passes; `--layers 4,6` to choose, `--seconds` per stackup)+    /// `connect measure` (read the photos once for attribution), `connect attribute [--vias <file>]` (every pour, track and via gets a net with graded evidence, no photo read), `connect witness` (each pin of a chip with `datasheets/<ref>.pins.json` against the copper: agree, disagree or not visible, findings with crops), `connect extract` (connectivity measured on the photos) or `connect route` (the router on each stackup, jumping layers until every connection closes and the DRC passes; `--layers 4,6` to choose, `--seconds` per stackup)     Connect { what: String, #[arg(long)] vias: Option<String>, #[arg(long)] layers: Option<String>, #[arg(long, default_value_t = 480.0)] seconds: f64, #[arg(long, default_value_t = 3)] stall: usize, #[arg(long, default_value_t = 8)] max_layers: usize },     /// `review` or `review pass [--target 90] [--max-passes 20] [--note "..."]`: one pass of the loop: measure, score every category, draw the audits still needed, and say CONTINUE, TARGET MET, STALLED or STOPPED. `review measure` lists disagreements only     Review { what: Option<String>, #[arg(long)] target: Option<f64>, #[arg(long)] max_passes: Option<usize>, #[arg(long)] note: Option<String> },@@ -118,8 +173,18 @@ enum Cmd {     Confidence,     /// `tests generate`: a test for every INFERRED or UNKNOWN item     Tests { what: String },-    /// Generated tables for the report-    Report,+    /// Generated tables for the report. `report --draft` also writes a starting report.md from them when there is none+    Report { #[arg(long)] draft: bool },+    /// `datasheet status` (which identified chips have `datasheets/<ref>.pins.json`, which wait) or `datasheet apply` (the pin names and types of every valid pins file into parts.json `pins`, for the schematic and the net names)+    Datasheet { what: String },+    /// Run every program-only step in order. Stops with `AUTO NEEDS AI: <input>` (exit 2) naming the file or command wanted, `AUTO COMPLETE` (exit 0) or `AUTO FAILED` (exit 1)+    Auto { #[arg(long, default_value_t = 200)] max_steps: usize },+    /// After the run: `bench plan` writes ordered instructions for a person with the real board and a multimeter (reverse-flow/bench/plan.md, with a crop for each pad); `bench ingest --file <results.json>` records their answers as MEASURED, and the scores move by computation. The flow never waits for this. docs/bench.md gives the results file format+    Bench { what: String, #[arg(long)] file: Option<String> },+    /// `nets draw [--net <name>]`: grade every net by its weakest link (net-confidence.json) and draw the nets under reverse-flow/nets/: both faces by confidence, a table, one picture per net, section sheets and an index+    Nets { what: String, #[arg(long)] net: Option<String> },+    /// `video build [--no-voice] [--workers <n>]`: the two minute video of the run, made from the run folder alone: reverse-flow/video/<project>-run.mp4 (1920x1080, 30 fps, H.264, AAC), a poster, .vtt and .srt captions, and video.json with every scene, the files it showed and where each figure was read. `auto` runs it after `report`+    Video { what: String, #[arg(long)] no_voice: bool, #[arg(long, default_value_t = 2)] workers: usize },     /// Register the KiCad project the flow window shows: `kicad --pcb <board> [--sch <root sheet>]`     Kicad { #[arg(long)] pcb: String, #[arg(long)] sch: Option<String> },     /// Record the current stage's view again after an update@@ -134,7 +199,7 @@ fn thread(cli: &Cli) -> String { cli.ai_thread.clone().unwrap_or_else(|| err("th  fn project(cli: &Cli) -> ledger::Project {     let start = cli.project.as_ref().map(PathBuf::from).unwrap_or_else(|| std::env::current_dir().unwrap());-    ledger::Project::find(&start, FLOW_DIR).unwrap_or_else(|| err(&format!("no {FLOW_DIR}/ project here"), &["Start one: adom-aiflow-reverse init --top <photo...> --bottom <photo...> --ai-thread <you>".into()]))+    ledger::Project::find(&start, FLOW_DIR).unwrap_or_else(|| err(&format!("no {FLOW_DIR}/ project here"), &["Start one: adom-aiflow-reverse init --photos <folder or photo...> --ai-thread <you> (or --top <photo...> --bottom <photo...> when the faces are already one photo each)".into()])) }  pub fn sha256(p: &Path) -> String {@@ -165,17 +230,33 @@ fn review_findings(f: &Path) -> Vec<String> {     for z in rd(&f.join("copper.json"))["zones"].as_array().into_iter().flatten() {         let s = z["fit"]["edgeShare"].as_f64().unwrap_or(0.0); if z["sketch_mm"].is_array() && s < 0.6 { lines.push(format!("zone {}: {:.0}% on measured gaps", z["id"].as_str().unwrap_or("?"), s * 100.0)); }     }+    lines.extend(notread::sentences(f));+    lines.extend(ercfind::recorded(f).iter().filter_map(|x| x["finding"].as_str().map(|t| format!("ERC {}: {t}", x["id"].as_str().unwrap_or("")))));+    for z in rd(&f.join(kicad_out::LEFT_OUT))["noFill"].as_array().into_iter().flatten() { lines.push(format!("pour {} ({} mm2) is in copper.json and drawn in the KiCad copy as an outline with no fill: {}", z["id"].as_str().unwrap_or("?"), z["areaMm2"], z["why"].as_str().unwrap_or(""))); }     if let Ok(c) = items::read_json(&f.join("detect/connectivity.json")) { for x in c["clashes"].as_array().into_iter().flatten() { lines.push(format!("net clash: {}", x.as_str().unwrap_or(""))); } }     lines } +/// The steps before `step` that are not done or are stale: a check and a review pass both wait for them.+fn steps_behind(fl: &flow::Flow, f: &Path, step: &str) -> Vec<String> {+    let Some(i) = fl.steps.iter().position(|s| s.name == step) else { return vec![] };+    fl.steps[..i].iter().filter(|s| !s.done.iter().all(|d| flow::exists(f, d))).map(|s| s.name.clone()).collect()+}+ fn step_hint(fl: &flow::Flow, dir: &Path) -> Vec<String> {     match flow::current(fl, dir) {-        None => vec!["every step is done: the flow is finished".into()],+        None => {+            let mut h = vec!["every step is done: the flow is finished. `confidence` prints both scores; `bench plan` writes the optional instructions for a person with the real board".to_string()];+            // the video follows the report: `auto` builds it, and a failed build is said here, never a reason to wait+            h.extend(auto::now_lines(dir, fl));+            h.extend(auto::video_note(dir));+            h+        }         Some(s) => {-            let mut h = vec![];+            let mut h = intake::hint(dir);             for d in &s.done { let st = flow::stale(dir, d); if !st.is_empty() { h.push(format!("{d} is stale: {} changed after the check passed; run it again", st.join(", "))); } }             h.push(format!("next step {}: {}", s.name, s.todo));+            h.extend(auto::now_lines(dir, fl));             h         }     }@@ -297,12 +378,12 @@ fn scale_by_holes(p: &mut ledger::Project, fl: &flow::Flow, th: &str, prep_doc:         let fit = regframe::Quad::fit(&kept).and_then(|mut q| { for _ in 0..3 { let mut res: Vec<f64> = kept.iter().map(|x| geom::dist(q.apply(x.0), x.1)).collect(); res.sort_by(|a, b| a.partial_cmp(b).unwrap()); let lim = (res[res.len() / 2] * 3.0).max(1.5); kept.retain(|x| geom::dist(q.apply(x.0), x.1) <= lim); q = regframe::Quad::fit(&kept)?; } Some(q) });         let by_tiles = fit.filter(|_| kept.len() >= 12).map(|q| { let mut r = regframe::report(&kept, &q, board::RES); r["tilesCorrelated"] = json!(tiles); r["through"] = json!("correlated 5 mm tiles"); (q, r) });         // vias that show on both faces, where there are enough: use whichever holds better on what was left out of its fit-        let by_pits = board::pit_pairs(&top_img, &first).and_then(|prs| regframe::Quad::fit(&prs).map(|q| { let mut r = regframe::report(&prs, &q, board::RES); r["through"] = json!("vias showing on both faces"); (q, r) }));+        let by_pits = board::pit_pairs(&top_img, &first).and_then(|prs| regframe::Quad::fit(&prs).map(|q| { let mut r = regframe::report(&prs, &q, board::RES); r["through"] = json!("vias showing on both faces"); (q, r) })).or_else(|| viasig::relay(&top_img, &first, None));         let loo = |r: &Value| r["leaveOneOutRmsMm"].as_f64().unwrap_or(f64::MAX);         if let (Some(t), Some(v)) = (&by_tiles, &by_pits) { h.push(format!("face to face, left out of the fit: tiles {} mm RMS, vias {} mm RMS", t.1["leaveOneOutRmsMm"], v.1["leaveOneOutRmsMm"])); }         let chosen_fit = match (by_tiles, by_pits) { (Some(t), Some(v)) => Some(if loo(&v.1) < loo(&t.1) { v } else { t }), (t, v) => t.or(v) };         let bimg = match chosen_fit {-            Some((q, r)) => { h.push(format!("bottom laid on top through {} {}: {}", r["pairs"], r["through"].as_str().unwrap_or(""), r)); align["registration"] = r; regframe::warp(&photo(b), c, &bdst, Some(&q), first.width(), first.height(), 0).unwrap_or_else(|e| err(&e, &[])) }+            Some((q, r)) => { h.push(format!("bottom laid on top through {} {}: {}", r["pairs"], r["through"].as_str().unwrap_or(""), r)); align["registration"] = r; align["registrationQuad"] = reframe::quad_json(&q); regframe::warp(&photo(b), c, &bdst, Some(&q), first.width(), first.height(), 0).unwrap_or_else(|e| err(&e, &[])) }             None => { h.push(format!("only {tiles} tiles correlate between the faces and too few vias show on both, so no warp: the bottom rests on its four holes alone")); first }         };         save(&bimg, &f.join("board/bottom.jpg"));@@ -422,11 +503,12 @@ fn merge_routed(copper: &mut Value, rt: &Value) {     if let Some(v) = copper["vias"].as_array_mut() { v.extend(rt["routedVias"].as_array().cloned().unwrap_or_default()); } } -/// Vias from the dome detector, written with the measurements that decided each candidate.+/// Vias read from the photos, each kept or dropped on what its own photo shows, with the measurements that decided it.+/// A face lit from one side shows tented vias as domes (`domes`). Any face may show them as holes: dark round centres, judged spot by spot against the other face (`viasig`).+/// Where the two faces cannot be laid on each other through their spots, a flat face falls back to the pit reader, which accepts or refuses the face as a whole. fn write_domes(f: &std::path::Path) -> Vec<String> {     let (mut kept, mut all, mut pit_all, mut lines) = (vec![], vec![], vec![], vec![]);     let mut light = serde_json::Map::new();-    // each face first, because a flat face's pits are judged against what the other face shows     let mut faces = vec![];     for side in ["top", "bottom"] {         let file = f.join(format!("board/{side}.jpg")); if !file.is_file() { continue; }@@ -436,28 +518,48 @@ fn write_domes(f: &std::path::Path) -> Vec<String> {         light.insert(side.to_string(), json!({"toward": [d.light.0, d.light.1], "coherence": coh, "oneLight": coh >= domes::FACE_COHERENCE}));         let vs = domes::finish(&d);         let flat = coh < domes::FACE_COHERENCE;-        lines.push(format!("{side}: {} candidates, {} read as domes, shading agreement {coh:.2}{}", d.cands.len(), vs.len(), if flat { ": this face is flat, so none of them count" } else { "" }));+        lines.push(format!("{side}: {} candidates, {} read as domes, shading agreement {coh:.2}{}", d.cands.len(), vs.len(), if flat { ": no one light on this face, so none of them count as domes" } else { "" }));         for c in &d.cands { all.push(domes::cand_json(c, side)); }-        let pf = if flat { Some(domes::read_pits(&i)) } else { None };-        faces.push((side, flat, vs, pf, (i.width() as f64, i.height() as f64)));+        faces.push((side, flat, vs, i));     }-    let seen_on = |fc: &(&str, bool, Vec<domes::Dome>, Option<domes::PitFace>, (f64, f64))| -> Vec<(f64, f64)> { match &fc.3 { Some(pf) => pf.pits.iter().filter(|q| q.keep).map(|q| (q.x, q.y)).collect(), None => fc.2.iter().map(|v| (v.x, v.y)).collect() } };-    let spots: Vec<Vec<(f64, f64)>> = faces.iter().map(&seen_on).collect();-    for (k, (side, flat, vs, pf, wh)) in faces.iter().enumerate() {-        if !*flat { for v in vs { let (x, y) = board::to_mm(v.x, v.y); kept.push(json!({"side": side, "pos_mm": [(x * 1000.0).round() / 1000.0, (y * 1000.0).round() / 1000.0], "diameter_mm": (v.d_mm * 1000.0).round() / 1000.0, "shape": v.shape, "z": (v.z * 100.0).round() / 100.0})); } continue; }-        // no one light means no dome highlights, but frontal light still shows a via as a dark pit, if the face has a population of them that lines up with the other face-        let Some(pf) = pf else { continue };+    let r3 = |v: f64| (v * 1000.0).round() / 1000.0;+    // holes, spot by spot, where both faces are there to witness each other+    let reading = if faces.len() == 2 { Some(viasig::read(&faces[0].3, &faces[1].3)) } else { None };+    let by_holes = reading.as_ref().is_some_and(|r| r.lay.is_some());+    if let Some(r) = &reading { lines.extend(r.lines.iter().cloned()); }+    // without that, a flat face's pits are judged as a population against what the other face shows+    let pits: Vec<Option<domes::PitFace>> = faces.iter().map(|fc| if fc.1 && !by_holes { Some(domes::read_pits(&fc.3)) } else { None }).collect();+    let spots: Vec<Vec<(f64, f64)>> = faces.iter().zip(&pits).map(|(fc, pf)| match pf { Some(pf) => pf.pits.iter().filter(|q| q.keep).map(|q| (q.x, q.y)).collect(), None => fc.2.iter().map(|v| (v.x, v.y)).collect() }).collect();+    for (k, (side, flat, vs, i)) in faces.iter().enumerate() {+        let mut here: Vec<(f64, f64)> = vec![];+        if !*flat { for v in vs { let (x, y) = board::to_mm(v.x, v.y); here.push((v.x, v.y)); kept.push(json!({"side": side, "pos_mm": [r3(x), r3(y)], "diameter_mm": r3(v.d_mm), "shape": v.shape, "z": (v.z * 100.0).round() / 100.0})); } }+        if by_holes {+            let rd = reading.as_ref().unwrap(); let sp = if k == 0 { &rd.top } else { &rd.bottom };+            for q in sp.iter().filter(|q| q.keep) {+                // a dome already stands for this via+                if here.iter().any(|p| geom::dist(*p, (q.x, q.y)) < viasig::SAME_VIA_MM * board::RES) { continue; }+                let (x, y) = board::to_mm(q.x, q.y); let (d, _) = viasig::width_mm(q);+                kept.push(json!({"side": side, "pos_mm": [r3(x), r3(y)], "diameter_mm": r3(d), "shape": "pit", "z": (q.z as f64 * 100.0).round() / 100.0, "seen": q.why}));+            }+            continue;+        }+        let Some(pf) = &pits[k] else { continue };         let n = pf.pits.iter().filter(|q| q.keep).count();         for q in &pf.pits { pit_all.push(domes::pit_json(q, side)); }         let other: Vec<(f64, f64)> = spots.iter().enumerate().filter(|(j, _)| *j != k).flat_map(|(_, v)| v.iter().copied()).collect();-        let (hit, chance, sure) = domes::pits_confirmed(&spots[k], &other, *wh);+        let (hit, chance, sure) = domes::pits_confirmed(&spots[k], &other, (i.width() as f64, i.height() as f64));         let counts = pf.shows && sure;         lines.push(format!("{side}: {} dark spots measured, {n} look like pits, median {:.3} mm, middle half within {:.2} of it; {hit} have a via on the other face within 0.1 mm, {chance} by chance{}", pf.pits.len(), pf.median_mm, pf.spread, if counts { ": this face shows its vias as pits" } else { ": they are not vias, so none of them count" }));-        if counts { for q in pf.pits.iter().filter(|q| q.keep) { let (x, y) = board::to_mm(q.x, q.y); kept.push(json!({"side": side, "pos_mm": [(x * 1000.0).round() / 1000.0, (y * 1000.0).round() / 1000.0], "diameter_mm": (((q.len + q.wid) / 2.0) as f64 * 1000.0).round() / 1000.0, "shape": "pit", "z": (q.z as f64 * 100.0).round() / 100.0})); } }+        if counts { for q in pf.pits.iter().filter(|q| q.keep) { let (x, y) = board::to_mm(q.x, q.y); kept.push(json!({"side": side, "pos_mm": [r3(x), r3(y)], "diameter_mm": r3(((q.len + q.wid) / 2.0) as f64), "shape": "pit", "z": (q.z as f64 * 100.0).round() / 100.0})); } }     }-    wr(&f.join("detect/domes.json"), &json!({"domes": kept, "light": light, "note": "vias read from the photos: position and diameter measured; shape says what was seen (ring: dark circle round a pad, arc: near arc of a via, blob: the dome itself, edge: a dome on a copper edge, its width the face's median, pit: the dark spot a via makes under frontal light, its width the spot's, not the pad's)"}));+    wr(&f.join("detect/domes.json"), &json!({"domes": kept, "light": light, "note": "vias read from the photos: position and diameter measured; shape says what was seen (ring: dark circle round a pad, arc: near arc of a via, blob: the dome itself, edge: a dome on a copper edge, its width the face's median, pit: the dark centre of a via's hole, its width the pad's where a ring shows it and else the hole's own); seen: why a pit was kept (detect/via-spots.json lists every dark centre, kept or dropped, with its reason)"}));     wr(&f.join("detect/dome-candidates.json"), &json!({"candidates": all}));     wr(&f.join("detect/pit-candidates.json"), &json!({"candidates": pit_all}));+    if let Some(rd) = &reading {+        let mut sp = vec![]; for q in &rd.top { sp.push(viasig::spot_json(q, "top")); } for q in &rd.bottom { sp.push(viasig::spot_json(q, "bottom")); }+        wr(&f.join("detect/via-spots.json"), &json!({"spots": sp, "bars": {"top": viasig::bars_json(&rd.bars[0]), "bottom": viasig::bars_json(&rd.bars[1])}, "lay": rd.lay.as_ref().map(|l| json!({"pairs": l.pairs, "shift_mm": [r3(l.shift.0 / board::RES), r3(l.shift.1 / board::RES)], "quadratic": l.quad.is_some(), "votes": l.votes, "mirroredVotes": l.chance})),+            "note": "every dark round centre on both faces with what was measured on it; keep and why give the verdict; otherFace says what the other face shows at the same place (spot, bare mask, or covered)"}));+    }     lines } @@ -499,9 +601,56 @@ fn light_of(f: &Path, side: &str) -> Option<(f64, f64)> {     let (x, y) = (l["toward"][0].as_f64()?, l["toward"][1].as_f64()?); let n = (x * x + y * y).sqrt(); if n < 1e-6 { None } else { Some((x / n, y / n)) } } +/// `scale --crop`: a crop of the straightened top photo, enlarged, with that photo's pixel coordinates along its top and+/// left edges, so two points a known distance apart can be read off it for `scale --ruler`.+fn scale_crop(f: &Path, prep_doc: &Value, spec: &str) {+    let top = primary(prep_doc, "top").cloned().unwrap_or_else(|| err("no top photo measured: run `prep` first", &[]));+    let v: Vec<f64> = spec.split(',').filter_map(|t| t.trim().parse().ok()).collect();+    if v.len() < 2 { err("--crop <x>,<y>[,<size>]: the centre, in pixels of the straightened top photo (evidence/prep-<top photo>.jpg), and the size in pixels (600 by default)", &[]); }+    let size = v.get(2).copied().unwrap_or(600.0).clamp(100.0, 3000.0);+    let img = image::open(f.join("photos").join(top["file"].as_str().unwrap_or(""))).unwrap_or_else(|e| err(&e.to_string(), &[])).to_rgb8();+    let c = top["corners"].as_array().cloned().unwrap_or_default();+    if c.len() != 4 { err("prep.json has no outline for the top photo", &[]); }+    let corners: [geom::P; 4] = [0, 1, 2, 3].map(|i| (c[i][0].as_f64().unwrap_or(0.0), c[i][1].as_f64().unwrap_or(0.0)));+    let (w, h) = (top["rectPx"][0].as_f64().unwrap_or(0.0) as u32, top["rectPx"][1].as_f64().unwrap_or(0.0) as u32);+    let (rect, _) = prep::rectify(&img, &corners, w, h, false).unwrap_or_else(|e| err(&e, &[]));+    let (x0, y0) = ((v[0] - size / 2.0).round().clamp(0.0, (w as f64 - size).max(0.0)) as i64, (v[1] - size / 2.0).round().clamp(0.0, (h as f64 - size).max(0.0)) as i64);+    let n = (size as i64).min(w as i64 - x0).min(h as i64 - y0).max(1);+    let z = (1400 / n).clamp(1, 8);                       // whole-number enlargement, nearest pixel: no resampling blur+    let m = 70i64;                                         // margin for the coordinate scales+    let mut out = image::RgbImage::from_pixel((n * z + m) as u32, (n * z + m) as u32, image::Rgb([18, 22, 28]));+    for y in 0..n * z { for x in 0..n * z { out.put_pixel((x + m) as u32, (y + m) as u32, *rect.get_pixel((x0 + x / z) as u32, (y0 + y / z) as u32)); } }+    let step = if n <= 150 { 5 } else if n <= 300 { 10 } else if n <= 800 { 20 } else if n <= 1600 { 50 } else { 100 };+    let mut t = (x0 / step) * step; if t < x0 { t += step; }+    while t < x0 + n { let px = ((t - x0) * z + m) as f64; let major = (t / step) % 5 == 0;+        draw::line(&mut out, (px, if major { (m - 22) as f64 } else { (m - 10) as f64 }), (px, m as f64), [255, 220, 60], 1);+        if major { draw::label(&mut out, (px - 18.0, 8.0), &t.to_string(), [255, 220, 60], 2); draw::line(&mut out, (px, m as f64), (px, (n * z + m) as f64), [255, 220, 60], 1); }+        t += step; }+    let mut t = (y0 / step) * step; if t < y0 { t += step; }+    while t < y0 + n { let py = ((t - y0) * z + m) as f64; let major = (t / step) % 5 == 0;+        draw::line(&mut out, (if major { (m - 22) as f64 } else { (m - 10) as f64 }, py), (m as f64, py), [255, 220, 60], 1);+        if major { draw::label(&mut out, (2.0, py - 6.0), &t.to_string(), [255, 220, 60], 2); draw::line(&mut out, (m as f64, py), ((n * z + m) as f64, py), [255, 220, 60], 1); }+        t += step; }+    let name = format!("evidence/scale-crop-{}-{}-{}.jpg", v[0] as i64, v[1] as i64, n);+    out.save(f.join(&name)).unwrap_or_else(|e| err(&e.to_string(), &[]));+    ok(&format!("{name}: the straightened top photo from ({x0}, {y0}), {n} px square, enlarged {z}x; ticks every {step} px, labelled lines every {} px", step * 5),+       &["Read the two ruler points off the labelled lines and ticks (photo pixels, not crop pixels), then: scale --ruler <x1>,<y1>,<x2>,<y2>=<mm> --source \"<datasheet, figure>\". Crop each end separately at a small size for a finer reading.".into()]);+}+ fn drc_of(pcb: &Path, out: &Path) -> Value {-    let o = std::process::Command::new("service-kicad").args(["pcb", "drc"]).arg(pcb).args(["--format", "json", "--out"]).arg(out).output();-    match o { Ok(o) if o.status.success() => items::read_json(out).unwrap_or(json!({})), Ok(o) => json!({"error": String::from_utf8_lossy(&o.stderr).to_string()}), Err(e) => json!({"error": e.to_string()}) }+    // A native kicad-cli named in ADOM_AIFLOW_KICAD_CLI is used first (it reads the project's own rules); otherwise the+    // shared service. Both exit non-zero when the board HAS violations, so the report file decides whether the DRC ran:+    // a report that reads is a DRC that ran, whatever the exit code (the USB hub baseline, 2026-11-10, read every DRC+    // with errors as "did not run").+    let _ = std::fs::remove_file(out);+    let o = match std::env::var("ADOM_AIFLOW_KICAD_CLI") {+        Ok(cli) if !cli.is_empty() => std::process::Command::new(cli).args(["pcb", "drc", "--format", "json", "--severity-all", "--output"]).arg(out).arg(pcb).output(),+        _ => std::process::Command::new("service-kicad").args(["pcb", "drc"]).arg(pcb).args(["--format", "json", "--out"]).arg(out).output(),+    };+    match o {+        Ok(o) => match items::read_json(out) { Ok(v) if v.is_object() => v, _ if o.status.success() => json!({}), _ => json!({"error": format!("{} {}", String::from_utf8_lossy(&o.stderr), String::from_utf8_lossy(&o.stdout)).trim().to_string()}) },+        Err(e) => json!({"error": e.to_string()}),+    } }  /// Under this many known connections the routing test proposes nothing. The Driver had 253; the CM5 photos alone gave 8.@@ -622,13 +771,26 @@ fn main() {     let cli = Cli::parse();     let fl = flow::load();     match &cli.cmd {-        Cmd::Init { top, bottom, target, rules: rules_arg } => {+        Cmd::Init { top, bottom, photos: unsorted, target, rules: rules_arg } => {             let th = thread(&cli);             let rules_v = rules::load(rules_arg).unwrap_or_else(|e| err(&e, &[]));             let dir = cli.project.as_ref().map(PathBuf::from).unwrap_or_else(|| std::env::current_dir().unwrap());             if dir.join(FLOW_DIR).join("state.json").is_file() { err(&format!("{FLOW_DIR}/ already exists in {}", dir.display()), &["Continue it with `next`, or start in a new folder.".into()]); }-            if top.is_empty() { err("init needs at least one --top photo", &[]); }             let fdir = dir.join(FLOW_DIR);+            if !unsorted.is_empty() {+                // an unsorted set: kept as it came; `stitch` (run by `prep`) sorts it and builds the face images+                if !top.is_empty() || !bottom.is_empty() { err("give either --photos (unsorted, the program sorts them) or --top/--bottom, not both", &[]); }+                let files = intake::collect(unsorted).unwrap_or_else(|e| err(&e, &[]));+                for d in ["photos", "evidence", "board", "detect"] { std::fs::create_dir_all(fdir.join(d)).ok(); }+                let rec = intake::record(&fdir, &files).unwrap_or_else(|e| err(&e, &[]));+                let p = ledger::Project::create(&dir, FLOW_DIR, json!({"flow": fl.name, "photos": [], "intake": rec, "target": target, "started": ledger::now(), "rules": rules_v})).unwrap_or_else(|e| err(&e.to_string(), &[]));+                p.log(&th, "init", json!({"unsorted": p.state["intake"]["unsorted"]}));+                ok(&format!("reverse flow started in {} with {} unsorted photo(s)", p.flow_dir.display(), files.len()),+                   &["Next: `prep`. It first sorts the photos into faces, tiles and overviews and stitches one mosaic per face (`stitch` does that step alone).".into(),+                     "Start the flow window too: nohup adom-aiflow-reverse serve --port 8890 > reverse-flow/serve.log 2>&1 &".into()]);+                return;+            }+            if top.is_empty() { err("init needs photos: `--photos <folder or photo...>` for an unsorted set, or at least one --top photo", &[]); }             for d in ["photos", "evidence", "board", "detect"] { std::fs::create_dir_all(fdir.join(d)).ok(); }             let mut photos = vec![];             for (side, list) in [("top", top), ("bottom", bottom)] {@@ -643,6 +805,7 @@ fn main() {             let p = ledger::Project::create(&dir, FLOW_DIR, json!({"flow": fl.name, "photos": photos, "target": target, "started": ledger::now(), "rules": rules_v})).unwrap_or_else(|e| err(&e.to_string(), &[]));             p.log(&th, "init", json!({"photos": p.state["photos"]}));             let mut h = vec![]; if bottom.is_empty() { h.push("No bottom photo: bottom copper, via rings and layer evidence will be unknown. Ask for one.".into()); }+            h.extend(video::keep_stitch(&fdir, &top.iter().chain(bottom.iter()).map(PathBuf::from).collect::<Vec<_>>()));             h.push("Next: `prep`. Start the flow window too: nohup adom-aiflow-reverse serve --port 8890 > reverse-flow/serve.log 2>&1 &".into());             ok(&format!("reverse flow started in {} with {} photo(s)", p.flow_dir.display(), photos.len()), &h);         }@@ -666,10 +829,24 @@ fn main() {                 let now = if !stale { String::new() } else { format!(" ({} changed)", why.join(", ")) };                 let _ = writeln!(std::io::stdout(), "{state} {}{now}", s.name);             }+            // after the steps: the run video, which never holds the run up+            if cur.is_none() { let w = video::state_word(&p.flow_dir); let _ = writeln!(std::io::stdout(), "{} video{}", match w { "done" => "done ", "failed" => "fail ", _ => "NOW  " }, if w == "failed" { format!(" ({}; the run is complete without it)", video::record(&p.flow_dir)["error"].as_str().unwrap_or("not built")) } else { String::new() }); }         }         Cmd::Ledger => { let p = project(&cli); let _ = write!(std::io::stdout(), "{}", std::fs::read_to_string(p.path("run.jsonl")).unwrap_or_default()); }+        Cmd::Stitch { top } => {+            let th = thread(&cli); let mut p = project(&cli);+            if let Some(t) = top {+                let name = Path::new(t).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();+                if !p.state["intake"]["unsorted"].as_array().into_iter().flatten().any(|r| r["file"].as_str() == Some(name.as_str())) { err(&format!("{name} is not one of this run's photos"), &["Name a photo given to `init --photos`: see reverse-flow/photos-raw/.".into()]); }+                p.state["intake"]["topPhoto"] = json!(name);+            }+            let h = intake::run(&mut p, &th).unwrap_or_else(|e| err(&e, &["`stitch` works on a run started with `init --photos <folder or photo...>`.".into()]));+            ok("photos sorted and stitched: stitch.json, photos/top.jpg and photos/bottom.jpg are the board images now", &h);+        }         Cmd::Prep => {             let th = thread(&cli); let mut p = project(&cli); let f = p.flow_dir.clone();+            // a run started with unsorted photos: sort and stitch them first, so that what follows measures the mosaics+            let stitched: Vec<String> = if intake::pending(&p.state) { intake::run(&mut p, &th).unwrap_or_else(|e| err(&e, &[])) } else { vec![] };             let mut recs = vec![];             for ph in p.state["photos"].as_array().cloned().unwrap_or_default() {                 let file = ph["file"].as_str().unwrap();@@ -677,12 +854,28 @@ fn main() {                 recs.push(r);             }             let mut doc = json!({"photos": recs, "at": ledger::now()});-            let (t, b) = (primary(&doc, "top").cloned(), primary(&doc, "bottom").cloned());+            let (mut t, mut b) = (primary(&doc, "top").cloned(), primary(&doc, "bottom").cloned());             if let (Some(t), Some(b)) = (&t, &b) {                 let wh = |v: &Value| (v["rectPx"][0].as_f64().unwrap(), v["rectPx"][1].as_f64().unwrap());                 let (axis, m, o) = prep::flip_axis(t["holes"].as_array().unwrap(), wh(t), b["holes"].as_array().unwrap(), wh(b));-                doc["flip"] = json!({"axis": axis, "matches": m, "other": o});+                // through features matched between the faces decide it, and give both faces one outline; the outline fractions only when too few match, and then the mapping is weak+                doc["primary"] = json!({"top": t["file"], "bottom": b["file"]});+                let open = |v: &Value| image::open(f.join("photos").join(v["file"].as_str().unwrap())).unwrap_or_else(|e| err(&e.to_string(), &[])).to_rgb8();+                let (ti, bi) = (open(t), open(b));+                let (fl_rec, shared, tier) = facereg::unify(&doc, &ti, &bi);+                doc["faceFit"] = tier;+                doc["flip"] = fl_rec.unwrap_or_else(|| json!({"axis": axis, "matches": m, "other": o, "weak": "too few through features match between the faces, so this rests on the two outlines coinciding"}));+                if let Some((tc, bc, from)) = shared {+                    for (rec, img, c) in [(t, &ti, tc), (b, &bi, bc)] {+                        let file = rec["file"].as_str().unwrap();+                        let again = board::prep_image(img, &f.join("photos").join(file), rec["side"].as_str().unwrap_or("top"), &f.join("evidence"), Some((c, from.clone(), rec["corners"].clone(), rec["maskBeyondOwnEdges"].clone()))).unwrap_or_else(|e| err(&format!("{file}: {e}"), &[]));+                        for ph in doc["photos"].as_array_mut().unwrap() { if ph["file"] == rec["file"] { *ph = again.clone(); } }+                    }+                }             }+            // the two primaries may have been measured again through the shared outline+            let again = |doc: &Value, v: &Option<Value>| v.as_ref().and_then(|v| doc["photos"].as_array().unwrap().iter().find(|p| p["file"] == v["file"]).cloned());+            (t, b) = (again(&doc, &t), again(&doc, &b));             doc["primary"] = json!({"top": t.as_ref().map(|v| v["file"].clone()), "bottom": b.as_ref().map(|v| v["file"].clone())});             let cands = t.as_ref().map(|t| board::scale_candidates(t["rows"].as_array().unwrap())).unwrap_or_default();             doc["scaleCandidates"] = json!(cands);@@ -690,68 +883,145 @@ fn main() {             p.log(&th, "prep", json!({"flip": doc["flip"], "photos": doc["photos"].as_array().map(|a| a.len())}));             reach(&mut p, &fl, "intake");             let mut h: Vec<String> = doc["photos"].as_array().unwrap().iter().map(|r| format!("{} ({}): sharpness {:.0}, {} holes, {} rows, {} bodies", r["file"].as_str().unwrap(), r["side"].as_str().unwrap(), fv(r, "sharpness"), r["holes"].as_array().unwrap().len(), r["rows"].as_array().unwrap().len(), r["bodies"].as_array().unwrap().len())).collect();-            if doc["flip"].is_object() { h.push(format!("bottom maps onto the top turned over {} ({} holes agree, {} the other way)", if doc["flip"]["axis"] == "y" { "top-to-bottom" } else { "left-to-right" }, doc["flip"]["matches"], doc["flip"]["other"])); }+            if doc["flip"].is_object() { h.push(format!("bottom maps onto the top turned over {} ({} holes agree, {} the other way{})", if doc["flip"]["axis"] == "y" { "top-to-bottom" } else { "left-to-right" }, doc["flip"]["matches"], doc["flip"]["other"],+                match (doc["flip"]["by"].as_str(), doc["flip"]["weak"].as_str()) { (Some(b), _) => format!("; {b}, residual {} px", doc["flip"]["residualPx"]), (_, Some(w)) => format!("; WEAK: {w}"), _ => String::new() })); }+            for r in doc["photos"].as_array().unwrap() { let cut: Vec<&str> = r["notBoardEdge"].as_array().into_iter().flatten().filter_map(|x| x.as_str()).collect();+                if !cut.is_empty() { h.push(format!("{}: the board runs out of the photo at the {}: the outline there is the photo's edge, NOT the board's edge, and the board's size across it is not known", r["file"].as_str().unwrap_or("?"), cut.join(" and "))); } }             for c in cands.iter().take(3) { h.push(format!("scale candidate {} px/mm explains {} holes in {} rows", c["pxPerMm"], c["score"], c["rows"].as_array().unwrap().len())); }             h.push("Run `check prep`, then `scale --body <id>=<L>x<W> --source \"...\"` with a body from evidence/prep-<top photo>.jpg whose size a datasheet gives.".into());             if cands.is_empty() { h.push("No rows of holes here. With four mounting holes at a spacing a document gives: `scale --holes <x>x<y> --board <x>x<y> --source \"...\"`.".into()); }             h.extend(views::record(&f, &p.state, "intake", "photos measured", &th, None));+            let h: Vec<String> = stitched.into_iter().chain(h).collect();             ok("every photo measured: prep.json", &h);         }-        Cmd::Scale { body, source, holes, board: board_arg, flip } => {+        Cmd::Reframe { flip } => {+            let th = thread(&cli); let p = project(&cli);+            match reframe::run(&p.dir, &p.flow_dir, &th, flip.as_deref()) {+                Ok(h) => { p.log(&th, "reframe", json!({"summary": h})); ok("the run is on the new frame: prep.json, scale.json and parts.json carried over", &h); }+                Err(e) => err(&format!("reframe: {e}"), &["Nothing is lost: the old files are kept as prep.before-reframe.json, scale.before-reframe.json and parts.before-reframe.json. Copy them back to undo.".into()]),+            }+        }+        Cmd::Scale { body, source, holes, board: board_arg, flip, ruler, crop, carried } => {             let th = thread(&cli); let mut p = project(&cli); let f = p.flow_dir.clone();             let mut prep_doc = rd(&f.join("prep.json"));+            if let Some(c) = crop { scale_crop(&f, &prep_doc, &c); return; }             if let Some(hs) = holes {                 scale_by_holes(&mut p, &fl, &th, &mut prep_doc, hs, board_arg.as_deref().unwrap_or_else(|| err("--holes needs --board <x>x<y>: the board's size from the same source", &[])), source.as_deref().unwrap_or(""), flip.as_deref());                 return;             }             let top = primary(&prep_doc, "top").cloned().unwrap_or_else(|| err("no top photo measured", &[]));             let cands: Vec<Value> = prep_doc["scaleCandidates"].as_array().cloned().unwrap_or_default();+            let (k, method, basis): (f64, String, Value) = if let Some(kc) = carried {+                // `reframe`: the scale an earlier frame of this run was given, converted through the photo; what it rests on is in scale.carry.json+                if *kc <= 0.0 { err("--carried <px/mm>: a positive number, written by `reframe`", &[]); }+                let mut b = rd(&f.join("scale.carry.json")); if !b.is_object() { b = json!({}); } b["pxPerMm"] = json!(kc);+                (*kc, "carried over by `reframe` from the scale this run was given before".to_string(), b)+            } else if !ruler.is_empty() {+                // Rulers: known distances the AI points out on the photo. They do not depend on the board's hole rows being on a+                // standard pitch (the MOSFET board of 2026-11-10 has one row, at 3.0 mm, and both pitch candidates near its chip+                // body were 16% wrong). A hole-row candidate within 3% of the rulers refines them; otherwise the rulers stand.+                if source.as_deref().unwrap_or("").is_empty() { err("--source: name the datasheet (and figure) that gives each ruler's length", &[]); }+                let mut ks: Vec<(f64, f64, f64)> = vec![]; let mut ends: Vec<Vec<f64>> = vec![];+                for r in ruler.iter() {+                    let (pts, mm) = r.split_once('=').unwrap_or_else(|| err("--ruler <x1>,<y1>,<x2>,<y2>=<mm>", &[]));+                    let v: Vec<f64> = pts.split(',').filter_map(|t| t.trim().parse().ok()).collect(); let mm: f64 = mm.trim().parse().unwrap_or(0.0);+                    if v.len() != 4 || mm <= 0.0 { err("--ruler <x1>,<y1>,<x2>,<y2>=<mm>: four pixel coordinates and a length in mm", &[]); }+                    let d = ((v[2] - v[0]).powi(2) + (v[3] - v[1]).powi(2)).sqrt();+                    if d < 80.0 { err(&format!("the ruler {r} is only {d:.0} px long: use a longer one (a whole row of leads, a connector, a header), since a 2 px reading error is {:.1}% of it", 200.0 / d.max(1.0)), &[]); }+                    ks.push((d / mm, d, mm)); ends.push(v.clone());+                }+                let mut sorted: Vec<f64> = ks.iter().map(|x| x.0).collect(); sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());+                let k_r = if sorted.len() % 2 == 1 { sorted[sorted.len() / 2] } else { (sorted[sorted.len() / 2 - 1] + sorted[sorted.len() / 2]) / 2.0 };+                let spread = (sorted[sorted.len() - 1] - sorted[0]) / k_r;+                if spread > 0.04 { err(&format!("the rulers disagree by {:.1}% ({}): read the points again on `scale --crop`, or check each length against its datasheet", spread * 100.0, ks.iter().map(|x| format!("{:.2} px/mm", x.0)).collect::<Vec<_>>().join(", ")), &[]); }+                let refined = cands.iter().map(|c| fv(c, "pxPerMm")).filter(|c| ((c / k_r).ln()).abs() < 0.03).min_by(|a, b| ((a / k_r).ln().abs()).partial_cmp(&(b / k_r).ln().abs()).unwrap());+                let k = refined.unwrap_or((k_r * 1000.0).round() / 1000.0);+                let method = if refined.is_some() { "rulers pointed out on the photo, refined by the hole-row pitch within 3% of them" } else { "rulers pointed out on the photo (no hole-row pitch solution lies within 3% of them, so the rows are not on a standard pitch)" };+                (k, method.to_string(), json!({"rulers": ks.iter().zip(&ends).map(|(x, e)| json!({"px": x.1, "mm": x.2, "pxPerMm": x.0, "ends": e})).collect::<Vec<_>>(), "pxPerMm": k_r, "spread": spread, "source": source}))+            } else {             let Some(b) = body else {-                let mut h: Vec<String> = cands.iter().take(4).map(|c| format!("candidate {} px/mm: rows {}", c["pxPerMm"], c["rows"].as_array().unwrap().iter().map(|r| format!("{} mm", r["mm"])).collect::<Vec<_>>().join(", "))).collect();-                h.push("scale --body <id>=<L>x<W> --source \"<datasheet>\" (ids on evidence/prep-<top photo>.jpg)".into());-                h.push("or, with no rows of holes: scale --holes <x>x<y> --board <x>x<y> --source \"<document>\", four mounting holes at a stated spacing".into());-                err("scale needs a body whose size a datasheet gives, to confirm the pitch solution", &h)-            };-            if source.as_deref().unwrap_or("").is_empty() { err("--source: name the datasheet and package drawing that give the body size", &[]); }-            let (id, dims) = b.split_once('=').unwrap_or_else(|| err("--body <id>=<L>x<W>", &[]));-            let (l, w) = dims.split_once('x').map(|(a, c)| (a.parse::<f64>().unwrap_or(0.0), c.parse::<f64>().unwrap_or(0.0))).unwrap_or((0.0, 0.0));-            let bd = top["bodies"].as_array().and_then(|a| a.get(id.parse::<usize>().unwrap_or(usize::MAX))).cloned().unwrap_or_else(|| err(&format!("no body {id} on the top photo"), &[]));-            let (bl, bw) = (fv(&bd["body"], "long"), fv(&bd["body"], "short"));-            let (lm, wm) = if l >= w { (l, w) } else { (w, l) };-            let k_body = (bl / lm + bw / wm) / 2.0;-            // the pitches give the precise scale; the body only decides which standard the rows are (a body-            // measures to about +-15%, the candidates differ by factors like 2 or 1.27): exactly one candidate-            // may lie within 25% of it-            let near: Vec<&Value> = cands.iter().filter(|c| ((fv(c, "pxPerMm") - k_body) / k_body).abs() < 0.25).collect();-            if near.len() > 1 { err(&format!("the body ({k_body:.2} px/mm) cannot decide between {} candidates", near.len()), &near.iter().map(|c| format!("candidate {} px/mm", c["pxPerMm"])).collect::<Vec<_>>()); }-            let pick = near.first().copied();-            let k = match pick {-                Some(c) => fv(c, "pxPerMm"),-                None => { let mut h: Vec<String> = cands.iter().map(|c| format!("candidate {} px/mm", c["pxPerMm"])).collect(); h.push("Check the body id and its datasheet size, or measure another body.".into());-                          err(&format!("the body gives {k_body:.2} px/mm ({bl:.0} x {bw:.0} px for {lm} x {wm} mm) but no hole-row solution is within 25% of it"), &h) }+                    let mut h: Vec<String> = cands.iter().take(4).map(|c| format!("candidate {} px/mm: rows {}", c["pxPerMm"], c["rows"].as_array().unwrap().iter().map(|r| format!("{} mm", r["mm"])).collect::<Vec<_>>().join(", "))).collect();+                    h.push("scale --body <id>=<L>x<W> --source \"<datasheet>\" (ids on evidence/prep-<top photo>.jpg)".into());+                    h.push("or, with no rows of holes: scale --holes <x>x<y> --board <x>x<y> --source \"<document>\", four mounting holes at a stated spacing".into());+                    err("scale needs a body whose size a datasheet gives, to confirm the pitch solution", &h)+                };+                if source.as_deref().unwrap_or("").is_empty() { err("--source: name the datasheet and package drawing that give the body size", &[]); }+                let (id, dims) = b.split_once('=').unwrap_or_else(|| err("--body <id>=<L>x<W>", &[]));+                let (l, w) = dims.split_once('x').map(|(a, c)| (a.parse::<f64>().unwrap_or(0.0), c.parse::<f64>().unwrap_or(0.0))).unwrap_or((0.0, 0.0));+                let bd = top["bodies"].as_array().and_then(|a| a.get(id.parse::<usize>().unwrap_or(usize::MAX))).cloned().unwrap_or_else(|| err(&format!("no body {id} on the top photo"), &[]));+                let (bl, bw) = (fv(&bd["body"], "long"), fv(&bd["body"], "short"));+                let (lm, wm) = if l >= w { (l, w) } else { (w, l) };+                let k_body = (bl / lm + bw / wm) / 2.0;+                // the pitches give the precise scale; the body only decides which standard the rows are (a body+                // measures to about +-15%, the candidates differ by factors like 2 or 1.27): exactly one candidate+                // may lie within 25% of it+                // The candidate nearest the body's scale wins when it is within 25% and clearly nearer than the next one+                // (0.06 in log ratio, about 6%): neighbouring standards sit 20 to 27% apart (2.0 against 2.5 or 1.5 mm), so a+                // fixed 25% window could never single out a 2.0 mm board (USB hub, 2026-11-10).+                let mut near: Vec<(f64, &Value)> = cands.iter().map(|c| ((fv(c, "pxPerMm") / k_body).ln().abs(), c)).collect();+                near.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());+                let k = match near.first() {+                    Some((d1, c)) if *d1 < 1.25f64.ln() => {+                        if let Some((d2, c2)) = near.get(1) { if d2 - d1 < 0.06 { err(&format!("the body ({k_body:.2} px/mm) cannot decide between candidates {} and {} px/mm", c["pxPerMm"], c2["pxPerMm"]), &["Measure a second body, or a larger one: the longer the body, the finer it decides.".into()]); } }+                        fv(c, "pxPerMm")+                    }+                    _ if cands.is_empty() => {+                        // no rows of holes on this photo: the body alone sets the scale, to about the precision its edges were found+                        eprintln!("note: no hole rows were measured, so the scale rests on this body alone ({k_body:.2} px/mm, about +-5%); a second body or `scale --holes` would tighten it");+                        k_body+                    }+                    _ => { let mut h: Vec<String> = cands.iter().take(12).map(|c| format!("candidate {} px/mm", c["pxPerMm"])).collect(); h.push("Check the body id and its datasheet size, or measure another body.".into());+                           err(&format!("the body gives {k_body:.2} px/mm ({bl:.0} x {bw:.0} px for {lm} x {wm} mm) but no hole-row solution is within 25% of it"), &h) }+                };+                (k, "hole-row pitches, confirmed by a datasheet body".to_string(), json!({"id": id, "mm": [lm, wm], "px": [bl, bw], "pxPerMm": k_body, "source": source}))             };             let (wmm, hmm) = (top["rectPx"][0].as_f64().unwrap() / k, top["rectPx"][1].as_f64().unwrap() / k);-            let axis = prep_doc["flip"]["axis"].as_str().unwrap_or("y").to_string();+            // `--flip x|y` states which way the board was turned over when the holes cannot tell (it was honoured only with+            // --holes until the MOSFET board of 2026-11-10); the statement is recorded in prep.json like a measured one+            let mut axis = match flip.as_deref() {+                Some(a) if a == "x" || a == "y" => { prep_doc["flip"] = json!({"axis": a, "how": "stated with --flip", "matches": prep_doc["flip"]["matches"], "other": prep_doc["flip"]["other"]}); wr(&f.join("prep.json"), &prep_doc); a.to_string() }+                Some(a) => err(&format!("--flip {a}: expected x or y"), &[]),+                None => prep_doc["flip"]["axis"].as_str().unwrap_or("y").to_string(),+            };             let corners = |v: &Value| -> [geom::P; 4] { let c = v["corners"].as_array().unwrap(); [0, 1, 2, 3].map(|i| (c[i][0].as_f64().unwrap(), c[i][1].as_f64().unwrap())) };             let tdst = board::frame_corners(wmm, hmm, "top", &axis);             let top_img = board::to_frame(&f.join("photos").join(top["file"].as_str().unwrap()), &corners(&top), &tdst, wmm, hmm, (0.0, 0.0)).unwrap_or_else(|e| err(&e, &[]));             save(&top_img, &f.join("board/top.jpg"));             let top_holes = holes_mm(&top_img);             let mut align = json!(null);+            // Not stated, and the holes cannot say (`flipdecide`): read from what shows through on both faces.+            if let Some(bot) = primary(&prep_doc, "bottom").cloned().filter(|_| flip.is_none()) {+                let bpath = f.join("photos").join(bot["file"].as_str().unwrap());+                if let Some((a, how)) = flipdecide::decide(&prep_doc, None, &top_img, &|ax: &str| board::to_frame(&bpath, &corners(&bot), &board::frame_corners(wmm, hmm, "bottom", ax), wmm, hmm, (0.0, 0.0)).unwrap_or_else(|e| err(&e, &[]))) {+                    let (m, o) = (prep_doc["flip"]["matches"].as_u64().unwrap_or(0), prep_doc["flip"]["other"].as_u64().unwrap_or(0));+                    eprintln!("note: bottom turned over {} ({how})", if a == "y" { "top-to-bottom" } else { "left-to-right" });+                    axis = a; prep_doc["flip"] = json!({"axis": axis, "matches": m, "other": o, "how": how}); wr(&f.join("prep.json"), &prep_doc);+                }+            }+            // the bottom photo's corners become where the top's outline lies in it, by the through features both faces show+            let faces = facereg::lay(&mut prep_doc, &axis, k);+            if faces["why"] != "one face only" { wr(&f.join("prep.json"), &prep_doc); }             if let Some(bot) = primary(&prep_doc, "bottom") {                 let bdst = board::frame_corners(wmm, hmm, "bottom", &axis);                 let bpath = f.join("photos").join(bot["file"].as_str().unwrap());                 let first = board::to_frame(&bpath, &corners(bot), &bdst, wmm, hmm, (0.0, 0.0)).unwrap_or_else(|e| err(&e, &[]));                 let px = |v: &[Value]| v.iter().map(|h| { let q = board::to_px(fv(h, "x"), fv(h, "y")); json!({"x": q.0, "y": q.1}) }).collect::<Vec<_>>();                 let (shift, pairs, res) = board::align(&px(&top_holes), &px(&holes_mm(&first)));-                let bimg = if pairs >= 3 { board::to_frame(&bpath, &corners(bot), &bdst, wmm, hmm, shift).unwrap_or_else(|e| err(&e, &[])) } else { first };-                align = json!({"shift_mm": [(shift.0 / board::RES * 1000.0).round() / 1000.0, (shift.1 / board::RES * 1000.0).round() / 1000.0], "pairs": pairs, "residual_mm": (res / board::RES * 1000.0).round() / 1000.0});+                let shift = if faces["registered"] == true { (0.0, 0.0) } else { shift };+                let bimg = if pairs >= 3 && shift != (0.0, 0.0) { board::to_frame(&bpath, &corners(bot), &bdst, wmm, hmm, shift).unwrap_or_else(|e| err(&e, &[])) } else { first };+                align = json!({"shift_mm": [(shift.0 / board::RES * 1000.0).round() / 1000.0, (shift.1 / board::RES * 1000.0).round() / 1000.0], "pairs": pairs, "residual_mm": (res / board::RES * 1000.0).round() / 1000.0, "faces": faces});                 // the shift cannot take out the warp a corner error leaves: pin the bottom to the top through the holes both show                 let sdst: [geom::P; 4] = bdst.map(|p| (p.0 + shift.0, p.1 + shift.1));                 let hp = |v: &[Value]| v.iter().map(|h| board::to_px(fv(h, "x"), fv(h, "y"))).collect::<Vec<_>>();                 let prs = regframe::pairs(&hp(&top_holes), &hp(&holes_mm(&bimg)), 1.0 * board::RES);-                let bimg = match regframe::Quad::fit(&prs) {-                    Some(q) => { align["registration"] = regframe::report(&prs, &q, board::RES); regframe::warp(&bpath, &corners(bot), &sdst, Some(&q), bimg.width(), bimg.height(), 0).unwrap_or_else(|e| err(&e, &[])) }+                let by_holes = regframe::Quad::fit(&prs);+                let bimg = match &by_holes {+                    Some(q) => { align["registration"] = regframe::report(&prs, q, board::RES); align["registrationQuad"] = reframe::quad_json(q); regframe::warp(&bpath, &corners(bot), &sdst, Some(q), bimg.width(), bimg.height(), 0).unwrap_or_else(|e| err(&e, &[])) }+                    None => bimg,+                };+                // a few pin holes leave the faces off between them (0.35 mm on the USB hub): vias that show on both faces as holes hold them everywhere+                let bimg = match viasig::relay(&top_img, &bimg, by_holes.as_ref()) {+                    Some((q, r)) => { align["registration"] = r; align["registrationQuad"] = reframe::quad_json(&q); regframe::warp(&bpath, &corners(bot), &sdst, Some(&q), bimg.width(), bimg.height(), 0).unwrap_or_else(|e| err(&e, &[])) }                     None => bimg,                 };                 save(&bimg, &f.join("board/bottom.jpg"));@@ -763,13 +1033,14 @@ fn main() {                 .into_iter().map(|mut r| { r["pitch_mm"] = json!((fv(&r, "pitch") * 1000.0).round() / 1000.0); r }).collect();             wr(&f.join("detect/holes-top.json"), &json!({"holes": top_holes, "rows": rows}));             let radius = corner_radius(&top_img);-            let doc = json!({"pxPerMm": k, "method": "hole-row pitches, confirmed by a datasheet body", "body": {"id": id, "mm": [lm, wm], "px": [bl, bw], "pxPerMm": k_body, "source": source}, "candidates": cands,+            let doc = json!({"pxPerMm": k, "method": method, "body": basis, "candidates": cands,                              "board_mm": [(wmm * 100.0).round() / 100.0, (hmm * 100.0).round() / 100.0], "corner_mm": radius, "frame": {"origin": [board::ORIGIN, board::ORIGIN], "pxPerMm": board::RES, "bottomFlip": axis}, "alignment": align, "at": ledger::now()});             wr(&f.join("scale.json"), &doc);             p.log(&th, "scale", doc.clone());             reach(&mut p, &fl, "scale");-            let mut h = vec![format!("{k} px/mm (body {id} says {k_body:.2}); board {:.2} x {:.2} mm, corner radius about {radius} mm", wmm, hmm)];-            if align.is_object() { h.push(format!("bottom aligned to top on {} shared holes, residual {} mm", align["pairs"], align["residual_mm"])); }+            let mut h = vec![format!("{k} px/mm ({method}; basis {:.2} px/mm); board {:.2} x {:.2} mm, corner radius about {radius} mm", fv(&basis, "pxPerMm"), wmm, hmm)];+            if align.is_object() { h.push(facereg::say(&align["faces"])); h.push(format!("bottom aligned to top on {} shared holes, residual {} mm", align["pairs"], align["residual_mm"])); }+            if align["registration"]["through"].is_string() { h.push(format!("bottom laid on top through {} {}: {}", align["registration"]["pairs"], align["registration"]["through"].as_str().unwrap_or(""), align["registration"])); }             h.extend(views::record(&f, &p.state, "scale", "board frame written", &th, None));             h.extend(step_hint(&fl, &f));             ok("scale.json and the board frame written (board/top.jpg, board/bottom.jpg, gridded copies)", &h);@@ -783,12 +1054,14 @@ fn main() {             let (holes, rows) = (ht["holes"].as_array().unwrap(), ht["rows"].as_array().unwrap());             let holes_bot: Vec<Value> = items::read_json(&f.join("detect/holes-bottom.json")).ok().and_then(|d| d["holes"].as_array().cloned()).unwrap_or_else(|| holes.clone());             let (mut cands, mut left_out, mut replaced, mut body_first) = (vec![], vec![], 0, 0);+            let mut levels = serde_json::Map::new();             for (side, face) in [("top", Some(&top)), ("bottom", bot.as_ref().filter(|_| *bottom))] {                 let Some(face) = face else { continue };                 let bodies = prep::bodies(face, (0.1 * board::RES) as f32, if side == "top" { 0.4 } else { 0.6 } * board::RES);                 // chip passives are read body first; what the bodies and holes of this face already show to be a bigger part keeps them out of it                 let zones = passives::zones(&bodies, if side == "top" { holes } else { &holes_bot }, rows, holes);-                let (chips, taken) = passives::find(face, &zones);+                let (chips, taken, lv) = passives::find_with(face, &zones);+                levels.insert(side.to_string(), lv.json());                 // bottom parts: only bodies that match a package (the bottom face is mostly mask)                 let old: Vec<Value> = parts::candidates(&bodies, side, 0).into_iter().filter(|c| side == "top" || c["fits"] == json!(true)).collect();                 // where both describe one spot, the body-first passive wins@@ -801,6 +1074,9 @@ fn main() {             cands.extend(parts::hole_parts(holes, rows, n));             // numbered in the order written: the two detectors and the faces each counted from their own start             for (k, c) in cands.iter_mut().enumerate() { c["id"] = json!(format!("P{:03}", k + 1)); }+            // how sure each one is: print, leads and fragments rate low instead of flooding the list+            let board_mm = items::read_json(&f.join("scale.json")).ok().and_then(|s| Some((s["board_mm"][0].as_f64()?, s["board_mm"][1].as_f64()?)));+            candconf::rate(&mut cands, (holes, &holes_bot), board_mm);             std::fs::create_dir_all(f.join("evidence/parts")).ok();             // one map per face searched: a bottom candidate is marked on the bottom photo, where it can be seen             let mut crops = vec![]; let mut map = top.clone(); let mut map_bot = bot.clone().filter(|_| *bottom);@@ -817,16 +1093,21 @@ fn main() {                 if c["method"] == json!("body") {                     // a body-first passive is drawn as its package outline: yellow for a tan body, magenta for a dark one                     draw::poly(map, &passives::corners(q, (size.0 * board::RES, size.1 * board::RES), -fv(c, "rot")), if c["kind"] == json!("tan") { [255, 255, 0] } else { [255, 0, 255] }, 1);-                } else { draw::circle(map, q, 6.0, if c["fits"] == json!(true) { [0, 255, 255] } else { [255, 80, 80] }, 2); }-                draw::label(map, (q.0 + 6.0, q.1 - 14.0), id.trim_start_matches('P'), [255, 255, 0], if c["method"] == json!("body") { 2 } else { 3 });+                } else if c["confidence"].as_f64().unwrap_or(1.0) < candconf::SURE { draw::circle(map, q, 4.0, [150, 150, 150], 1); }+                else { draw::circle(map, q, 6.0, if c["fits"] == json!(true) { [0, 255, 255] } else { [255, 80, 80] }, 2); }+                let unsure = c["confidence"].as_f64().unwrap_or(1.0) < candconf::SURE;+                draw::label(map, (q.0 + 6.0, q.1 - 14.0), id.trim_start_matches('P'), if unsure { [150, 150, 150] } else { [255, 255, 0] }, if c["method"] == json!("body") || unsure { 2 } else { 3 });             }             save(&map, &f.join("evidence/parts-map.jpg"));             if let Some(m) = &map_bot { save(m, &f.join("evidence/parts-map-bottom.jpg")); }             for (k, chunk) in crops.chunks(48).enumerate() { save(&parts::contact_sheet(chunk, 220, 8), &f.join(format!("evidence/parts-sheet-{}.jpg", k + 1))); }             let fits = cands.iter().filter(|c| c["fits"] == json!(true)).count();-            wr(&f.join("detect/parts.json"), &json!({"candidates": cands, "passives": {"method": "body", "found": body_first, "replaced": replaced, "leftOut": left_out}, "at": ledger::now()}));+            wr(&f.join("detect/parts.json"), &json!({"candidates": cands, "passives": {"method": "body", "found": body_first, "replaced": replaced, "leftOut": left_out, "levels": levels}, "at": ledger::now()}));             p.log(&th, "detect", json!({"what": "parts", "candidates": cands.len(), "fits": fits, "bodyFirst": body_first}));+            let sure = cands.iter().filter(|c| c["confidence"].as_f64().unwrap_or(0.0) >= candconf::SURE).count();             let mut h = vec!["Next: `parts init`, then read every crop and complete parts.json.".to_string()];+            h.push(format!("{sure} of {} candidates are rated {} or more (`confidence` in detect/parts.json; `doubt` says what counts against one). The rest are drawn as small grey rings on the map: print, leads, pads and fragments mostly, but real parts hide among them, so read them most sure first.", cands.len(), candconf::SURE));+            for (side, lv) in &levels { h.push(format!("{side} face colour levels: {}", if lv["bodies"].as_u64().unwrap_or(0) > 0 { format!("measured on {} of this photo's own chip bodies", lv["bodies"]) } else { "too few clear chip bodies on this photo to measure its own, so the CM5's numbers scaled by this photo's brightness were used: expect misses".to_string() })); }             h.push(format!("{body_first} chip passives read body first (\"method\": \"body\"): they took the place of {replaced} older candidates on the same spots; {} more were left out as lying inside a bigger part or on a hole (detect/parts.json, passives.leftOut).", left_out.len()));             if !*bottom && bot.is_some() { h.push("The bottom face was not searched for parts: if board/bottom.jpg shows components, run `detect parts --bottom`.".into()); }             ok(&format!("{} candidates ({fits} match a package by size); crops in evidence/parts/, contact sheets evidence/parts-sheet-*.jpg, map evidence/parts-map.jpg{}", cands.len(), if map_bot.is_some() { " and evidence/parts-map-bottom.jpg" } else { "" }), &h);@@ -835,29 +1116,40 @@ fn main() {             let th = thread(&cli); let p = project(&cli); let f = p.flow_dir.clone();             if what == "snap" {                 let mut doc = rd(&f.join("parts.json"));-                let metal_top = snap::metal_plane(&img(&f.join("board/top.jpg")));-                let metal_bot = if f.join("board/bottom.jpg").is_file() { Some(snap::metal_plane(&img(&f.join("board/bottom.jpg")))) } else { None };+                let (face_top, face_bot) = (img(&f.join("board/top.jpg")), if f.join("board/bottom.jpg").is_file() { Some(img(&f.join("board/bottom.jpg"))) } else { None });+                let metal_top = snap::metal_plane(&face_top);+                let metal_bot = face_bot.as_ref().map(snap::metal_plane);+                // each face's chip bodies as the photo shows them, read once: a chip part's size is checked against its own body+                let bodies_top = passives::find_with(&face_top, &[]).0;+                let bodies_bot = face_bot.as_ref().map(|i| passives::find_with(i, &[]).0).unwrap_or_default();+                let mut resized = 0;                 let (mut lines, mut weak) = (vec![], vec![]);                 let cands_all: Vec<Value> = items::read_json(&f.join("detect/parts.json")).ok().and_then(|d| d["candidates"].as_array().cloned()).unwrap_or_default();                 for pt in doc["parts"].as_array_mut().into_iter().flatten() {                     let id = pt["footprint"].as_str().unwrap_or("").to_string(); if id.is_empty() || pt["snap"]["lock"] == json!(true) { continue; }                     let metal = if pt["side"] == json!("bottom") { match &metal_bot { Some(m) => m, None => continue } } else { &metal_top };                     let (x, y, r) = (pt["pos_mm"][0].as_f64().unwrap_or(0.0), pt["pos_mm"][1].as_f64().unwrap_or(0.0), fv(pt, "rot"));-                    // a chip part's size is settled by its lands: the next size down or up takes over when its pads-                    // fit the photo's metal clearly better, so pads never hang over bare board or onto a neighbour-                    let mut id = id; let mut sized = None; let mut size_fits: Option<Value> = None;+                    // a chip part's size is settled by its own body on the photo (`snap::settle_size`); how well each+                    // neighbouring size's pads fit the metal is recorded, can hold a change back, and makes one only+                    // where no body reads and the fit is far better+                    let mut id = id; let mut sized = None; let mut size_fits: Option<Value> = None; let mut size_why: Option<String> = None;                     if let Some((stem, k)) = snap::chip_size(&id) {                         let fit = |fid: &str| -> Option<f64> { let t = kicad_out::footprint_text(&kicad_out::cache_dir(&f), fid, pt).ok()?; let pd = snap::pads(&t); if pd.is_empty() { return None; }                             let c = if pt["anchor"] == json!("center") { Some(snap::pad_centre(&pd)) } else { None }; Some(snap::best_pose_at(metal, &pd, x, y, &[r], snap::reach(&pd), c).3) };                         if let Some(own) = fit(&id) {-                            let mut best = (own, k); let mut fits = serde_json::Map::new(); fits.insert(snap::CHIP_SIZES[k][..4].to_string(), json!((own * 1000.0).round() / 1000.0));-                            for j in [k.wrapping_sub(1), k + 1] { if j < snap::CHIP_SIZES.len() { if let Some(sc) = fit(&format!("{stem}{}", snap::CHIP_SIZES[j])) { fits.insert(snap::CHIP_SIZES[j][..4].to_string(), json!((sc * 1000.0).round() / 1000.0));-                                let need = if j < k { snap::SIZE_MARGIN_DOWN } else { snap::SIZE_MARGIN }; if sc >= own + need && (best.1 == k || sc > best.0) { best = (sc, j); } } } }+                            let mut fits = serde_json::Map::new(); fits.insert(snap::CHIP_SIZES[k][..4].to_string(), json!((own * 1000.0).round() / 1000.0));+                            let mut tried = vec![];+                            for j in [k.wrapping_sub(1), k + 1] { if j < snap::CHIP_SIZES.len() { if let Some(sc) = fit(&format!("{stem}{}", snap::CHIP_SIZES[j])) { fits.insert(snap::CHIP_SIZES[j][..4].to_string(), json!((sc * 1000.0).round() / 1000.0)); tried.push((j, sc)); } } }+                            let body = snap::body_read(if pt["side"] == json!("bottom") { &bodies_bot } else { &bodies_top }, x, y);+                            let (to, why) = snap::settle_size(k, own, &tried, body);+                            let best = (tried.iter().find(|t| t.0 == to).map(|t| t.1).unwrap_or(own), to); size_why = Some(why);                             size_fits = Some(Value::Object(fits));                             if best.1 != k { sized = Some((id.clone(), own, best.0)); id = format!("{stem}{}", snap::CHIP_SIZES[best.1]); }                         }                     }                     if let Some(sf) = size_fits { pt["sizeFits"] = sf; }+                    if let Some(w) = size_why { pt["sizeCheck"] = json!(w); }+                    if sized.is_some() { resized += 1; }                     if let Some((was, a, b)) = &sized { pt["footprint"] = json!(id); pt["package"] = json!(&snap::CHIP_SIZES[snap::chip_size(&id).unwrap().1][..4]); pt["sizedByPads"] = json!({"was": was, "fit": (a * 1000.0).round() / 1000.0, "now": (b * 1000.0).round() / 1000.0}); }                     let Ok(text) = kicad_out::footprint_text(&kicad_out::cache_dir(&f), &id, pt) else { continue };                     let pads = snap::pads(&text); if pads.is_empty() { continue; }@@ -908,8 +1200,9 @@ fn main() {                     if sc < 0.3 { weak.push(format!("{r_} ({id}): pad match {sc:.2} (pads hidden or wrong footprint): placed by {method}")); }                 }                 wr(&f.join("parts.json"), &doc);-                p.log(&th, "parts-snap", json!({"turned": lines.len(), "weak": weak.len()}));+                p.log(&th, "parts-snap", json!({"turned": lines.len(), "weak": weak.len(), "resized": resized}));                 let mut h = lines; h.extend(weak);+                h.push(format!("{resized} chip part(s) changed size: to what their own body measures on the photo or, where no body reads, to pads that fit far better (sizedByPads). Every chip part's sizeCheck says what its body and its pads read."));                 return ok("parts snapped to their pads in the photo (positions now measured; see each part's snap score)", &h);             }             if what == "reconcile" {@@ -961,7 +1254,9 @@ fn main() {             let mut count: HashMap<String, usize> = HashMap::new();             let mut list = vec![]; let mut unassigned = vec![];             for c in &cands {-                if c["fits"] != json!(true) { unassigned.push(json!({"candidate": c["id"], "size_mm": c["size_mm"], "colour": c["colour"], "best": c["matches"].get(0)})); continue; }+                // a candidate from a file written before candidates were rated counts as sure+                let conf = c["confidence"].as_f64().unwrap_or(1.0);+                if c["fits"] != json!(true) || conf < candconf::SURE { unassigned.push(json!({"candidate": c["id"], "size_mm": c["size_mm"], "colour": c["colour"], "best": c["matches"].get(0), "confidence": conf, "doubt": c["doubt"], "package": c["package"]})); continue; }                 let pre = c["prefix"].as_str().unwrap_or("X").to_string();                 let n = { let e = count.entry(pre.clone()).or_default(); *e += 1; *e };                 let r = format!("{pre}{n}");@@ -973,6 +1268,8 @@ fn main() {                 if one_way { e["pin1"] = json!({"mark": "", "evidence": format!("evidence/parts/{}.jpg", c["id"].as_str().unwrap())}); }                 list.push(e);             }+            // most sure first: the top of the list is where the real parts are+            unassigned.sort_by(|a, b| b["confidence"].as_f64().unwrap_or(0.0).partial_cmp(&a["confidence"].as_f64().unwrap_or(0.0)).unwrap());             wr(&f.join("parts.json"), &json!({"parts": list, "unassigned": unassigned, "dismissed": []}));             p.log(&th, "parts-init", json!({"parts": list.len(), "unassigned": unassigned.len()}));             ok(&format!("parts.json: {} parts from candidates, {} candidates unassigned (add each as a part or dismiss it with a reason)", list.len(), unassigned.len()),@@ -1027,7 +1324,7 @@ fn main() {                 "domes" => {                     let lines = write_domes(&f);                     p.log(&th, "copper-domes", json!({"report": lines.clone()}));-                    ok("domes measured on both faces: detect/domes.json, detect/dome-candidates.json", &lines);+                    ok("vias read on both faces: detect/domes.json; every candidate with its reason in detect/dome-candidates.json and detect/via-spots.json", &lines);                 }                 "trace" => {                     // a run that never sketched any copper starts from the outline scale measured@@ -1036,6 +1333,11 @@ fn main() {                         let (x0, y0) = (board::ORIGIN, board::ORIGIN);                         wr(&f.join("copper.json"), &json!({"outline": {"points_mm": [[x0, y0], [x0 + x, y0], [x0 + x, y0 + y], [x0, y0 + y]], "corner_mm": sc["corner_mm"], "evidence": ["board/top.jpg"], "tag": "MEASURED"}, "zones": [], "traces": [], "vias": [], "defaultEvidence": ["evidence/gaps-bottom.jpg"]}));                     }+                    // the pads of the parts placed so far: the reader starts each pad's copper from the pad itself (`padjoin`)+                    let pads_early: Vec<kicad_out::Pad> = if f.join("parts.json").is_file() { let parts = rd(&f.join("parts.json")); let nets_doc = items::read_json(&f.join("nets.json")).unwrap_or(json!({"nets": []})); let empty = json!({"outline": rd(&f.join("copper.json"))["outline"], "zones": [], "traces": [], "vias": []});+                        kicad_out::board_opt(&kicad_out::cache_dir(&f), parts["parts"].as_array().map(|v| v.as_slice()).unwrap_or(&[]), &empty, &nets_doc, 2, "pads", false).map(|b| b.pads).unwrap_or_default() } else { vec![] };+                    if let Ok(dir) = std::env::var("TRACE_DUMP") { let v: Vec<Value> = pads_early.iter().map(|q| { let c = board::to_px(q.x, q.y); json!({"pin": format!("{}.{}", q.part, q.num), "px": [c.0, c.1], "w": q.w * board::RES, "h": q.h * board::RES, "rot": q.rot, "round": q.round, "drill": q.drill, "top": q.on("F.Cu"), "bottom": q.on("B.Cu")}) }).collect(); wr(&PathBuf::from(format!("{dir}/pads.json")), &json!(v)); }+                    let seeds_of = |layer: &str| -> Vec<padjoin::Seed> { padjoin::seeds_from(pads_early.iter().filter(|q| q.on(layer)).map(|q| { let c = board::to_px(q.x, q.y); (q.part.as_str(), c.0, c.1, q.w * board::RES, q.h * board::RES, q.rot, q.round) })) };                     // every island of copper from the photos, turned into pours, tracks and vias, with what they connect                     let mut faces: Vec<islands::Face> = vec![]; let mut h = vec![];                     let mut imgs = vec![];@@ -1046,12 +1348,15 @@ fn main() {                         let board_px = { let o = rd(&f.join("copper.json")); let pts: Vec<(f64, f64)> = o["outline"]["points_mm"].as_array().map(|a| a.iter().map(|q| (q[0].as_f64().unwrap_or(0.0), q[1].as_f64().unwrap_or(0.0))).collect()).unwrap_or_default(); if pts.len() >= 3 { let (x0, y0, x1, y1) = pts.iter().fold((f64::MAX, f64::MAX, f64::MIN, f64::MIN), |a, p| (a.0.min(p.0), a.1.min(p.1), a.2.max(p.0), a.3.max(p.1))); let (a, b) = board::to_px(x0, y0); let (c, d) = board::to_px(x1, y1); Some((a, b, c, d, o["outline"]["corner_mm"].as_f64().unwrap_or(0.0) * board::RES)) } else { None } };                         let shot2 = second_shot(&f, side, &im);                         let dome_px = domes_of(&f, side);-                        let t = trace::trace(&im, &trace::Params { bodies: photomat::carries_parts(&im), board: board_px, second: shot2.as_ref(), domes: &dome_px, light: light_of(&f, side), ..Default::default() });+                        let pad_seeds = seeds_of(layer);+                        let t = trace::trace(&im, &trace::Params { bodies: photomat::face_carries_parts(&f, side, &im), board: board_px, second: shot2.as_ref(), domes: &dome_px, light: light_of(&f, side), pads: &pad_seeds, ..Default::default() });                         faces.push(islands::Face { side, layer, board_colour: islands::board_coloured(&im, &t.labels), t });                         imgs.push((side, im));                     }                     // for working on the reader: read the masks with TRACE_DUMP and stop before anything is written-                    if let Ok(dir) = std::env::var("TRACE_DUMP") { for (side, im) in &imgs { let c = photomat::classify(im, photomat::carries_parts(im)); let g = image::GrayImage::from_fn(c.w as u32, c.h as u32, |x, y| image::Luma([c.cls[y as usize * c.w + x as usize]])); let _ = g.save(format!("{dir}/{side}-cls.png")); } }+                    if let Ok(dir) = std::env::var("TRACE_DUMP") { for (side, im) in &imgs { let c = photomat::classify(im, photomat::face_carries_parts(&f, side, im)); let g = image::GrayImage::from_fn(c.w as u32, c.h as u32, |x, y| image::Luma([c.cls[y as usize * c.w + x as usize]])); let _ = g.save(format!("{dir}/{side}-cls.png")); } }+                    if let Ok(dir) = std::env::var("TRACE_DUMP") { for (fi, (side, im)) in imgs.iter().enumerate() { save(&trace::overlay(im, &faces[fi].t), &PathBuf::from(format!("{dir}/{side}-islands.jpg"))); } }+                    if let Ok(dir) = std::env::var("TRACE_DUMP") { for (fi, (side, _)) in imgs.iter().enumerate() { let b: Vec<u8> = faces[fi].t.labels.iter().flat_map(|l| l.to_le_bytes()).collect(); let _ = std::fs::write(format!("{dir}/{side}-labels.u32"), b); } }                     if std::env::var("TRACE_STOP").is_ok() { return; }                     let parts = rd(&f.join("parts.json"));                     let nets_doc = items::read_json(&f.join("nets.json")).unwrap_or(json!({"nets": []}));@@ -1070,8 +1375,15 @@ fn main() {                     // a drilled pad with a dark middle on the bottom photo has no pin in it                     let open_holes: Vec<(f64, f64)> = imgs.iter().find(|i| i.0 == "bottom").map(|(_, im)| pads.iter().filter(|q| q.drill > 0.0).filter(|q| domes::hole_is_open(im, q.x, q.y, q.drill)).map(|q| (q.x, q.y)).collect()).unwrap_or_default();                     let rl = rules_of(&p.state);-                    let m = islands::measure(&mut faces, &pads, &domes, &pin_net, &islands::Options { track: rules::num(&rl, "track", 0.2), via: rules::via(&rl, "viaSignal"), clearance: rules::num(&rl, "clearance", 0.2), hole_hole: rules::num(&rl, "holeToHole", 0.25), edge_clear: rules::num(&rl, "edgeClearance", 0.5), faces_show_vias, open_holes, bodies: kicad_out::bodies_of(&kicad_out::cache_dir(&f), parts["parts"].as_array().map(|v| v.as_slice()).unwrap_or(&[])), edge: { let o = rd(&f.join("copper.json")); let pts: Vec<(f64, f64)> = o["outline"]["points_mm"].as_array().map(|a| a.iter().map(|q| (q[0].as_f64().unwrap_or(0.0), q[1].as_f64().unwrap_or(0.0))).collect()).unwrap_or_default(); pts.iter().fold((f64::MAX, f64::MAX, f64::MIN, f64::MIN), |a, p| (a.0.min(p.0), a.1.min(p.1), a.2.max(p.0), a.3.max(p.1))) } });+                    let opts = islands::Options { track: rules::num(&rl, "track", 0.2), via: rules::via(&rl, "viaSignal"), clearance: rules::num(&rl, "clearance", 0.2), hole_hole: rules::num(&rl, "holeToHole", 0.25), edge_clear: rules::num(&rl, "edgeClearance", 0.5), faces_show_vias, open_holes, bodies: kicad_out::bodies_of(&kicad_out::cache_dir(&f), parts["parts"].as_array().map(|v| v.as_slice()).unwrap_or(&[])), edge: { let o = rd(&f.join("copper.json")); let pts: Vec<(f64, f64)> = o["outline"]["points_mm"].as_array().map(|a| a.iter().map(|q| (q[0].as_f64().unwrap_or(0.0), q[1].as_f64().unwrap_or(0.0))).collect()).unwrap_or_default(); pts.iter().fold((f64::MAX, f64::MAX, f64::MIN, f64::MIN), |a, p| (a.0.min(p.0), a.1.min(p.1), a.2.max(p.0), a.3.max(p.1))) } };+                    // a face that cannot be read is taken out and said to be unread (`faceread`)+                    let m = faceread::measure(&mut faces, &pads, &domes, &pin_net, &opts);+                    for u in m["unread"].as_array().into_iter().flatten() { h.push(format!("{} face NOT READ: {}. Nothing is drawn from it and its pins join nothing on that face.", u["side"].as_str().unwrap_or(""), u["why"].as_str().unwrap_or(""))); }+                    for u in m["warnings"].as_array().into_iter().flatten() { h.push(format!("{} face, worth a look: {}", u["side"].as_str().unwrap_or(""), u["what"].as_str().unwrap_or(""))); }+                    for r in m["partPinsRefused"].as_array().into_iter().flatten() { h.push(format!("{}: island {} on the {} face held {} of its {} pins; none of them was joined to it", r["part"].as_str().unwrap_or(""), r["island"], r["side"].as_str().unwrap_or(""), r["pins"], r["of"])); }+                    if let Some(n) = m["padPairsRefused"].as_array().map(|a| a.len()).filter(|n| *n > 0) { h.push(format!("{n} two-pad part(s) had both pads on one island; neither pad was joined to it (padPairsRefused in detect/copper-measured.json)")); }                     for (fi, (side, im)) in imgs.iter().enumerate() { save(&trace::overlay(im, &faces[fi].t), &f.join(format!("evidence/copper-islands-{side}.jpg"))); }+                    if let Ok(dir) = std::env::var("TRACE_DUMP") { for (fi, (side, _)) in imgs.iter().enumerate() { let b: Vec<u8> = faces[fi].t.labels.iter().flat_map(|l| l.to_le_bytes()).collect(); let _ = std::fs::write(format!("{dir}/{side}-labels-final.u32"), b); } }                     // the seen copper replaces the sketched copper; the old file is kept beside it                     let old = rd(&f.join("copper.json"));                     if !f.join("copper.sketched.json").is_file() { wr(&f.join("copper.sketched.json"), &old); }@@ -1081,7 +1393,7 @@ fn main() {                     c["zones"] = m["zones"].clone(); c["traces"] = m["traces"].clone(); c["vias"] = vias_out; c["from"] = json!("islands");                     wr(&f.join("copper.json"), &c);                     wr(&f.join("detect/copper-island-accounting.json"), &m["accounting"]);-                    wr(&f.join("detect/copper-measured.json"), &json!({"trackPadConflicts": m["trackPadConflicts"], "report": m["report"], "groups": m["groups"], "pinAdds": m["pinAdds"], "pinClashes": m["pinClashes"], "viaClashes": m["viaClashes"], "mixed": m["mixed"], "joins": m["joins"], "profile": m["profile"], "viasDropped": m["viasDropped"]}));+                    wr(&f.join("detect/copper-measured.json"), &json!({"trackPadConflicts": m["trackPadConflicts"], "report": m["report"], "groups": m["groups"], "pinAdds": m["pinAdds"], "pinClashes": m["pinClashes"], "viaClashes": m["viaClashes"], "mixed": m["mixed"], "joins": m["joins"], "profile": m["profile"], "viasDropped": m["viasDropped"], "padPairsRefused": m["padPairsRefused"], "partPinsRefused": m["partPinsRefused"], "padPairsSeen": m["padPairsSeen"], "unread": m["unread"], "warnings": m["warnings"]}));                     // pins with no net yet that the copper names: seen, so they join that net                     let mut nd = nets_doc.clone(); let mut added = 0;                     if let Some(list) = nd["nets"].as_array_mut() { for a in m["pinAdds"].as_array().into_iter().flatten() {@@ -1278,7 +1590,8 @@ fn main() {             let pcb = kdir.join(format!("{name}.kicad_pcb"));             std::fs::write(&pcb, &b.text).unwrap_or_else(|e| err(&e.to_string(), &[]));             let pro = kdir.join(format!("{name}.kicad_pro"));-            std::fs::write(&pro, rules::kicad_pro(&rules_of(&p.state), &name)).unwrap_or_else(|e| err(&e.to_string(), &[]));+            std::fs::write(&pro, unrouted::with_evidence_classes(&rules::kicad_pro(&rules_of(&p.state), &name), &f)).unwrap_or_else(|e| err(&e.to_string(), &[]));+            let hidden = unrouted::write(&f);             let mut faces = vec![];             for (layer, file, side) in [("F.Cu", "board/top.jpg", "top"), ("B.Cu", "board/bottom.jpg", "bottom")] {                 if !f.join(file).is_file() { continue; }@@ -1295,28 +1608,44 @@ fn main() {                 save(&o, &f.join(format!("evidence/overlay-{side}.jpg")));                 faces.push(side);             }-            // the schematic from the same files: blocks (or one block of every part), footprint pads as pins, nets as labels-            let parts_doc = rd(&f.join("parts.json"));-            let blocks = items::read_json(&f.join("blocks.json")).unwrap_or_else(|_| json!({"blocks": [{"id": "all", "name": "All parts", "function": "", "parts": parts_doc["parts"].as_array().map(|a| a.iter().map(|p| p["ref"].clone()).collect::<Vec<_>>()).unwrap_or_default()}]}));-            let nets_doc = items::read_json(&f.join("nets.json")).unwrap_or(json!({"nets": []}));+            // the schematic from the same files: a root sheet and one sheet per block (sch.rs)             let schp = kdir.join(format!("{name}.kicad_sch"));-            std::fs::write(&schp, sch::schematic(&kicad_out::cache_dir(&f), parts_doc["parts"].as_array().map(|v| v.as_slice()).unwrap_or(&[]), &blocks, &nets_doc, &name)).unwrap_or_else(|e| err(&e.to_string(), &[]));+            let sch_hints = sch::write(&f, &kdir, &name).unwrap_or_else(|e| err(&format!("schematic: {e}"), &[]));             p.state["kicad"]["pcb"] = json!(abs(&pcb)); p.state["kicad"]["sch"] = json!(abs(&schp)); p.save().ok();-            p.log(&th, "build", json!({"pcb": abs(&pcb), "layers": n, "warnings": b.warnings, "pads": b.pads.len(), "zones": b.fills.len()}));+            p.log(&th, "build", json!({"pcb": abs(&pcb), "layers": n, "warnings": b.warnings, "pads": b.pads.len(), "zones": b.zones_filled}));             let cur = flow::current(&fl, &f).map(|s| s.name.clone()).unwrap_or("mimic".into());-            let mut h: Vec<String> = b.warnings.iter().map(|w| format!("warning: {w}")).collect();+            // parts the board does not carry are said first and kept in a file, so `auto` and `check mimic` name the footprint wanted+            let left = kicad_out::left_out_rows(&b.warnings, items::read_json(&f.join("parts.json")).ok().and_then(|d| d["parts"].as_array().cloned()).unwrap_or_default().as_slice());+            wr(&f.join(kicad_out::LEFT_OUT), &json!({"leftOut": left, "noFill": b.no_fill}));+            let mut h: Vec<String> = left.iter().map(|r| format!("PART LEFT OUT: {}: {}", r["ref"].as_str().unwrap_or("?"), r["why"].as_str().unwrap_or(""))).collect();+            h.extend(b.no_fill.iter().map(|z| format!("POUR WITH NO FILL: {} ({} mm2, net {}): {}. It stays in copper.json and is drawn as an outline; `check mimic` lists it as a finding", z["id"].as_str().unwrap_or("?"), z["areaMm2"], z["net"].as_str().unwrap_or(""), z["why"].as_str().unwrap_or(""))));+            h.extend(b.warnings.iter().filter(|w| !w.starts_with(kicad_out::LEFT_OUT_MARK)).map(|w| format!("warning: {w}")));             h.push(format!("overlays: {}", faces.iter().map(|s| format!("evidence/overlay-{s}.jpg")).collect::<Vec<_>>().join(", ")));+            h.extend(sch_hints);+            if routes_for(&f, n).is_none() && hidden["count"].as_u64().unwrap_or(0) > 0 { h.push(format!("{} hidden connection(s) are left unrouted: each is a ratsnest line in KiCad, in a net class named for its evidence ({}); the list is reverse-flow/unrouted.json", hidden["count"], hidden["byTag"])); }             h.extend(views::record(&f, &p.state, &cur, &format!("build ({n} layers)"), &th, None));-            ok(&format!("{} ({n} copper layers, {} pads, {} zones filled)", abs(&pcb), b.pads.len(), b.fills.len()), &h);+            ok(&format!("{} ({n} copper layers, {} pads, {} zones filled)", abs(&pcb), b.pads.len(), b.zones_filled), &h);+        }+        Cmd::Schematic => {+            let th = thread(&cli); let mut p = project(&cli); let f = p.flow_dir.clone();+            let name = p.dir.file_name().map(|x| x.to_string_lossy().to_string()).unwrap_or("board".into());+            let kdir = p.dir.join("kicad"); std::fs::create_dir_all(&kdir).ok();+            let h = sch::write(&f, &kdir, &name).unwrap_or_else(|e| err(&format!("schematic: {e}"), &[]));+            let schp = kdir.join(format!("{name}.kicad_sch"));+            let pro = kdir.join(format!("{name}.kicad_pro"));+            if !pro.is_file() { std::fs::write(&pro, rules::kicad_pro(&rules_of(&p.state), &name)).unwrap_or_else(|e| err(&e.to_string(), &[])); }+            p.state["kicad"]["sch"] = json!(abs(&schp)); p.save().ok();+            p.log(&th, "schematic", json!({"sch": abs(&schp)}));+            ok(&abs(&schp), &h);         }         Cmd::Check { what, pcb, skip_earlier } => {             let th = thread(&cli); let mut p = project(&cli);             let fdir = p.flow_dir.clone(); let f = &fdir;             let pcbp = pcb.as_ref().map(PathBuf::from).or_else(|| p.state["kicad"]["pcb"].as_str().map(PathBuf::from));-            let step = match what.as_str() { "prep" => "intake", "nets" | "layers" => "connect", w => w };+            let step = match what.as_str() { "prep" => "intake", "nets" | "layers" | "schematic" => "connect", "pinout" => "parts", w => w };             let idx = fl.steps.iter().position(|s| s.name == step).unwrap_or(0);-            if let Some(i) = fl.steps.iter().position(|s| s.name == step) {-                let behind: Vec<String> = fl.steps[..i].iter().filter(|s| !s.done.iter().all(|d| flow::exists(f, d))).map(|s| s.name.clone()).collect();+            {+                let behind = steps_behind(&fl, f, step);                 if !behind.is_empty() && skip_earlier.is_none() { err(&format!("check {what}: earlier step(s) not done or stale: {}", behind.join(", ")), &step_hint(&fl, f)); }             }             let was_stale: Vec<String> = fl.steps.get(idx).map(|s| s.done.iter().flat_map(|d| flow::stale(f, d)).collect()).unwrap_or_default();@@ -1325,13 +1654,16 @@ fn main() {                 "parts" => checks::parts(f), "blocks" => checks::blocks(f), "copper" => checks::copper(f),                 "mimic" => { let Some(b) = &pcbp else { err("check mimic needs --pcb <board> (or run `build`)", &[]) }; checks::mimic(f, b, &p.state) }                 "nets" => checks::nets(f), "layers" => checks::layers(f, pcbp.as_deref()), "review" => checks::review(f), "tests" => checks::tests(f),+                "schematic" => schcheck::schematic(f, &p.state),+                // the chain part, orientation, pinout: fails on a chip whose pinout is known and whose orientation is not settled+                "pinout" => { let asks = pinnets::unsettled(f); if asks.is_empty() { Ok(checks::Pass { summary: json!({"orientationNotSettled": 0, "pinoutNetsCurrent": !pinnets::stale(f)}), hints: vec![format!("the chain for every part: reverse-flow/{} (written by `connect pinout`)", pinnets::FILE)], files: vec!["parts.json".into()] }) } else { Err(asks) } }                 "fidelity" => {                     let Some(b) = &pcbp else { err("check fidelity needs --pcb <board> (or run `build`)", &[]) };                     // the photos are scored against the copper read from them: routed copper is not read and moves what was, so a board with fresh routes in it is scored without them                     let routed = pcb.is_none() && [2usize, 4, 6, 8, 10, 12].iter().any(|n| routes_for(f, *n).is_some());                     if routed { let tmp = f.join(".fidelity-board.kicad_pcb"); std::fs::write(&tmp, build_board_for(f, 2, true).text).unwrap_or_else(|e| err(&e.to_string(), &[])); let r = fidelity::check(f, &tmp); let _ = std::fs::remove_file(&tmp); r } else { fidelity::check(f, b) }                 }-                _ => err(&format!("unknown check {what}"), &["prep | parts | blocks | copper | mimic | nets | layers | fidelity | review | tests".into()]),+                _ => err(&format!("unknown check {what}"), &["prep | parts | blocks | copper | mimic | nets | layers | schematic | fidelity | review | tests".into()]),             };             match r {                 Err(e) => { p.log(&th, "check", json!({"what": what, "ok": false, "errors": e})); err(&format!("check {what}: {} problem(s)", e.len()), &e) }@@ -1344,15 +1676,24 @@ fn main() {                     p.log(&th, "check", json!({"what": what, "ok": true, "summary": pass.summary}));                     note_progress(&mut p, &fl, idx, &was_stale); p.save().ok();                     let mut h = pass.hints.clone();-                    h.extend(views::record(f, &p.state, step, &format!("check {what} passed"), &th, None));+                    h.extend(views::record(f, &p.state, step, &if what == "copper" && pass.summary["notRead"].is_array() { "check copper failed and is recorded: copper NOT READ on a face".to_string() } else { format!("check {what} passed") }, &th, None));                     h.extend(step_hint(&fl, f));-                    ok(&format!("check {what} passed: {}", pass.summary), &h);+                    // a failed reading that only a program fix can change is said as a failure, recorded, and the run goes on+                    match notread::headline(&pass.summary).filter(|_| what == "copper") { Some(line) => ok(&line, &h), None => ok(&format!("check {what} passed: {}", pass.summary), &h) }                 }             }         }-        Cmd::Layers { what } => {+        Cmd::Layers { what, count, source, evidence, kind } => {             let _th = thread(&cli); let p = project(&cli); let f = p.flow_dir.clone();             match what.as_str() {+                "state" => {+                    let (doc, mut h) = layerstate::state(&f, &layerstate::Ask { count: *count, source: source.as_deref(), evidence: evidence.as_deref(), kind: kind.as_deref() }).unwrap_or_else(|e| err(&e, &["layers state [--count <n> --source \"<document>\" | --count <n> --evidence <file> --kind edge-micrograph|cross-section|xray|ct]".into()]));+                    p.log(&_th, "layers-state", json!({"proposed": doc["proposed"], "tag": doc["tag"], "minimum": doc["minimum"]}));+                    let hidden = unrouted::write(&f);+                    h.push(format!("{} hidden connection(s) stay unrouted (reverse-flow/unrouted.json): `connect route` is optional and no step needs it", hidden["count"]));+                    h.extend(step_hint(&fl, &f));+                    ok(&match doc["proposed"].as_u64() { Some(n) => format!("layers.json: {n} layers stated, tag {}", doc["tag"].as_str().unwrap_or("")), None => format!("layers.json: layer count UNKNOWN, at least {} from the photos ({})", doc["minimum"]["layers"], doc["minimum"]["tag"].as_str().unwrap_or("")) }, &h);+                }                 "classify" => {                     for side in ["top", "bottom"] {                         let im = img(&f.join(format!("board/{side}.jpg")));@@ -1369,7 +1710,7 @@ fn main() {                     wr(&keep, &doc);                     ok(&format!("{} written", photolayers::FILE), &[format!("silk {} / {} polygons, {} mm2 / {} mm2; {} pads shaped; {} holes seen, {} on no pad; {} exposed patches on no pad", doc["silk"]["F"].as_array().map(|a| a.len()).unwrap_or(0), doc["silk"]["B"].as_array().map(|a| a.len()).unwrap_or(0), doc["silkAreaMm2"]["F"], doc["silkAreaMm2"]["B"], doc["pads"].as_array().map(|a| a.len()).unwrap_or(0), doc["holesSeen"], doc["freeHoles"].as_array().map(|a| a.len()).unwrap_or(0), doc["freeMetal"].as_array().map(|a| a.len()).unwrap_or(0)), "Next: `build`, then `check fidelity`.".into()]);                 }-                _ => err("layers classify | trace", &[]),+                _ => err("layers classify | trace | state", &[]),             }         }         Cmd::Snapshot { name, dir } => {@@ -1403,12 +1744,14 @@ fn main() {                     let bodies = kicad_out::bodies_of(&kicad_out::cache_dir(&f), rd(&f.join("parts.json"))["parts"].as_array().map(|v| v.as_slice()).unwrap_or(&[]));                     let mut faces: Vec<attrib::FaceIn> = vec![];                     for (side, layer, file) in [("top", "F.Cu", "board/top.jpg"), ("bottom", "B.Cu", "board/bottom.jpg")] {-                        if !f.join(file).is_file() { err(&format!("{file} is missing: attribution needs both faces"), &[]); }+                        // a run with one face photographed goes on with that face; the other is recorded as NOT PHOTOGRAPHED (`notread::missing`)+                        if !f.join(file).is_file() { if notread::missing(&f).iter().any(|x| x.side == side) { continue; } err(&format!("{file} is missing: `scale` writes the board images"), &[]); }                         let im = img(&f.join(file));                         let board_px = { let o = rd(&f.join("copper.json")); let pts: Vec<(f64, f64)> = o["outline"]["points_mm"].as_array().map(|a| a.iter().map(|q| (q[0].as_f64().unwrap_or(0.0), q[1].as_f64().unwrap_or(0.0))).collect()).unwrap_or_default(); if pts.len() >= 3 { let (x0, y0, x1, y1) = pts.iter().fold((f64::MAX, f64::MAX, f64::MIN, f64::MIN), |a, p| (a.0.min(p.0), a.1.min(p.1), a.2.max(p.0), a.3.max(p.1))); let (a, b) = board::to_px(x0, y0); let (c, d) = board::to_px(x1, y1); Some((a, b, c, d, o["outline"]["corner_mm"].as_f64().unwrap_or(0.0) * board::RES)) } else { None } };                         let shot2 = second_shot(&f, side, &im);                         let dome_px = domes_of(&f, side);-                        let mut t = trace::trace(&im, &trace::Params { bodies: photomat::carries_parts(&im), board: board_px, second: shot2.as_ref(), domes: &dome_px, light: light_of(&f, side), ..Default::default() });+                        let pad_seeds = padjoin::seeds_from(pads.iter().filter(|q| q.on(layer)).map(|q| { let c = board::to_px(q.x, q.y); (q.part.as_str(), c.0, c.1, q.w * board::RES, q.h * board::RES, q.rot, q.round) }));+                        let mut t = trace::trace(&im, &trace::Params { bodies: photomat::face_carries_parts(&f, side, &im), board: board_px, second: shot2.as_ref(), domes: &dome_px, light: light_of(&f, side), pads: &pad_seeds, ..Default::default() });                         // the same islands `copper trace` kept: what it read on a part's top is not copper here either                         islands::clear_bodies(&mut t, &im, layer, &bodies);                         faces.push(attrib::FaceIn { side, layer, img: im, t });@@ -1421,9 +1764,38 @@ fn main() {                     ok(&format!("detect/attrib/ written: {} pieces of copper, {} pads, {} dome candidates", m["pieces"], m["pads"], m["domes"]), &["Next: `connect attribute`, and `connect attribute --vias <file>` whenever the via list improves. Neither reads a photo.".into()]);                 }                 "attribute" => {-                    let s = attrib::attribute(&f, vias.as_deref().map(Path::new)).unwrap_or_else(|e| err(&e, &["`connect measure` writes what this reads.".into()]));+                    // a run in which nobody has named a net yet starts from none: attribution names the pins that share copper+                    let started = !f.join("nets.json").is_file();+                    if started { wr(&f.join("nets.json"), &json!({"nets": []})); }+                    let s = attrib::attribute(&f, vias.as_deref().map(Path::new)).unwrap_or_else(|e| err(&e, &[if e.contains("measure") { "`connect measure` reads the photos once and writes what this reads: run it first.".to_string() } else { "`next` names the step that writes what is missing.".to_string() }]));+                    bench::reapply(&f);                     p.log(&th, "connect-attribute", s["ladder"].clone());-                    ok("copper.json and nets.json attributed, geometry untouched; every item has a net, a rung and how it was found", &attrib::one_line(&s));+                    let mut h = attrib::one_line(&s);+                    // names the copper took from datasheet pins are tagged for what they are; the joins stay the copper's+                    match dspins::stamp(&f) { Ok(d) => { let k = d["netsNamedFromDatasheet"].as_array().map(|a| a.len()).unwrap_or(0); if k > 0 || d["netsCarryingDatasheetPins"].as_u64().unwrap_or(0) > 0 { h.push(format!("datasheet pins: {k} net(s) the copper proves took their name from the pin on them (name tagged DATASHEET with the document cited, the join as seen); {} of {} nets carry a pin a datasheet names", d["netsCarryingDatasheetPins"], d["nets"])); } } Err(e) => h.push(format!("datasheet names were not stamped: {e}")) }+                    // a pin a datasheet leaves unconnected that the copper put on a net is drawn on that net, as nets.json has it+                    match pinnets::reconcile(&f) { Ok(v) if !v.is_empty() => h.push(format!("{} pin(s) a datasheet leaves unconnected sit on copper joined to a net and are no longer marked not connected: {} (each is named on its net as `unconnectedPinsOnCopper`, and the witness raises it)", v.len(), v.join(", "))), Ok(_) => {}, Err(e) => h.push(format!("no-connect pins were not reconciled: {e}")) }+                    // the datasheets as a second witness: every pin of a chip with a pins file against the copper just attributed+                    match witness::run(&f, true) { Ok(w) => { if w["chips"].as_u64().unwrap_or(0) > 0 { h.extend(witness::one_line(&w)); } } Err(e) => h.push(format!("the datasheet witness did not run: {e}")) }+                    if started { h.push("nets.json did not exist, so it was started empty: every net here is named from copper (CU_<pin>). Pin names from datasheets go in nets.json whenever you have them ({\"name\", \"pins\": [\"U1.3\"], \"tag\": \"DATASHEET\", \"source\": \"<document, page>\"}); run `connect attribute` again after".into()); }+                    let contradicted = items::read_json(&f.join("nets.json")).ok().map(|d| d["nets"].as_array().into_iter().flatten().filter(|n| n["contradiction"].is_object()).map(|n| n["name"].as_str().unwrap_or("").to_string()).collect::<Vec<_>>()).unwrap_or_default();+                    if !contradicted.is_empty() { h.push(format!("{} net(s) are contradicted by the copper and are now INFERRED with a test, each with the finding in nets.json `contradiction`: {}", contradicted.len(), contradicted.iter().take(12).cloned().collect::<Vec<_>>().join(", "))); }+                    // attribution writes nets into copper.json: what was built on the old nets is stale, and the next lines say what to run+                    h.extend(step_hint(&fl, &f));+                    ok("copper.json and nets.json attributed, geometry untouched; every item has a net, a rung and how it was found", &h);+                }+                "pinout" => {+                    // nets from the parts first: identity, orientation, pinout, then a net for every pin with a known function, by named rules+                    let s = pinnets::build(&f).unwrap_or_else(|e| err(&e, &[]));+                    p.log(&th, "connect-pinout", s.clone());+                    let mut h = pinnets::one_line(&s); h.extend(step_hint(&fl, &f));+                    ok(&format!("{} net(s) from the pinouts in nets.json: {} DATASHEET, {} INFERRED with a test; none is SEEN or MEASURED", s["pinoutNets"], s["pinoutNetsDatasheet"], s["pinoutNetsInferred"]), &h);+                }+                "witness" => {+                    // changes neither copper nor nets: it records, per pin and per net, whether the datasheet agrees with the copper+                    let w = witness::run(&f, true).unwrap_or_else(|e| err(&e, &["`connect attribute` writes the copper graph this reads; `datasheet status` says which chips have a pins file.".into()]));+                    p.log(&th, "connect-witness", w.clone());+                    ok(&format!("datasheet witness: {} agree, {} disagree, {} not visible, over {} pin(s) of {} chip(s)", w["agree"], w["disagree"], w["notVisible"], w["pins"], w["chips"]), &witness::one_line(&w));                 }                 "extract" => {                     let b = build_board(&f, 2);@@ -1522,7 +1894,13 @@ fn main() {                         println!("  jumping: {last_why}");                     }                     let mut l = items::read_json(&f.join("layers.json")).unwrap_or(json!({}));-                    write_proposal(&mut l, &cands, closed_at);+                    // routing is an optional later stage: a count stated from outside evidence stays, and routing only adds what it found+                    let stated = l["method"] == json!("stated") && l["proposed"].as_u64().is_some();+                    if stated {+                        let mut said = json!({}); write_proposal(&mut said, &cands, closed_at);+                        l["candidates"] = said["candidates"].clone(); l["trail"] = said["trail"].clone(); l["routingSaid"] = said["why"].clone();+                        l["routing"] = json!({"ran": true, "note": "an optional `connect route`: one way to make the hidden connections, not what is inside the board. The count above is the stated one"});+                    } else { write_proposal(&mut l, &cands, closed_at); l["method"] = json!("routed"); }                     let pick = l["proposed"].as_u64();                     let trail: Vec<String> = l["trail"].as_array().into_iter().flatten().filter_map(|x| x.as_str().map(str::to_string)).collect();                     l["escalation"] = json!(match closed_at { Some(n) => format!("{n} layers closed every connection and passed the gate"), None => format!("not closed; last: {last_why}") });@@ -1545,6 +1923,10 @@ fn main() {                     if lines.is_empty() { ok("every measurement agrees with the files", &["Run the pass: `review pass`".into()]) } else { ok(&format!("{} finding(s)", lines.len()), &lines) }                 }                 "pass" => {+                    // one rule for the review and for every check: not while an earlier step is not done or stale. A reader fault that+                    // `check copper` recorded as NOT READ is a step done with its failure carried, so the review runs and counts it+                    let behind = steps_behind(&fl, &f, "review");+                    if !behind.is_empty() { err(&format!("review pass: earlier step(s) not done or stale: {}", behind.join(", ")), &step_hint(&fl, &f)); }                     if let Some(t) = target {                         if !(*t > 0.0 && *t <= 100.0) { err("--target must be above 0 and at most 100", &[]); }                         p.state["confidenceTarget"] = json!(t); p.save().ok();@@ -1568,18 +1950,22 @@ fn main() {                     doc["passes"].as_array_mut().unwrap().push(json!({"n": k, "at": ledger::now(), "aiThread": th, "note": note, "target": t, "maxPasses": max, "verdict": verdict, "findings": findings.len(), "fullPass": s.full, "hashes": checks::current_hashes(&f), "scores": s.json()}));                     wr(&path, &doc);                     wr(&f.join("confidence-scores.json"), &json!({"derived": "written by the program for reading; nothing reads it back", "at": ledger::now(), "verdict": verdict, "scores": s.json()}));-                    p.log(&th, "review", json!({"pass": k, "verdict": verdict, "overall": s.overall, "target": t, "findings": findings.len(), "scores": s.cats.iter().map(|c| (c.category.clone(), json!(c.score))).collect::<serde_json::Map<_, _>>()}));+                    p.log(&th, "review", json!({"pass": k, "verdict": verdict, "photo": s.photo, "bench": s.bench, "overall": s.overall, "target": t, "findings": findings.len(), "scores": s.cats.iter().map(|c| (c.category.clone(), json!(c.score))).collect::<serde_json::Map<_, _>>()}));                     reach(&mut p, &fl, "review");                     let _ = write!(std::io::stdout(), "{}", confidence::table(&s));-                    let mut h = views::record(&f, &p.state, "review", &format!("review pass {k}: {verdict}, overall {:.1}", s.overall), &th, None);+                    let mut h = views::record(&f, &p.state, "review", &format!("review pass {k}: {verdict}, photo score {:.1}, bench score {:.1}", s.photo, s.bench), &th, None);                     h.push(format!("{} finding(s) from the re-measure; `review measure` lists them", findings.len()));                     h.extend(drawn);                     match verdict {-                        "TARGET MET" => h.push("every category reached the target: run `check review`".into()),-                        "STALLED" => { h.push("a full pass raised no score. Each category below the target:".into()); h.extend(confidence::stall_lines(&s)); h.push("run `check review` to record the stall, or do one of the things above and pass again".into()); }+                        "TARGET MET" => h.push("the photo score reached the target: run `check review`".into()),+                        "STALLED" => { h.push("a full pass raised no photo figure. Each category whose photo figure is below the target:".into()); h.extend(confidence::stall_lines(&s)); h.push("run `check review` to record the stall, or do one of the things above and pass again".into()); }                         "STOPPED" => { h.push(format!("the limit of {max} passes was reached")); h.extend(confidence::stall_lines(&s)); }-                        _ => { h.extend(s.cats.iter().filter(|c| c.score < t).map(|c| format!("{} {:.1}: {}", c.category, c.score, c.raise))); h.push("do those, then `review pass` again".into()); }+                        _ => { h.extend(s.below().iter().filter_map(|c| c.photo.as_ref().map(|p| format!("{} photo figure {:.1}: {}", c.category, p.score, p.raise)))); h.push("do those, then `review pass` again".into()); }                     }+                    // the bench score beside it: reported, never waited for+                    h.push(format!("bench score {:.1} (outside the target). What would raise it:", s.bench));+                    h.extend(confidence::bench_lines(&s));+                    h.extend(auto::now_lines(&f, &fl));                     ok(&format!("review pass {k}: {}", confidence::overall_line(&s, verdict)), &h);                 }                 "record" => err("`review record` is gone: levels are no longer typed", &["Run `review pass`: the program computes each score from measurements and audits (docs/method.md).".into()]),@@ -1645,14 +2031,37 @@ fn main() {             let (s, verdict) = report::current(&p.flow_dir);             wr(&p.path("confidence-scores.json"), &json!({"derived": "written by the program for reading; nothing reads it back", "at": ledger::now(), "verdict": verdict, "scores": s.json()}));             let _ = write!(std::io::stdout(), "{}\n{}\nEvidence tags (what the items claim, not what was checked):\n{table}", confidence::table(&s), confidence::inputs_text(&s));-            ok(&confidence::overall_line(&s, &verdict), &["confidence-scores.json written; docs/method.md states every formula".into()]);+            let mut h = vec!["confidence-scores.json written; docs/method.md states every formula and which score each ceiling belongs to".to_string()];+            h.push(format!("bench score {:.1} (outside the target). What would raise it:", s.bench));+            h.extend(confidence::bench_lines(&s));+            ok(&confidence::overall_line(&s, &verdict), &h);         }         Cmd::Tests { what } => {             let th = thread(&cli); let p = project(&cli); let f = p.flow_dir.clone();             if what != "generate" { err("tests generate", &[]); }             let all = items::all(&f);             let mut tests: Vec<Value> = items::read_json(&f.join("tests.json")).ok().and_then(|t| t["tests"].as_array().cloned()).unwrap_or_default();+            // a generated test that settles only items that no longer exist (the copper was traced again, a part was renamed) and that no item names+            // is dropped, with a count: `check tests` refuses a test that settles nothing known, and nobody wrote this one by hand+            let (ids, named): (HashSet<String>, HashSet<String>) = (all.iter().map(|i| i.id.clone()).collect(), all.iter().flat_map(|i| i.tests.iter().cloned()).collect());+            let is_item = |x: &str| ids.contains(x) || x.starts_with("face:") || x.rsplit_once('.').map(|(a, _)| ids.contains(a)).unwrap_or(false);+            let before = tests.len();+            tests.retain(|t| t["generated"] != json!(true) || named.contains(t["id"].as_str().unwrap_or("")) || t["settles"].as_array().into_iter().flatten().filter_map(|x| x.as_str()).any(&is_item));+            let dropped = before - tests.len();+            for t in tests.iter_mut().filter(|t| t["generated"] == json!(true)) { if let Some(st) = t["settles"].as_array() { let keep: Vec<Value> = st.iter().filter(|x| x.as_str().map(&is_item).unwrap_or(false)).cloned().collect(); if !keep.is_empty() && keep.len() < st.len() { t["settles"] = json!(keep); } } }             let mut added = 0;+            // a datasheet disagreement brings its own test, with the pins to probe; one whose finding cleared goes+            let raised = witness::tests(&f);+            tests.retain(|t| !t["id"].as_str().unwrap_or("").starts_with("T-witness-") || raised.iter().any(|r| r["id"] == t["id"]));+            for t in raised { if !tests.iter().any(|x| x["id"] == t["id"]) { tests.push(t); added += 1; } }+            // an ERC finding from the copper as read brings a continuity test; it goes when the finding clears+            let erc = ercfind::tests(&f);+            tests.retain(|t| !t["id"].as_str().unwrap_or("").starts_with("T-erc-") || erc.iter().any(|r| r["id"] == t["id"]));+            for t in erc { if !tests.iter().any(|x| x["id"] == t["id"]) { tests.push(t); added += 1; } }+            // a face whose copper was not read brings its own test; it goes when the face is read+            let unread = notread::tests(&f);+            tests.retain(|t| !t["id"].as_str().unwrap_or("").starts_with("T-copper-not-read-") || unread.iter().any(|r| r["id"] == t["id"]));+            for t in unread { if !tests.iter().any(|x| x["id"] == t["id"]) { tests.push(t); added += 1; } }             for it in all.iter().filter(|i| i.open() || !i.tests.is_empty()) {                 for tid in if it.tests.is_empty() { vec![format!("T-{}", it.id.replace(':', "-"))] } else { it.tests.clone() } {                     if tests.iter().any(|t| t["id"] == json!(tid)) { continue; }@@ -1668,27 +2077,107 @@ fn main() {                 }             }             wr(&f.join("tests.json"), &json!({"tests": tests}));-            p.log(&th, "tests-generate", json!({"added": added}));-            ok(&format!("tests.json: {added} test(s) added, {} in all", tests.len()), &["Edit the expected results the generator could not know (rail voltages from the divider, marking candidates), then `check tests`.".into()]);+            p.log(&th, "tests-generate", json!({"added": added, "dropped": dropped}));+            let mut h = vec!["Edit the expected results the generator could not know (rail voltages from the divider, marking candidates), then `check tests`.".to_string()];+            if dropped > 0 { h.push(format!("{dropped} generated test(s) dropped: every item they settled is gone from the artifacts")); }+            ok(&format!("tests.json: {added} test(s) added, {} in all", tests.len()), &h);         }-        Cmd::Report => {+        Cmd::Report { draft } => {             let th = thread(&cli); let mut p = project(&cli);-            let md = report::tables(&p.flow_dir, &fl);+            // the schematic's sheets, each with a picture under reverse-flow/schematic/, so the report shows them without KiCad+            let md = report::tables(&p.flow_dir, &fl) + &pinnets::report_section(&p.flow_dir) + &schcheck::report_section(&p.flow_dir, &p.state);             std::fs::write(p.path("report-tables.md"), &md).unwrap_or_else(|e| err(&e.to_string(), &[]));             let (s, verdict) = report::current(&p.flow_dir);             let line = confidence::overall_line(&s, &verdict);+            // a starting report, only when there is none: the program's tables with the computed line, for the AI to write around+            if *draft && !p.path("report.md").is_file() {+                std::fs::write(p.path("report.md"), report::draft(&p.flow_dir, &line)).unwrap_or_else(|e| err(&e.to_string(), &[]));+                p.log(&th, "report-draft", json!({}));+            }             let hand = std::fs::read_to_string(p.path("report.md")).ok();-            let Some(text) = hand else { ok("report-tables.md written", &[format!("Write report.md around the tables, with this line word for word: {line}"), "Then run `report` again.".into()]); return };+            let Some(text) = hand else { ok("report-tables.md written", &[format!("Write report.md around the tables, with this line word for word: {line}"), "`report --draft` writes a starting report.md from the tables with that line in it. Then run `report` again.".into()]); return };             if !text.contains(&line) { err("report.md does not carry the computed confidence", &[format!("It must say, word for word: {line}")]); }-            if let Some(l) = confidence::false_claim(&text, &s) { err("report.md claims more confidence than the run earned", &[format!("This line: {l}"), format!("The real number: {line}")]); }+            // what the run carries as failed, and a board the photos cut off, must be in the report in plain words+            let owed: Vec<String> = notread::faces(&p.flow_dir).iter().map(notread::required).filter(|l| !text.contains(l.as_str())).collect();+            if !owed.is_empty() { err("report.md does not say which copper was not read", &owed.iter().map(|l| format!("It must say, word for word: {l}")).collect::<Vec<_>>()); }+            if !window::cut(&p.flow_dir).is_empty() && !text.contains("photographed window") { err("report.md does not say that the photos cut the board off", &[format!("Say it where the size is stated, for example: {}", window::size_sentence(&p.flow_dir).unwrap_or_default())]); }+            if let Some(l) = confidence::false_claim(&text, &s) { err("report.md claims more confidence than the run earned", &[format!("This line: {l}"), format!("The real numbers: {line}")]); }             let mut files: Vec<String> = checks::REVIEWED.iter().chain(["review.json", "tests.json", "report.md"].iter()).filter(|f| p.path(f).is_file()).map(|f| f.to_string()).collect();             files.extend(confidence::audit_files(&p.flow_dir));             files.push("report-tables.md".into());             checks::write_ok(&p.flow_dir, "report", &checks::Pass { summary: json!({"confidence": line}), hints: vec![], files }).unwrap_or_else(|e| err(&e.to_string(), &[]));             p.log(&th, "report", json!({})); reach(&mut p, &fl, "report");             let mut h = views::record(&p.flow_dir, &p.state, "report", "report built", &th, None); h.extend(step_hint(&fl, &p.flow_dir));+            match netdraw::run(&p.flow_dir, None) { Ok(s) => { h.push(netdraw::one_line(&s)); h.extend(netview::record(&p.flow_dir, "report", &th)); } Err(e) => h.push(format!("the nets pictures were not drawn: {e}")) }             ok("report-tables.md written", &h);         }+        Cmd::Datasheet { what } => {+            let th = thread(&cli); let p = project(&cli); let f = p.flow_dir.clone();+            match what.as_str() {+                "status" => { let (line, h) = dspins::status_lines(&f); ok(&line, &h); }+                "apply" => {+                    let s = dspins::apply(&f).unwrap_or_else(|e| err("datasheet apply: a pins file does not pass, and nothing was written", &e));+                    p.log(&th, "datasheet-apply", s.clone());+                    let mut h = vec![];+                    for d in s["declined"].as_array().into_iter().flatten() { h.push(format!("{}: none available: {}", d["part"].as_str().unwrap_or(""), d["reason"].as_str().unwrap_or(""))); }+                    if s["missing"].as_array().is_some_and(|a| !a.is_empty()) { h.push(format!("still waiting for a pins file or a \"none available\": {} (`datasheet status` says what each wants)", s["missing"])); }+                    h.extend(step_hint(&fl, &f));+                    ok(&format!("{} pin(s) of {} part(s) named from datasheets in parts.json `pins`{}", s["pins"], s["sheets"].as_array().map(|a| a.len()).unwrap_or(0), if s["partsJsonChanged"] == json!(true) { "" } else { " (already there: nothing changed)" }), &h);+                }+                _ => err("datasheet status | apply", &[]),+            }+        }+        Cmd::Auto { max_steps } => {+            let th = thread(&cli); let p = project(&cli);+            let end = auto::run_all(&p.flow_dir, &p.dir, &fl, &th, *max_steps, &mut |m| println!("{m}"));+            println!("{}", end.line);+            for h in end.hints.iter().filter(|h| !h.is_empty()) { println!("Hint: {h}"); }+            p.log(&th, "auto", json!({"state": end.state, "line": end.line, "ran": end.ran.len()}));+            std::process::exit(match end.state { "complete" => 0, "needs-ai" => 2, _ => 1 });+        }+        Cmd::Bench { what, file } => {+            let th = thread(&cli); let p = project(&cli); let f = p.flow_dir.clone();+            match what.as_str() {+                "plan" => {+                    let (doc, mut h) = bench::plan(&f).unwrap_or_else(|e| err(&e, &[]));+                    p.log(&th, "bench-plan", json!({"continuity": doc["continuity"].as_array().map(|a| a.len()), "values": doc["values"].as_array().map(|a| a.len()), "planHash": doc["planHash"]}));+                    if flow::current(&fl, &f).is_some() { h.push("the run is not complete yet: the plan is made from the files as they stand, and a later change to nets.json makes its probes stale".into()); }+                    ok(&format!("bench plan written: reverse-flow/bench/plan.md ({} continuity probes, {} part values, {} other measurements)", doc["continuity"].as_array().map(|a| a.len()).unwrap_or(0), doc["values"].as_array().map(|a| a.len()).unwrap_or(0), doc["other"].as_array().map(|a| a.len()).unwrap_or(0)), &h);+                }+                "ingest" => {+                    let path = file.clone().unwrap_or_else(|| err("bench ingest needs --file <results.json>", &["docs/bench.md gives the format; reverse-flow/bench/results-template.json is ready to fill in".into()]));+                    let (before, _) = report::current(&f);+                    let (sum, mut h) = bench::ingest(&f, Path::new(&path)).unwrap_or_else(|e| err(&e, &["docs/bench.md gives the results file format".into()]));+                    unrouted::write(&f);+                    p.log(&th, "bench-ingest", sum.clone());+                    let (after, verdict) = report::current(&f);+                    h.push(format!("scores, computed before and after: photo {:.1} -> {:.1}, bench {:.1} -> {:.1}", before.photo, after.photo, before.bench, after.bench));+                    for (b, a) in before.cats.iter().zip(after.cats.iter()) { if let (Some(x), Some(y)) = (&b.bench, &a.bench) { if (x.score - y.score).abs() >= 0.05 { h.push(format!("{} bench figure {:.1} -> {:.1}: now limited by {}", a.category, x.score, y.score, y.limit)); } } }+                    h.push(confidence::overall_line(&after, &verdict));+                    h.push("the artifacts changed, so the checks that read them are stale: `auto` runs them again (`build` refreshes the KiCad copy's evidence classes)".into());+                    ok(&format!("bench results recorded: {} connection(s) found joined, {} found open, {} value(s), {} layer statement(s), {} other; {} refused", sum["joined"], sum["open"], sum["values"], sum["layers"], sum["tests"], sum["refused"]), &h);+                }+                _ => err("bench plan | bench ingest --file <results.json>", &[]),+            }+        }+        Cmd::Nets { what, net } => {+            let th = thread(&cli); let p = project(&cli);+            if what != "draw" { err("nets draw [--net <name>]", &[]); }+            let s = netdraw::run(&p.flow_dir, net.as_deref()).unwrap_or_else(|e| err(&e, &["`nets draw` reads what `connect measure` and `connect attribute` wrote: run them first.".into()]));+            p.log(&th, "nets-draw", s.clone());+            let mut h = netdraw::hints(&s);+            if net.is_none() { h.extend(netview::record(&p.flow_dir, "connect", &th)); }+            ok(&netdraw::one_line(&s), &h);+        }+        Cmd::Video { what, no_voice, workers } => {+            let th = thread(&cli); let p = project(&cli);+            if what != "build" { err("video build [--no-voice] [--workers <n>]", &[]); }+            let b = video::build(&p.flow_dir, &p.dir, &p.state, &fl, *no_voice, *workers);+            p.log(&th, "video-build", json!({"ok": b.ok, "line": b.line, "call": b.call, "seconds": (b.seconds * 10.0).round() / 10.0, "tool": video::tool_hash()}));+            let mut h = b.hints.clone();+            if flow::current(&fl, &p.flow_dir).is_some() { h.push("the run is not complete: the video shows the files as they stand and says which steps are not done".into()); }+            if b.ok { ok(&format!("video built: {}", b.line), &h); } else { err(&b.line, &h); }+        }         Cmd::Kicad { pcb, sch } => {             let th = thread(&cli); let mut p = project(&cli);             if !Path::new(pcb).is_file() { err(&format!("{pcb}: not a file"), &[]); }
src/maskcolour.rsadded+149
@@ -0,0 +1,149 @@+//! The solder mask's colour, measured on each photo, and the tests that depend on it.+//!+//! The readers were written on green boards: copper was "the green channel", a part or gold was "red over green", mask was "green over red". A red or a blue mask breaks+//! all three. This module finds the mask's hue from the photo, picks the channel copper is read in, and says whether a pixel has a warm colour that is not the mask's.+//! A green mask (the bracket `prep::mask_tone` has always used, 65 to 200 degrees) keeps the channel and the tests the readers were developed with, to the letter, so+//! nothing measured on the Driver, the CM5 or the hub board moves.+use image::RgbImage;++#[derive(Clone, Copy, Debug)]+pub struct MaskColour {+    /// the peak of the hue histogram of the photo's coloured pixels, degrees+    pub hue: f32,+    /// the share of the photo within `NEAR_DEG` of that hue+    pub share: f32,+    /// median red, green and blue of those pixels, 8 bit+    pub rgb: [f32; 3],+    /// the hue lies in the green family+    pub green: bool,+    /// the channel copper under the mask is read in: 0 red, 1 green, 2 blue+    pub measure: usize,+}++/// A pixel counts as coloured from this saturation and value up: grey parts, print, solder and black bodies fall below.+const MIN_SAT: f32 = 0.25;+const MIN_VAL: f32 = 0.10;+/// How far round the peak the mask's own hue reaches. A red and a blue mask (test boards 7 and 8, close-up tiles) hold 90 percent of their coloured pixels within 15 degrees of the peak.+pub const NEAR_DEG: f32 = 25.0;+/// The channel copper is read in must sit below this at the mask's median, 8 bit. A camera's tone curve flattens towards white, and a mask's own channel can sit in the+/// flat part: on the blue board the blue channel has a median of 190 to 207 and shows copper over laminate as a step of 0.06 to 0.11 in log brightness, where the green+/// channel at 82 to 114 shows 0.24 to 0.44. On the red board the red channel is clipped at 255 over 16 to 18 percent of the mask. From tiles Board7.1, 7.3, 8.1 and 8.5.+const MEASURE_MAX_MEDIAN: f32 = 160.0;++fn hue_gap(a: f32, b: f32) -> f32 { let d = (a - b).rem_euclid(360.0); d.min(360.0 - d) }++/// Measure the mask's colour. The photo should be the board's own frame: a coloured table in view counts as mask if it is the larger.+pub fn of(img: &RgbImage) -> MaskColour {+    let step = ((img.width() as usize * img.height() as usize) / 400_000).max(1);+    // the same floor `prep::mask_tone` has always used for what counts as coloured, so a dim green mask (value 0.09 in a dark corner) is found as it was+    let px: Vec<(f32, [u8; 3])> = img.pixels().step_by(step).filter_map(|p| { let (h, s, v) = crate::raster::hsv(p); if s > 0.18 && v > 0.07 { Some((h, [p[0], p[1], p[2]])) } else { None } }).collect();+    let total = (img.width() as usize * img.height() as usize / step).max(1);+    let mut hist = [0f32; 72];+    for (h, _) in &px { hist[((h / 5.0) as usize).min(71)] += 1.0; }+    // smoothed round the circle, so a peak that straddles 0 degrees (red) is one peak+    let sm: Vec<f32> = (0..72).map(|i| (0..5).map(|k| hist[(i + 72 + k - 2) % 72]).sum::<f32>()).collect();+    let peak = (0..72).max_by(|a, b| sm[*a].partial_cmp(&sm[*b]).unwrap()).unwrap_or(24);+    let hue = peak as f32 * 5.0 + 2.5;+    let mut ch: [Vec<u8>; 3] = [vec![], vec![], vec![]];+    for (h, c) in &px { if hue_gap(*h, hue) < NEAR_DEG { for k in 0..3 { ch[k].push(c[k]); } } }+    let n = ch[0].len();+    let med = |v: &mut Vec<u8>| -> f32 { if v.is_empty() { return 0.0; } let k = v.len() / 2; *v.select_nth_unstable(k).1 as f32 };+    let rgb = [med(&mut ch[0]), med(&mut ch[1]), med(&mut ch[2])];+    // a photo with next to nothing coloured in it says nothing about the mask: the readers' own assumption, green, stands+    let green = (65.0..200.0).contains(&hue) || n * 100 < total;+    let measure = if green { 1 } else {+        let mut order = [0usize, 1, 2]; order.sort_by(|a, b| rgb[*b].partial_cmp(&rgb[*a]).unwrap());+        order.iter().copied().find(|k| rgb[*k] <= MEASURE_MAX_MEDIAN).unwrap_or(order[2])+    };+    MaskColour { hue, share: n as f32 / total as f32, rgb, green, measure }+}++impl MaskColour {+    /// Whether a pixel is a warm colour that is not the mask's: gold plating, a tan ceramic body, bare copper, flux. `margin` is how far red must stand over green, 8 bit,+    /// on a green mask, where that one difference tells them from mask (the rule the readers were developed with). On any other mask the pixel's hue has to lie in the+    /// warm band, 8 to 75 degrees (tan ceramics 20 to 33, gold 40 to 55 on the Driver photos), and at least 20 degrees from the mask's own hue: a red mask sits at 355 to 10.+    pub fn warm(&self, r: f32, g: f32, b: f32, margin: f32) -> bool {+        if self.green { return r > g + margin; }+        let (h, s, _) = crate::raster::hsv(&image::Rgb([r.clamp(0.0, 255.0) as u8, g.clamp(0.0, 255.0) as u8, b.clamp(0.0, 255.0) as u8]));+        s > MIN_SAT && (8.0..75.0).contains(&h) && hue_gap(h, self.hue) >= 20.0+    }+    /// Whether a pixel has the mask's own hue.+    pub fn is_mask(&self, r: f32, g: f32, b: f32) -> bool {+        let (h, s, v) = crate::raster::hsv(&image::Rgb([r.clamp(0.0, 255.0) as u8, g.clamp(0.0, 255.0) as u8, b.clamp(0.0, 255.0) as u8]));+        s > MIN_SAT && v > MIN_VAL && hue_gap(h, self.hue) < NEAR_DEG+    }+}++/// "How far green stands over red", 8 bit, as `photomat` uses it to tell mask from everything else, for a mask of any colour: on a green mask it is that difference+/// itself. On another mask it is rebuilt from what the difference meant on the photos the rules were written on: 100 times the saturation for a pixel of the mask's own+/// hue (a Driver mask pixel reads 27 to 45 and up), -60 for a warm colour off the mask's hue (gold and tan read -40 to -110 there), 0 for anything else, as a neutral reads.+pub fn mask_over_red(mc: &MaskColour, r: f32, g: f32, b: f32) -> f32 {+    if mc.green { return g - r; }+    let (h, s, v) = crate::raster::hsv(&image::Rgb([r.clamp(0.0, 255.0) as u8, g.clamp(0.0, 255.0) as u8, b.clamp(0.0, 255.0) as u8]));+    if s > MIN_SAT && v > MIN_VAL && hue_gap(h, mc.hue) < NEAR_DEG { 100.0 * s } else if s > MIN_SAT && (8.0..75.0).contains(&h) { -60.0 } else { 0.0 }+}++/// The mask's tone as `photomat` wants it. A green mask is measured as it always was. Any other: median value and luma of the pixels at the mask's hue, and a chroma+/// factor of 1, because `mask_over_red` already puts those pixels where a Driver mask pixel sits.+pub fn tone(img: &RgbImage, mc: &MaskColour) -> crate::prep::MaskTone {+    if mc.green { return crate::prep::mask_tone(img); }+    let step = ((img.width() as usize * img.height() as usize) / 400_000).max(1);+    let (mut vs, mut ys) = (vec![], vec![]);+    for p in img.pixels().step_by(step) { if mc.is_mask(p[0] as f32, p[1] as f32, p[2] as f32) { let (_, _, v) = crate::raster::hsv(p); vs.push(v); ys.push(0.299 * p[0] as f32 + 0.587 * p[1] as f32 + 0.114 * p[2] as f32); } }+    let at = |v: &mut Vec<f32>, q: f64| -> f32 { if v.is_empty() { return 0.0; } v.sort_by(|a, b| a.partial_cmp(b).unwrap()); v[((v.len() - 1) as f64 * q) as usize] };+    let v50 = at(&mut vs, 0.5);+    crate::prep::MaskTone { hue: mc.hue, v50, v_hi: (at(&mut vs, 0.98) * 1.1).clamp(0.62, 1.001), y50: at(&mut ys, 0.5), gr50: crate::prep::MASK_GR_READ_AT }+}++#[cfg(test)]+mod tests {+    use super::*;+    use image::Rgb;+    fn board(mask: [u8; 3]) -> RgbImage {+        // mask with a little grain, a gold pad, white print and a black body+        RgbImage::from_fn(300, 200, |x, y| { let n = ((x * 7919) ^ (y * 104729)) % 9; let m = |c: u8| c.saturating_add(n as u8).saturating_sub(4);+            if x < 40 && y < 40 { Rgb([205, 165, 70]) } else if x > 250 && y < 30 { Rgb([235, 235, 235]) } else if x > 200 && y > 150 { Rgb([30, 30, 32]) } else { Rgb([m(mask[0]), m(mask[1]), m(mask[2])]) } })+    }+    #[test]+    fn a_green_mask_keeps_the_green_channel_and_the_red_over_green_rule() {+        let c = of(&board([41, 130, 74]));+        assert!(c.green && c.measure == 1, "{c:?}");+        assert!(c.warm(120.0, 100.0, 60.0, 10.0) && !c.warm(105.0, 100.0, 60.0, 10.0));+    }+    #[test]+    fn a_red_mask_is_found_across_zero_degrees_and_is_not_read_in_its_clipped_channel() {+        let c = of(&board([233, 65, 61]));+        assert!(!c.green && (c.hue < 15.0 || c.hue > 345.0), "{c:?}");+        assert_eq!(c.measure, 1, "red sits at 233, past the tone curve's bend: green is next");+        assert!(c.warm(205.0, 165.0, 70.0, 10.0), "gold on a red mask is warm and not mask");+        assert!(!c.warm(233.0, 65.0, 61.0, 10.0), "the mask itself is not");+        assert!(c.is_mask(225.0, 70.0, 66.0) && !c.is_mask(205.0, 165.0, 70.0));+    }+    #[test]+    fn the_material_reader_finds_print_and_gold_on_a_red_and_a_blue_mask() {+        use crate::photomat::{classify, GOLD, MASK, SILK};+        for mask in [[200u8, 50, 45], [45, 80, 180], [41, 130, 74]] {+            // mask, a printed bar 12 px wide and a gold pad 60 px square+            let im = RgbImage::from_fn(400, 300, |x, y| { let n = ((x * 7919) ^ (y * 104729)) % 7; let m = |c: u8| c.saturating_add(n as u8);+                if (100..300).contains(&x) && (60..72).contains(&y) { Rgb([232, 236, 244]) } else if (150..210).contains(&x) && (180..240).contains(&y) { Rgb([215, 170, 60]) } else { Rgb([m(mask[0]), m(mask[1]), m(mask[2])]) } });+            let f = classify(&im, false);+            let at = |x: usize, y: usize| f.cls[y * 400 + x];+            assert_eq!(at(200, 66), SILK, "print on mask {mask:?}");+            assert_eq!(at(180, 210), GOLD, "gold on mask {mask:?}");+            assert_eq!(at(50, 150), MASK, "mask {mask:?}");+        }+    }+    #[test]+    fn a_dim_green_mask_and_a_photo_with_no_colour_are_green() {+        assert!(of(&RgbImage::from_pixel(200, 200, Rgb([6, 22, 12]))).green);+        assert!(of(&RgbImage::from_pixel(200, 200, Rgb([90, 90, 92]))).green);+    }+    #[test]+    fn a_blue_mask_is_read_in_green_when_blue_is_near_the_top() {+        let c = of(&board([65, 114, 207]));+        assert!(!c.green && (c.hue - 220.0).abs() < 12.0, "{c:?}");+        assert_eq!(c.measure, 1);+        let dim = of(&board([20, 40, 120]));+        assert_eq!(dim.measure, 2, "a dim blue mask is read in blue, its brightest channel");+    }+}
src/maskread.rsadded+128
@@ -0,0 +1,128 @@+//! Reading copper through a solder mask that is not green: which channel the copper shows in, and what on the face is not board at all.+//!+//! `maskcolour` finds the mask's hue and picks the brightest channel that is clear of the tone curve's bend. On the red board of the test set (board 7) that rule lands on+//! green or blue, whose median is 30 to 50 of 255: the camera's colour noise is as large there as the step from laminate to copper, pours break into grain and the+//! clearances round tracks are lost. The red channel shows the same step at a tenth of the grain. Which channel carries the copper depends on what the pigment lets+//! through and on what copper reflects, so it is measured on the photo: the structure the plain mask shows in each channel against that channel's grain.+//!+//! The second thing a green-only rule misses is a part whose colour is neither grey nor warm. The reader's test for a dark body, "value under 0.30 and saturation under+//! 0.42", was written where every dark thing with colour in it was green mask in shadow. On a red board a black chip under a blue-white light reads dark teal, saturation+//! 0.5, and was traced as copper. A pixel with colour whose hue lies far from the mask's is not board, whatever its brightness.+use crate::maskcolour::MaskColour;+use crate::raster::{blur, Mask, Plane};+use image::RgbImage;++/// A coloured pixel this far from the mask's hue, degrees, is not board. The red mask's own pixels (board 7, both faces) lie within 30 degrees of its peak, pink print rims+/// and highlights included; the blue mask's purple track rims (board 8 bottom) reach 48 degrees. The black chips on board 7 read 170 to 200 degrees from the mask.+pub const OFF_HUE_DEG: f32 = 60.0;+/// Colour enough for a hue to mean something: the floor `maskcolour` uses for the same purpose.+const OFF_SAT: f32 = 0.25;+/// The measured channel replaces the rule's only when it is this much better by `scores`. Board 7: red scores 1.73 and 1.45 (top, bottom) against 0.95 and 0.58 for the+/// rule's channel, 1.8 and 2.5 times. Board 8: the rule's channel, green, is best on the top (0.93 against 0.73 and 0.86) and within 1.2 times of the best on the bottom,+/// where red (0.86 against 0.74) owes its structure to the pale rims of tracks and shows a pour and bare laminate alike. So the bar sits between 1.2 and 1.8.+const SWITCH: f32 = 1.5;++fn hue_gap(a: f32, b: f32) -> f32 { let d = (a - b).rem_euclid(360.0); d.min(360.0 - d) }++/// Whether a pixel has a colour that cannot be this mask's: a tinted body, a coloured connector, the table. Never true on a green mask, whose readers have their own rules.+pub fn not_board(mc: &MaskColour, r: f32, g: f32, b: f32) -> bool {+    if mc.green { return false; }+    let (h, s, v) = crate::raster::hsv(&image::Rgb([r.clamp(0.0, 255.0) as u8, g.clamp(0.0, 255.0) as u8, b.clamp(0.0, 255.0) as u8]));+    s > OFF_SAT && v > 0.06 && hue_gap(h, mc.hue) >= OFF_HUE_DEG+}++/// For each channel: how much structure the plain mask shows in it (5th to 95th percentile of the log brightness between 2 and 12 px of smoothing) over its grain+/// (the same spread of the log brightness against its own 1 px smoothing). Zero where the photo has too little plain mask to say.+pub fn scores(img: &RgbImage, mc: &MaskColour) -> [f32; 3] {+    let (w, h) = (img.width() as usize, img.height() as usize);+    let plain = Mask { w, h, d: img.pixels().map(|p| mc.is_mask(p[0] as f32, p[1] as f32, p[2] as f32)).collect() }.erode(7.0);+    let at: Vec<usize> = (0..w * h).step_by(5).filter(|i| plain.d[*i]).collect();+    if at.len() < 2000 { return [0.0; 3]; }+    let spread = |v: &mut Vec<f32>| -> f32 { v.sort_by(|a, b| a.partial_cmp(b).unwrap()); v[(v.len() - 1) * 95 / 100] - v[(v.len() - 1) * 5 / 100] };+    let mut out = [0f32; 3];+    for c in 0..3 {+        let l = Plane { w, h, d: img.pixels().map(|p| (p[c] as f32).max(1.0).ln()).collect() };+        let (fine, mid, wide) = (blur(&l, 1, 2), blur(&l, 2, 3), blur(&l, 12, 3));+        let mut structure: Vec<f32> = at.iter().map(|i| mid.d[*i] - wide.d[*i]).collect();+        let mut grain: Vec<f32> = at.iter().map(|i| l.d[*i] - fine.d[*i]).collect();+        out[c] = spread(&mut structure) / spread(&mut grain).max(1e-3);+    }+    out+}++/// The fitted levels may be this many times further apart than the step the face's own copper edges show most often, before they are taken from the edges instead.+/// A face that is nearly all pour with thin clearances gives the two-class fit almost no laminate, and its lower level lands on shadow: board 7 top, levels 0.49 apart+/// in log brightness at the edges where the edges step by 0.21, 2.3 times (and 5.0 times on the bottom, which the older test catches too). Read from the edges, the+/// clearances round the tracks beside the top header come out as lines where the fit gave one dark blob. On board 8 the ratio is 0.58 and 0.64 (top, bottom): levels+/// closer than the step. Taking those from the edges was tried and turned most of the top face into laminate, because the strongest edges there are the pale rims of+/// tracks, so only the wide side is acted on. The bar, 1.6, lies between 0.64 and 2.3. Applied to masks that are not green only; the green boards keep the test they+/// were developed with (`edgelevels::Agreement`).+const STEP_RATIO: f32 = 1.6;++/// Whether the two levels sit much further apart than the face's own copper edges step (median over the edges).+pub fn separation_is_off(e: &crate::edgelevels::StepEdges, cop: &Plane, lam: &Plane, debug: bool) -> bool {+    if e.at.len() < 500 { return false; }+    let mut sep: Vec<f32> = e.at.iter().map(|(x, y, _, _)| { let i = *y as usize * e.w + *x as usize; cop.d[i] - lam.d[i] }).collect();+    let k = sep.len() / 2; let med = *sep.select_nth_unstable_by(k, |a, b| a.partial_cmp(b).unwrap()).1;+    let ratio = med / e.modal.max(1e-3);+    if debug { eprintln!("levels {med:.3} apart at the edges, the edges step by {:.3}: ratio {ratio:.2}", e.modal); }+    ratio > STEP_RATIO+}++/// The mask colour with the channel copper is read in settled on the photo. A green mask is returned as it came.+pub fn with_channel(img: &RgbImage, mc: MaskColour, debug: bool) -> MaskColour {+    if mc.green { return mc; }+    let s = scores(img, &mc);+    let best = (0..3).max_by(|a, b| s[*a].partial_cmp(&s[*b]).unwrap()).unwrap_or(mc.measure);+    let measure = if s[best] >= SWITCH * s[mc.measure] && s[mc.measure] > 0.0 { best } else { mc.measure };+    if debug { eprintln!("structure over grain by channel {:.2} {:.2} {:.2}: read in channel {measure} (the rule alone gives {})", s[0], s[1], s[2], mc.measure); }+    MaskColour { measure, ..mc }+}++#[cfg(test)]+mod tests {+    use super::*;+    use image::Rgb;+    /// A red mask whose copper shows as a clean step in red and as a step buried in grain in green and blue.+    fn red_board() -> RgbImage {+        RgbImage::from_fn(400, 300, |x, y| { let copper = (x / 40) % 2 == 0; let n = (((x * 7919) ^ (y * 104729)) % 23) as i32 - 11;+            let (r, g, b) = if copper { (200, 36, 30) } else { (172, 26, 24) };+            Rgb([(r + n / 6).clamp(0, 255) as u8, (g + n).clamp(1, 255) as u8, (b + n).clamp(1, 255) as u8]) })+    }+    #[test]+    fn a_red_mask_is_read_in_red_when_red_shows_the_copper_cleanly() {+        let im = red_board();+        let mc = crate::maskcolour::of(&im);+        assert!(!mc.green && mc.measure != 0, "the rule alone avoids the mask's own channel: {mc:?}");+        let s = scores(&im, &mc);+        assert!(s[0] > 1.5 * s[1] && s[0] > 1.5 * s[2], "{s:?}");+        assert_eq!(with_channel(&im, mc, false).measure, 0);+    }+    #[test]+    fn levels_far_wider_than_the_edges_step_are_caught() {+        let e = crate::edgelevels::StepEdges { w: 100, h: 100, at: (0..600).map(|k| ((k % 90 + 5) as u32, (k / 90 + 5) as u32, 5.3, 5.1)).collect(), modal: 0.2, measured: 600 };+        let lv = |c: f32, l: f32| (Plane::new(100, 100, c), Plane::new(100, 100, l));+        let (c, l) = lv(5.4, 4.8); assert!(separation_is_off(&e, &c, &l, false), "0.6 apart where the edges step 0.2");+        let (c, l) = lv(5.32, 5.1); assert!(!separation_is_off(&e, &c, &l, false));+        let (c, l) = lv(5.2, 5.12); assert!(!separation_is_off(&e, &c, &l, false), "closer than the step is left to the older test");+    }+    #[test]+    fn a_green_mask_is_left_alone_and_nothing_on_it_is_off_hue() {+        let im = RgbImage::from_fn(300, 200, |x, y| { let n = (((x * 7919) ^ (y * 104729)) % 9) as u8; Rgb([41 + n, 130 + n, 74 + n]) });+        let mc = crate::maskcolour::of(&im);+        assert!(mc.green);+        assert_eq!(with_channel(&im, mc, false).measure, 1);+        assert!(!not_board(&mc, 40.0, 70.0, 75.0));+    }+    #[test]+    fn a_tinted_body_is_not_board_on_a_red_or_a_blue_mask_and_the_masks_own_shades_are() {+        let red = crate::maskcolour::of(&red_board());+        assert!(not_board(&red, 40.0, 70.0, 75.0), "a black chip that reads dark teal");+        assert!(!not_board(&red, 120.0, 20.0, 18.0), "red mask in shadow");+        assert!(!not_board(&red, 230.0, 120.0, 150.0), "a pink highlight");+        assert!(!not_board(&red, 60.0, 60.0, 62.0), "grey has no hue to speak of");+        let blue = crate::maskcolour::of(&RgbImage::from_fn(300, 200, |x, y| { let n = (((x * 7919) ^ (y * 104729)) % 9) as u8; Rgb([45 + n, 80 + n, 180 + n]) }));+        assert!(not_board(&blue, 205.0, 165.0, 70.0), "a gold pin");+        assert!(!not_board(&blue, 110.0, 90.0, 190.0), "the purple rim of a track");+    }+}
src/netconf.rsadded+440
@@ -0,0 +1,440 @@+//! How well each net is proven: its weakest link, not its average.+//!+//! `connect attribute` grades every piece of copper on a ladder (`attrib.rs`). This module reads what that wrote and+//! grades each net. It reads no photo and measures nothing new. The question for a net is not how much of its copper+//! was seen but whether its pins are seen joined, so the level is defined on the connection graph:+//!+//! - **Vertices.** The net's pins, and the conductors (`node` in detect/connect-graph.json) that carry its name.+//! - **Links, strongest first.**+//!   1. `copper`: a pad joined to a conductor of the net with no gap (a solid contact). Copper stitched into one+//!      conductor is one vertex already.+//!   2. `via`: two conductors of the net joined through verified vias (a link of tier 2).+//!   3. `inferred`: one via track to track (tier 3); a pad whose edge does not settle what it touches (a weak contact);+//!      one unconfirmed via onto a pour, which proves nothing by itself (tier 4); and last the pin map alone, which+//!      joins whatever nothing seen joins.+//! - **The path set.** The links are added strongest first until every pin of the net is in one piece (a spanning+//!   tree that keeps the strongest links it can). The net's level is the rung of the weakest link in that set, and that+//!   link is reported with its place. A pin with no solid contact to the net's copper is a member by inference only,+//!   so a net with one such pin is `inferred` however much of its copper was seen.+//! - **One pin.** Nothing is there to join, so the net is graded by how that pin meets its copper and marked `singlePin`.+//!+//! Places where the net appears shorted to another (`shorts` in net-evidence.json) are no link between its own pins.+//! They are listed as `disputes` and do not lower the level; the copper in dispute carries no net and is drawn as+//! `conflict`. A piece of copper is drawn at the weaker of its own rung and its net's level.+use crate::items::read_json;+use serde_json::{json, Value};+use std::cmp::Ordering;+use std::collections::{BTreeMap, BTreeSet, HashMap};+use std::path::Path;++pub const FILE: &str = "net-confidence.json";+/// The ladder, strongest first: the same names `attrib.rs` writes.+pub const LADDER: [&str; 6] = ["copper", "via", "inferred", "group", "none", "conflict"];++pub fn rank(rung: &str) -> usize { LADDER.iter().position(|r| *r == rung).unwrap_or(4) }+/// The weaker of two rungs.+pub fn weaker<'a>(a: &'a str, b: &'a str) -> &'a str { if rank(a) >= rank(b) { a } else { b } }++/// Copper that carries no net of its own, by the name attribution gave it. None for a named net.+pub fn unnamed_class(net: &str) -> Option<&'static str> {+    if !net.starts_with("UNK") && !net.is_empty() { return None; }+    Some(if net.starts_with("UNK_CONFLICT") { "conflict" } else if net.starts_with("UNK_G") { "group" } else { "none" })+}++/// Names in the order a person reads them: C2 before C10.+pub fn nat_cmp(a: &str, b: &str) -> Ordering {+    let (x, y) = (a.as_bytes(), b.as_bytes());+    let (mut i, mut j) = (0, 0);+    while i < x.len() && j < y.len() {+        if x[i].is_ascii_digit() && y[j].is_ascii_digit() {+            let (s, t) = (i, j);+            while i < x.len() && x[i].is_ascii_digit() { i += 1; }+            while j < y.len() && y[j].is_ascii_digit() { j += 1; }+            let (p, q) = (a[s..i].trim_start_matches('0'), b[t..j].trim_start_matches('0'));+            let o = p.len().cmp(&q.len()).then(p.cmp(q));+            if o != Ordering::Equal { return o; }+        } else {+            let o = x[i].to_ascii_uppercase().cmp(&y[j].to_ascii_uppercase());+            if o != Ordering::Equal { return o; }+            i += 1; j += 1;+        }+    }+    (x.len() - i).cmp(&(y.len() - j)).then(a.cmp(b))+}++#[derive(Clone, Debug)]+pub struct Contact { pub pin: String, pub node: usize, pub solid: bool, pub kind: String, pub side: String }+#[derive(Clone, Debug)]+pub struct Link { pub a: usize, pub b: usize, pub tier: u8, pub vias: Vec<String> }+/// The connection graph as attribution left it.+#[derive(Default)]+pub struct Graph { pub node_net: HashMap<usize, String>, pub contacts: Vec<Contact>, pub links: Vec<Link> }++/// One link of a net's path set.+#[derive(Clone, Debug, PartialEq)]+pub struct Step { pub strength: u8, pub kind: &'static str, pub pins: Vec<String>, pub nodes: Vec<usize>, pub vias: Vec<String>, pub contact: String, pub side: String }+impl Step {+    pub fn rung(&self) -> &'static str { if self.strength >= 50 { "copper" } else if self.strength >= 40 { "via" } else { "inferred" } }+}++#[derive(Clone, Debug)]+pub struct NetLevel {+    pub level: &'static str,+    /// The weakest link of the path set. None for a net with no pins.+    pub weakest: Option<Step>,+    /// How many links of the path set are as weak as that one.+    pub weakest_count: usize,+    /// Links of the path set per rung: copper, via, inferred.+    pub steps: [usize; 3],+    /// Pins with a solid contact to copper of this net.+    pub seen: Vec<String>,+    /// Pins on the net by inference only.+    pub inferred: Vec<String>,+    /// Sets of pins seen joined to each other, largest first.+    pub groups: Vec<Vec<String>>,+}++const PIN_MAP: u8 = 30;+fn solid_strength(kind: &str) -> u8 { match kind { "lug" => 53, "pour" => 52, "track-end" => 51, _ => 50 } }++/// Grade one net on the graph. `pins` are the net's pins as nets.json lists them.+pub fn net_level(name: &str, pins: &[String], g: &Graph) -> NetLevel {+    let mut pins: Vec<String> = pins.to_vec();+    pins.sort_by(|a, b| nat_cmp(a, b)); pins.dedup();+    let pin_ix: HashMap<&str, usize> = pins.iter().enumerate().map(|(i, p)| (p.as_str(), i)).collect();+    let mut node_ix: BTreeMap<usize, usize> = BTreeMap::new();+    let on_net = |n: usize| g.node_net.get(&n).map(|x| x == name).unwrap_or(false);+    let mut steps: Vec<(Step, usize, usize)> = vec![];+    let n_p = pins.len();+    let vertex = |m: &mut BTreeMap<usize, usize>, n: usize| -> usize { let k = m.len(); n_p + *m.entry(n).or_insert(k) };+    for c in &g.contacts {+        let (Some(&pi), true) = (pin_ix.get(c.pin.as_str()), on_net(c.node)) else { continue };+        let v = vertex(&mut node_ix, c.node);+        let (strength, kind) = if c.solid { (solid_strength(&c.kind), "pad-on-copper") } else { (32, "pad-edge-unsettled") };+        steps.push((Step { strength, kind, pins: vec![c.pin.clone()], nodes: vec![c.node], vias: vec![], contact: c.kind.clone(), side: c.side.clone() }, pi, v));+    }+    for l in &g.links {+        if l.a == l.b || !on_net(l.a) || !on_net(l.b) { continue; }+        let (va, vb) = (vertex(&mut node_ix, l.a), vertex(&mut node_ix, l.b));+        let (strength, kind) = match l.tier { 0..=2 => (40, "verified-vias"), 3 => (33, "single-via-track-to-track"), _ => (31, "single-via-onto-pour") };+        steps.push((Step { strength, kind, pins: vec![], nodes: vec![l.a, l.b], vias: l.vias.clone(), contact: String::new(), side: String::new() }, va, vb));+    }+    // strongest first; among equals an order that does not depend on how the files were written+    steps.sort_by(|a, b| b.0.strength.cmp(&a.0.strength).then_with(|| a.0.pins.first().map(String::as_str).unwrap_or("").cmp(b.0.pins.first().map(String::as_str).unwrap_or("")).then(a.0.nodes.cmp(&b.0.nodes))));+    let n_v = n_p + node_ix.len();+    let mut parent: Vec<usize> = (0..n_v).collect();+    fn root(p: &mut [usize], x: usize) -> usize { let mut r = x; while p[r] != r { r = p[r]; } let mut y = x; while p[y] != r { let t = p[y]; p[y] = r; y = t; } r }+    let mut kept: Vec<(Step, usize, usize)> = vec![];+    let mut seen_parent: Option<Vec<usize>> = None;+    for (s, a, b) in steps.iter() {+        // what is joined by copper and verified vias alone: the groups a person can see+        if s.strength < 40 && seen_parent.is_none() { seen_parent = Some(parent.clone()); }+        let (x, y) = (root(&mut parent, *a), root(&mut parent, *b));+        if x != y { parent[x] = y; kept.push((s.clone(), *a, *b)); }+    }+    // only links on the way from one pin to another count: copper of the net that hangs off the side, with no pin on+    // it, joins no pins, so however weakly it hangs it does not lower the level+    let mut degree = vec![0usize; n_v];+    for (_, a, b) in &kept { degree[*a] += 1; degree[*b] += 1; }+    loop {+        let before = kept.len();+        kept.retain(|(_, a, b)| { let dead = (*a >= n_p && degree[*a] == 1) || (*b >= n_p && degree[*b] == 1); if dead { degree[*a] -= 1; degree[*b] -= 1; } !dead });+        if kept.len() == before { break; }+    }+    let mut tree: Vec<Step> = kept.into_iter().map(|k| k.0).collect();+    let mut seen_parent = seen_parent.unwrap_or_else(|| parent.clone());+    let seen: Vec<String> = pins.iter().filter(|p| g.contacts.iter().any(|c| c.solid && c.pin == **p && on_net(c.node))).cloned().collect();+    let seen_set: BTreeSet<&String> = seen.iter().collect();+    let inferred: Vec<String> = pins.iter().filter(|p| !seen_set.contains(p)).cloned().collect();+    let mut by_root: BTreeMap<usize, Vec<String>> = BTreeMap::new();+    for (i, p) in pins.iter().enumerate() { if seen_set.contains(p) { by_root.entry(root(&mut seen_parent, i)).or_default().push(p.clone()); } }+    let mut groups: Vec<Vec<String>> = by_root.into_values().collect();+    groups.sort_by(|a, b| b.len().cmp(&a.len()).then_with(|| nat_cmp(&a[0], &b[0])));+    // what the links leave apart, the pin map joins: every piece but the largest hangs on it+    let mut comps: BTreeMap<usize, Vec<String>> = BTreeMap::new();+    for (i, p) in pins.iter().enumerate() { comps.entry(root(&mut parent, i)).or_default().push(p.clone()); }+    let mut comps: Vec<Vec<String>> = comps.into_values().collect();+    comps.sort_by(|a, b| b.len().cmp(&a.len()).then_with(|| nat_cmp(&a[0], &b[0])));+    for c in comps.iter().skip(1) { tree.push(Step { strength: PIN_MAP, kind: "pin-map-only", pins: c.clone(), nodes: vec![], vias: vec![], contact: String::new(), side: String::new() }); }+    if pins.len() == 1 {+        // nothing to join: the pin's best meeting with the net's copper stands for the net+        let best = steps.iter().filter(|s| s.1 == 0).map(|s| s.0.clone()).max_by_key(|s| s.strength)+            .unwrap_or(Step { strength: PIN_MAP, kind: "pin-map-only", pins: pins.clone(), nodes: vec![], vias: vec![], contact: String::new(), side: String::new() });+        tree = vec![best];+    }+    let mut count = [0usize; 3];+    for s in &tree { count[rank(s.rung())] += 1; }+    let weakest = tree.iter().min_by_key(|s| s.strength).cloned();+    let weakest_count = weakest.as_ref().map(|w| tree.iter().filter(|s| s.strength == w.strength && s.kind == w.kind).count()).unwrap_or(0);+    let level = match &weakest { Some(w) => w.rung(), None => "none" };+    NetLevel { level, weakest, weakest_count, steps: count, seen, inferred, groups }+}++fn r2(v: f64) -> f64 { (v * 100.0).round() / 100.0 }+fn r3(v: f64) -> f64 { (v * 1000.0).round() / 1000.0 }++/// A file name for a net's picture: letters, digits, dot, dash and underscore, with `+` spelled out, and never two+/// nets on one name whatever the case.+pub fn file_stem(net: &str, taken: &mut BTreeSet<String>) -> String {+    let mut s = String::new();+    for ch in net.chars() { if ch.is_ascii_alphanumeric() || ch == '.' || ch == '-' || ch == '_' { s.push(ch); } else if ch == '+' { s.push_str("plus"); } else { s.push('_'); } }+    if s.is_empty() { s.push_str("net"); }+    let mut out = s.clone(); let mut k = 2;+    while !taken.insert(out.to_lowercase()) { out = format!("{s}-{k}"); k += 1; }+    out+}++/// The graph from detect/connect-graph.json and detect/attribution.json.+pub fn load_graph(flow: &Path) -> Result<Graph, String> {+    let gr = read_json(&flow.join("detect/connect-graph.json")).map_err(|e| format!("{e}: run `connect measure` and `connect attribute` first"))?;+    let at = read_json(&flow.join("detect/attribution.json")).map_err(|e| format!("{e}: run `connect attribute` first"))?;+    let mut g = Graph::default();+    for p in at["pieces"].as_array().into_iter().flatten() { if let (Some(n), Some(net)) = (p["node"].as_u64(), p["net"].as_str()) { g.node_net.insert(n as usize, net.to_string()); } }+    for c in gr["contacts"].as_array().ok_or("detect/connect-graph.json has no contacts: it is from an older version, run `connect attribute` again")? {+        g.contacts.push(Contact { pin: c["pin"].as_str().unwrap_or("").to_string(), node: c["node"].as_u64().unwrap_or(0) as usize, solid: c["tier"].as_u64() == Some(1), kind: c["kind"].as_str().unwrap_or("").to_string(), side: c["side"].as_str().unwrap_or("").to_string() });+    }+    for l in gr["links"].as_array().into_iter().flatten() {+        g.links.push(Link { a: l["pieces"][0].as_u64().unwrap_or(0) as usize, b: l["pieces"][1].as_u64().unwrap_or(0) as usize, tier: l["tier"].as_u64().unwrap_or(9) as u8, vias: l["ids"].as_array().into_iter().flatten().filter_map(|v| v.as_str().map(str::to_string)).collect() });+    }+    Ok(g)+}++/// Where each pin's pad is, and on which faces: from what `connect measure` read.+pub fn pin_places(flow: &Path) -> HashMap<String, (f64, f64, Vec<&'static str>)> {+    let mut out: HashMap<String, (f64, f64, Vec<&'static str>)> = HashMap::new();+    let Ok(ms) = read_json(&flow.join("detect/attrib/measure.json")) else { return out };+    for p in ms["pads"].as_array().into_iter().flatten() {+        let num = p["num"].as_str().unwrap_or(""); if num.is_empty() { continue; }+        let layers: Vec<&str> = p["layers"].as_array().into_iter().flatten().filter_map(|l| l.as_str()).collect();+        let mut sides = vec![];+        if layers.iter().any(|l| *l == "F.Cu" || *l == "*.Cu" || *l == "F&B.Cu") { sides.push("top"); }+        if layers.iter().any(|l| *l == "B.Cu" || *l == "*.Cu" || *l == "F&B.Cu") { sides.push("bottom"); }+        if sides.is_empty() { continue; }+        let e = out.entry(format!("{}.{}", p["part"].as_str().unwrap_or(""), num)).or_insert((p["x"].as_f64().unwrap_or(0.0), p["y"].as_f64().unwrap_or(0.0), vec![]));+        for s in sides { if !e.2.contains(&s) { e.2.push(s); } }+    }+    out+}++fn sentence(s: &Step, status: &HashMap<String, String>, single: bool) -> String {+    let list = |v: &[String]| { let shown: Vec<&str> = v.iter().take(4).map(String::as_str).collect(); if v.len() > 4 { format!("{} and {} more", shown.join(", "), v.len() - 4) } else { shown.join(", ") } };+    match s.kind {+        "pad-on-copper" => { let how = match s.contact.as_str() { "lug" => "a lug bolted to its pour", "pour" => "a pad joined to a pour", "track-end" => "a track ending on the pad", "via-in-pad" => "vias through the pad", _ => "a pad on the copper" };+            if single { format!("{} meets its copper with no gap ({how}); the net has one pin, so nothing is there to join", s.pins[0]) } else { format!("every pin meets this net's copper with no gap; the weakest of those meetings is {} ({how})", s.pins[0]) } }+        "verified-vias" => format!("two pieces of this net's copper are joined through verified vias ({})", list(&s.vias)),+        "single-via-track-to-track" => format!("two pieces of this net's copper are joined through one via, track to track ({})", list(&s.vias)),+        "single-via-onto-pour" => format!("two pieces of this net's copper are joined by one unconfirmed via onto a pour ({}), which proves nothing by itself, and by the pin map", list(&s.vias)),+        "pad-edge-unsettled" => format!("{} is at this net's copper, but the edge of its pad does not settle whether they join", s.pins[0]),+        _ if s.pins.len() == 1 => { let why = match status.get(&s.pins[0]).map(String::as_str) { Some("unsettled") => "the edge of its pad does not settle what it touches", Some("touches unnamed copper") => "the copper it touches carries no name", Some("clash") => "the copper it touches is named for another net", _ => "no copper is seen joining this pin" };+            format!("{} is on this net by the pin map only: {why}", s.pins[0]) }+        _ => format!("{} are seen joined to each other, but nothing seen joins them to the rest of the net: the pin map alone does", list(&s.pins)),+    }+}++/// Grade every net and write net-confidence.json. `sections` maps a net to its section id, `pictures` to its picture.+pub fn compute(flow: &Path) -> Result<Value, String> {+    let g = load_graph(flow)?;+    let nets = read_json(&flow.join("nets.json"))?;+    let copper = read_json(&flow.join("copper.json"))?;+    let ev = read_json(&flow.join("net-evidence.json")).unwrap_or(json!({}));+    let places = pin_places(flow);+    let via_at: HashMap<String, (f64, f64)> = copper["vias"].as_array().into_iter().flatten().filter_map(|v| Some((v["id"].as_str()?.to_string(), (v["pos_mm"][0].as_f64()?, v["pos_mm"][1].as_f64()?)))).collect();+    let mut status: HashMap<String, HashMap<String, String>> = HashMap::new();+    for n in ev["nets"].as_array().into_iter().flatten() { status.insert(n["net"].as_str().unwrap_or("").to_string(), n["pins"].as_object().map(|m| m.iter().map(|(k, v)| (k.clone(), v.as_str().unwrap_or("").to_string())).collect()).unwrap_or_default()); }+    // disputes: every place attribution found this net meeting another+    let mut disputes: BTreeMap<String, Vec<Value>> = BTreeMap::new();+    for s in ev["shorts"].as_array().into_iter().flatten() {+        let names: Vec<String> = match &s["nets"] { Value::Object(m) => m.keys().cloned().collect(), Value::Array(a) => a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect(), _ => vec![] };+        let at = s["vias"].as_array().and_then(|v| v.iter().filter_map(|x| x.as_str()).find_map(|id| via_at.get(id))).map(|p| json!([r3(p.0), r3(p.1)]))+            .or_else(|| s["pins"].as_array().and_then(|v| v.iter().filter_map(|x| x.as_str()).find_map(|p| places.get(p))).map(|p| json!([r3(p.0), r3(p.1)]))).unwrap_or(s["atMm"].clone());+        for n in &names { disputes.entry(n.clone()).or_default().push(json!({"kind": s["kind"], "with": names.iter().filter(|x| *x != n).collect::<Vec<_>>(), "pins": s["pins"], "vias": s["vias"], "at": at})); }+    }+    // each net's level first, since a piece of copper is drawn at the weaker of its own rung and its net's level+    let mut rows: Vec<(String, Vec<String>, NetLevel)> = vec![];+    for n in nets["nets"].as_array().into_iter().flatten() {+        let name = n["name"].as_str().unwrap_or("").to_string();+        let pins: Vec<String> = n["pins"].as_array().into_iter().flatten().filter_map(|p| p.as_str().map(str::to_string)).collect();+        let lv = net_level(&name, &pins, &g);+        rows.push((name, pins, lv));+    }+    let level_of: HashMap<&str, &'static str> = rows.iter().map(|r| (r.0.as_str(), r.2.level)).collect();+    // a cross-check against attribution's own tag: a net of two or more pins is seen joined exactly when it is tagged MEASURED+    let tag_differs: Vec<&str> = nets["nets"].as_array().into_iter().flatten().filter(|n| n["pins"].as_array().map(|p| p.len() >= 2).unwrap_or(false))+        .filter(|n| (n["tag"].as_str() == Some("MEASURED")) != matches!(level_of.get(n["name"].as_str().unwrap_or("")).copied(), Some("copper") | Some("via"))).filter_map(|n| n["name"].as_str()).collect();+    // copper per net and rung, as drawn: zone area, track length and area at its drawn width, vias+    let mut per: BTreeMap<String, BTreeMap<&'static str, [f64; 4]>> = BTreeMap::new();+    let mut drawn: BTreeMap<&'static str, [f64; 4]> = LADDER.iter().map(|r| (*r, [0.0; 4])).collect();+    let mut by_own: BTreeMap<&'static str, [f64; 4]> = LADDER.iter().map(|r| (*r, [0.0; 4])).collect();+    let mut unnamed: BTreeMap<&'static str, (BTreeSet<String>, [f64; 4])> = BTreeMap::new();+    let mut tally = |net: &str, rung: &str, add: [f64; 4]| {+        let own = LADDER[rank(rung)];+        { let o = by_own.get_mut(own).unwrap(); for k in 0..4 { o[k] += add[k]; } }+        let shown = match unnamed_class(net) { Some(c) => { let e = unnamed.entry(c).or_insert((BTreeSet::new(), [0.0; 4])); e.0.insert(net.to_string()); for k in 0..4 { e.1[k] += add[k]; } c } None => { let e = per.entry(net.to_string()).or_default().entry(own).or_insert([0.0; 4]); for k in 0..4 { e[k] += add[k]; } LADDER[rank(weaker(own, level_of.get(net).copied().unwrap_or("none")))] } };+        let d = drawn.get_mut(shown).unwrap(); for k in 0..4 { d[k] += add[k]; }+    };+    // copper on a face that failed `check copper` is in no figure here: it was not read, so it is neither proven nor "isolated"+    let unread = crate::notread::faces(flow);+    let read = |v: &Value| !crate::notread::skips_layer(&unread, v["layer"].as_str().unwrap_or(""));+    for z in copper["zones"].as_array().into_iter().flatten().filter(|z| read(z)) { tally(z["net"].as_str().unwrap_or(""), z["netEvidence"]["rung"].as_str().unwrap_or("none"), [z["areaMm2"].as_f64().unwrap_or(0.0), 0.0, 0.0, 1.0]); }+    for t in copper["traces"].as_array().into_iter().flatten().filter(|t| read(t)) {+        let pts: Vec<(f64, f64)> = t["path_mm"].as_array().into_iter().flatten().map(|q| (q[0].as_f64().unwrap_or(0.0), q[1].as_f64().unwrap_or(0.0))).collect();+        let len: f64 = pts.windows(2).map(|q| crate::geom::dist(q[0], q[1])).sum();+        tally(t["net"].as_str().unwrap_or(""), t["netEvidence"]["rung"].as_str().unwrap_or("none"), [len * t["width_mm"].as_f64().unwrap_or(0.0), len, 0.0, 1.0]);+    }+    for v in copper["vias"].as_array().into_iter().flatten() { tally(v["net"].as_str().unwrap_or(""), v["netEvidence"]["rung"].as_str().unwrap_or("none"), [0.0, 0.0, 1.0, 1.0]); }+    let cu = |v: &[f64; 4]| json!({"areaMm2": r2(v[0]), "trackLengthMm": r2(v[1]), "vias": v[2], "items": v[3]});+    let mut out = vec![];+    let mut taken = BTreeSet::new();+    let (mut levels, mut multi, mut single): (BTreeMap<&str, usize>, BTreeMap<&str, usize>, BTreeMap<&str, usize>) = (BTreeMap::new(), BTreeMap::new(), BTreeMap::new());+    let mut disputed = 0;+    for (name, pins, lv) in &rows {+        let st = status.get(name).cloned().unwrap_or_default();+        let is_single = pins.len() == 1;+        *levels.entry(lv.level).or_default() += 1;+        *(if is_single { &mut single } else { &mut multi }).entry(lv.level).or_default() += 1;+        let weakest = lv.weakest.as_ref().map(|w| {+            let at = w.vias.iter().find_map(|id| via_at.get(id)).map(|p| (p.0, p.1, None)).or_else(|| w.pins.first().and_then(|p| places.get(p)).map(|p| (p.0, p.1, Some(if !w.side.is_empty() { w.side.clone() } else { p.2.first().copied().unwrap_or("top").to_string() }))));+            json!({"rung": w.rung(), "kind": w.kind, "what": sentence(w, &st, is_single), "pins": w.pins, "vias": w.vias, "at": at.as_ref().map(|a| json!([r3(a.0), r3(a.1)])), "side": at.and_then(|a| a.2), "sameKindOnPath": lv.weakest_count})+        });+        let cop = per.get(name).cloned().unwrap_or_default();+        let total: f64 = cop.values().map(|v| v[0]).sum();+        let by_rung: serde_json::Map<String, Value> = LADDER.iter().filter_map(|r| cop.get(r).map(|v| { let mut c = cu(v); c["share"] = json!(if total > 0.0 { r3(v[0] / total) } else { 0.0 }); (r.to_string(), c) })).collect();+        let members: Vec<Value> = lv.inferred.iter().map(|p| json!({"pin": p, "why": st.get(p).cloned().unwrap_or_else(|| "no copper seen".into()), "at": places.get(p).map(|q| json!([r3(q.0), r3(q.1)])), "sides": places.get(p).map(|q| q.2.clone())})).collect();+        let dis = disputes.get(name).cloned().unwrap_or_default();+        if !dis.is_empty() { disputed += 1; }+        // the datasheet witness for this net, as `connect witness` recorded it: shown beside the level, never part of it+        let witness = ev["nets"].as_array().into_iter().flatten().find(|r| r["net"].as_str() == Some(name.as_str())).map(|r| &r["datasheet"]).filter(|d| d.is_object()).map(|d| json!({"witness": d["witness"], "agree": d["agree"], "disagree": d["disagree"], "notVisible": d["notVisible"], "netList": d["netList"], "findings": d["findings"]})).unwrap_or(json!({"witness": "none", "netList": "none"}));+        // how the net is known when no copper says it: built from a pinout by rule (and how that is tagged), or stated by somebody+        let nrec = nets["nets"].as_array().into_iter().flatten().find(|n| n["name"].as_str() == Some(name.as_str()));+        let seen = matches!(lv.level, "copper" | "via");+        let known = match nrec { Some(n) if n["fromPinout"] == json!(true) => json!({"from": if seen { "pinout, confirmed by copper" } else { "pinout only" }, "tag": n["statedTag"], "rules": n["pinout"]["rules"], "datasheetPins": n["pinout"]["datasheetPins"], "inferredPins": n["pinout"]["inferredPins"]}),+            Some(n) if n["fromCopper"] == json!(true) => json!({"from": "copper"}), _ => json!({"from": if seen { "stated, confirmed by copper" } else { "stated" }}) };+        out.push(json!({"net": name, "knownFrom": known, "datasheetWitness": witness, "level": lv.level, "singlePin": is_single, "pins": pins.len(), "pinsSeen": lv.seen.len(), "pinsSeenShare": if pins.is_empty() { 0.0 } else { r3(lv.seen.len() as f64 / pins.len() as f64) },+            "seenGroups": lv.groups, "weakestLink": weakest, "pathSet": {"copper": lv.steps[0], "via": lv.steps[1], "inferred": lv.steps[2]},+            "seenPins": lv.seen, "inferredMembers": members, "copperByRung": by_rung, "disputes": dis, "file": file_stem(name, &mut taken)}));+    }+    let counts = |m: &BTreeMap<&str, usize>| LADDER.iter().take(3).chain(["none"].iter()).map(|r| (r.to_string(), json!(m.get(r).copied().unwrap_or(0)))).collect::<serde_json::Map<_, _>>();+    let un: serde_json::Map<String, Value> = ["group", "none", "conflict"].iter().map(|c| { let e = unnamed.get(c); let mut v = cu(&e.map(|x| x.1).unwrap_or([0.0; 4])); v["names"] = json!(e.map(|x| x.0.len()).unwrap_or(0)); (c.to_string(), v) }).collect();+    let islands = unnamed.get("none").map(|e| e.0.iter().filter(|n| n.starts_with("UNK_ISO")).count()).unwrap_or(0);+    let doc = json!({+        "what": "How well each net is proven. A net's level is the rung of the weakest link in the set of links that joins its pins, strongest links first; docs/method.md, \"A net's confidence is its weakest link\".",+        "ladder": LADDER, "from": ["detect/connect-graph.json", "detect/attribution.json", "nets.json", "copper.json", "net-evidence.json"],+        "copperSha": crate::attrib::zones_key(&copper),+        "summary": {"copperNotRead": unread.iter().map(|f| f.side.clone()).collect::<Vec<_>>(), "nets": rows.len(), "levels": counts(&levels), "netsOfTwoOrMorePins": counts(&multi), "netsOfOnePin": counts(&single), "netsDisputed": disputed,+            "copperAsDrawn": LADDER.iter().map(|r| (r.to_string(), cu(&drawn[r]))).collect::<serde_json::Map<_, _>>(),+            "copperByOwnRung": LADDER.iter().map(|r| (r.to_string(), cu(&by_own[r]))).collect::<serde_json::Map<_, _>>(),+            "unnamed": un, "unnamedIslands": islands, "differsFromMeasuredTag": tag_differs, "datasheetWitness": ev["datasheetWitness"]},+        "nets": out,+    });+    std::fs::write(flow.join(FILE), serde_json::to_string_pretty(&doc).unwrap() + "\n").map_err(|e| format!("{FILE}: {e}"))?;+    Ok(doc)+}++#[cfg(test)]+mod tests {+    use super::*;+    fn solid(pin: &str, node: usize, kind: &str) -> Contact { Contact { pin: pin.into(), node, solid: true, kind: kind.into(), side: "top".into() } }+    fn weak(pin: &str, node: usize) -> Contact { Contact { pin: pin.into(), node, solid: false, kind: "pour-neck".into(), side: "top".into() } }+    fn link(a: usize, b: usize, tier: u8) -> Link { Link { a, b, tier, vias: vec![format!("v{a}{b}")] } }+    fn graph(names: &[(usize, &str)], contacts: Vec<Contact>, links: Vec<Link>) -> Graph { Graph { node_net: names.iter().map(|(k, n)| (*k, n.to_string())).collect(), contacts, links } }+    fn pins(v: &[&str]) -> Vec<String> { v.iter().map(|s| s.to_string()).collect() }++    #[test]+    fn pins_on_one_conductor_are_copper() {+        let g = graph(&[(0, "A")], vec![solid("R1.1", 0, "pour"), solid("U1.3", 0, "track-end")], vec![]);+        let l = net_level("A", &pins(&["R1.1", "U1.3"]), &g);+        assert_eq!(l.level, "copper"); assert_eq!(l.steps, [2, 0, 0]); assert!(l.inferred.is_empty());+        // the weakest of equal rungs is the meeting that rests on least: a track end, not a pour+        assert_eq!(l.weakest.unwrap().pins, vec!["U1.3"]);+    }++    #[test]+    fn a_verified_via_between_two_conductors_makes_the_net_via() {+        let g = graph(&[(0, "A"), (1, "A")], vec![solid("R1.1", 0, "pour"), solid("U1.3", 1, "pour")], vec![link(0, 1, 2)]);+        let l = net_level("A", &pins(&["R1.1", "U1.3"]), &g);+        assert_eq!(l.level, "via"); assert_eq!(l.steps, [2, 1, 0]); assert_eq!(l.weakest.unwrap().kind, "verified-vias");+        assert_eq!(l.groups.len(), 1, "verified vias join what is seen");+    }++    #[test]+    fn one_inferred_step_makes_the_whole_net_inferred_whatever_else_was_seen() {+        // nineteen pins on one pour, the twentieth joined only by one via track to track+        let mut c: Vec<Contact> = (1..=19).map(|k| solid(&format!("C{k}.1"), 0, "pour")).collect();+        c.push(solid("U9.1", 1, "track-end"));+        let g = graph(&[(0, "A"), (1, "A")], c, vec![link(0, 1, 3)]);+        let p: Vec<String> = (1..=19).map(|k| format!("C{k}.1")).chain(["U9.1".to_string()]).collect();+        let l = net_level("A", &p, &g);+        assert_eq!(l.level, "inferred"); assert_eq!(l.steps, [20, 0, 1]);+        assert_eq!(l.weakest.as_ref().unwrap().kind, "single-via-track-to-track"); assert_eq!(l.weakest_count, 1);+        assert_eq!(l.seen.len(), 20); assert_eq!(l.groups.len(), 2, "an inferred via does not make two groups one");+    }++    #[test]+    fn a_pin_with_no_copper_is_a_member_by_inference_only() {+        let g = graph(&[(0, "A")], vec![solid("R1.1", 0, "pour"), solid("R2.1", 0, "pour")], vec![]);+        let l = net_level("A", &pins(&["R1.1", "R2.1", "U1.7"]), &g);+        assert_eq!(l.level, "inferred"); assert_eq!(l.inferred, vec!["U1.7"]);+        let w = l.weakest.unwrap(); assert_eq!((w.kind, w.pins), ("pin-map-only", vec!["U1.7".to_string()]));+    }++    #[test]+    fn copper_no_pin_path_crosses_does_not_lower_the_level() {+        // both pins on one pour; a second piece of the net hangs off it by one via, with no pin on it+        let g = graph(&[(0, "A"), (1, "A"), (2, "A")], vec![solid("R1.1", 0, "pour"), solid("U1.3", 0, "pour")], vec![link(0, 1, 3), link(1, 2, 4)]);+        let l = net_level("A", &pins(&["R1.1", "U1.3"]), &g);+        assert_eq!(l.level, "copper"); assert_eq!(l.steps, [2, 0, 0]);+        // the same piece between two pins is on the path, and counts+        let g = graph(&[(0, "A"), (1, "A"), (2, "A")], vec![solid("R1.1", 0, "pour"), solid("U1.3", 2, "pour")], vec![link(0, 1, 2), link(1, 2, 3)]);+        let l = net_level("A", &pins(&["R1.1", "U1.3"]), &g);+        assert_eq!(l.level, "inferred"); assert_eq!(l.steps, [2, 1, 1]);+    }++    #[test]+    fn a_stronger_path_is_preferred_to_a_weaker_one() {+        // two conductors joined both by verified vias and by a single via: the path set keeps the verified one+        let g = graph(&[(0, "A"), (1, "A")], vec![solid("R1.1", 0, "pour"), solid("U1.3", 1, "pour")], vec![link(0, 1, 4), link(0, 1, 2)]);+        assert_eq!(net_level("A", &pins(&["R1.1", "U1.3"]), &g).level, "via");+    }++    #[test]+    fn copper_of_another_net_joins_nothing() {+        let g = graph(&[(0, "A"), (1, "B")], vec![solid("R1.1", 0, "pour"), solid("U1.3", 1, "pour")], vec![link(0, 1, 2)]);+        let l = net_level("A", &pins(&["R1.1", "U1.3"]), &g);+        assert_eq!(l.level, "inferred"); assert_eq!(l.seen, vec!["R1.1"]);+    }++    #[test]+    fn an_unsettled_pad_edge_is_inferred_and_two_seen_groups_hang_on_the_pin_map() {+        let g = graph(&[(0, "A")], vec![solid("R1.1", 0, "pour"), weak("U1.3", 0)], vec![]);+        let l = net_level("A", &pins(&["R1.1", "U1.3"]), &g);+        assert_eq!((l.level, l.weakest.unwrap().kind), ("inferred", "pad-edge-unsettled"));+        let g = graph(&[(0, "A"), (1, "A")], vec![solid("R1.1", 0, "pour"), solid("R2.1", 0, "pour"), solid("U1.3", 1, "pour"), solid("U1.4", 1, "pour"), solid("U1.5", 1, "pour")], vec![]);+        let l = net_level("A", &pins(&["R1.1", "R2.1", "U1.3", "U1.4", "U1.5"]), &g);+        let w = l.weakest.unwrap();+        assert_eq!((l.level, w.kind), ("inferred", "pin-map-only")); assert_eq!(w.pins, vec!["R1.1", "R2.1"], "the smaller group hangs on the larger");+        assert_eq!(l.groups.len(), 2);+    }++    #[test]+    fn a_net_of_one_pin_is_graded_by_how_the_pin_meets_its_copper() {+        let g = graph(&[(0, "A")], vec![solid("J1.4", 0, "track-end")], vec![]);+        assert_eq!(net_level("A", &pins(&["J1.4"]), &g).level, "copper");+        assert_eq!(net_level("A", &pins(&["J1.5"]), &g).level, "inferred");+        assert_eq!(net_level("A", &[], &g).level, "none");+    }++    #[test]+    fn names_sort_the_way_a_person_reads_them_and_files_never_collide() {+        let mut v = vec!["C10.1", "C2.1", "C1.2", "c1.1"]; v.sort_by(|a, b| nat_cmp(a, b));+        assert_eq!(v, vec!["c1.1", "C1.2", "C2.1", "C10.1"]);+        let mut t = BTreeSet::new();+        assert_eq!(file_stem("+3V3", &mut t), "plus3V3"); assert_eq!(file_stem("USB3-0-RX_N", &mut t), "USB3-0-RX_N");+        assert_eq!(file_stem("SOA", &mut t), "SOA"); assert_eq!(file_stem("soa", &mut t), "soa-2"); assert_eq!(file_stem("A/B", &mut t), "A_B");+    }++    #[test]+    fn a_piece_is_drawn_at_the_weaker_of_its_rung_and_its_nets_level() {+        assert_eq!(weaker("copper", "inferred"), "inferred"); assert_eq!(weaker("via", "copper"), "via"); assert_eq!(weaker("conflict", "copper"), "conflict");+        assert_eq!(unnamed_class("UNK_ISO12"), Some("none")); assert_eq!(unnamed_class("UNK_G3"), Some("group")); assert_eq!(unnamed_class("UNK_CONFLICT_v4"), Some("conflict")); assert_eq!(unnamed_class("GND"), None);+    }+}
src/netdraw.rsadded+757
@@ -0,0 +1,757 @@+//! `nets draw`: the nets as pictures, under reverse-flow/nets/.+//!+//! - `confidence.jpg`: both faces, every piece of copper in the colour and pattern of how well its net is proven+//!   (`netconf.rs`: the weakest link joining the net's pins), with a legend and the counts. `confidence-top.jpg` and+//!   `confidence-bottom.jpg` are the same at the full 40 px/mm for looking closely.+//! - `attribution.jpg`: the evidence under it, each piece by its own rung, so the two can be compared.+//! - `confidence-table-<n>.jpg`: every net with its level, pins seen and weakest link.+//! - `net/<name>.jpg`: one picture per net, its copper, vias and pins lit on both faces and the rest of the board dimmed.+//! - `sections/<nn>-<name>.jpg`: the nets in groups (`netsections.rs`), up to eight to a sheet, each in its own hue.+//! - `index.md`: links to all of it. `sections.json`: the same for a program.+//!+//! Everything is drawn in the board frame over the face photo. The bottom photo is already turned over to line up with+//! the top, so a via sits at the same place in both panels. Nothing here reads copper from a photo: the shapes are+//! copper.json's, the nets and rungs are attribution's.+use crate::netconf::{self, nat_cmp, rank, LADDER};+use crate::netpaint::*;+use crate::netsections::{self, Section};+use image::RgbImage;+use serde_json::{json, Value};+use std::collections::{BTreeMap, BTreeSet, HashMap};+use std::path::Path;++const RES: f64 = crate::board::RES;+const ORIGIN: f64 = crate::board::ORIGIN;+const SIDES: [&str; 2] = ["top", "bottom"];+const SIDE_TITLES: [&str; 2] = ["TOP FACE", "BOTTOM FACE, turned over to line up with the top"];+/// Nets to a section sheet: one hue each.+const PER_SHEET: usize = 8;+/// A net that covers more of the board than this is shown on the whole board, not zoomed.+const ZOOM_MAX_SHARE: f64 = 0.45;++pub struct Zone { pub(crate) net: usize, pub(crate) rung: usize, pub(crate) side: usize, pub(crate) runs: Vec<(u32, u32, u32)>, bb: (f64, f64, f64, f64), area: f64, anchor: (f64, f64) }+pub struct Track { pub(crate) net: usize, pub(crate) rung: usize, pub(crate) side: usize, pub(crate) pts: Vec<(f64, f64)>, pub(crate) width: f64 }+pub struct Via { pub(crate) net: usize, pub(crate) rung: usize, pub(crate) x: f64, pub(crate) y: f64, pub(crate) d: f64 }+#[derive(Clone)]+pub struct Pin { part: String, pub(crate) x: f64, pub(crate) y: f64, w: f64, h: f64, rot: f64, round: bool, pub(crate) sides: [bool; 2] }+pub struct Part { name: String, side: usize, corners: [(f64, f64); 4] }+pub struct Scene {+    pub(crate) name: String, pub(crate) wmm: f64, pub(crate) hmm: f64, pub(crate) faces: [Option<Vec<RgbImage>>; 2],+    pub(crate) zones: Vec<Zone>, pub(crate) tracks: Vec<Track>, pub(crate) vias: Vec<Via>, pub(crate) pins: HashMap<String, Pin>, parts: Vec<Part>,+    pub(crate) net_ix: HashMap<String, usize>,+    /// Per face: the words to print over it when its copper failed `check copper` (`notread`). Its copper is not in `zones` or `tracks`.+    pub(crate) not_read: [Option<String>; 2],+}++/// The photo dimmed and greyed, so colour on it means something, and halved a few times for drawing small.+fn dimmed(img: &RgbImage) -> Vec<RgbImage> {+    let mut base = img.clone();+    for p in base.pixels_mut() { let g = 0.299 * p[0] as f32 + 0.587 * p[1] as f32 + 0.114 * p[2] as f32; for k in 0..3 { p[k] = ((0.72 * g + 0.28 * p[k] as f32) * 0.58 + 12.0) as u8; } }+    let mut out = vec![base];+    for _ in 0..3 {+        let a = out.last().unwrap(); let (w, h) = (a.width() / 2, a.height() / 2); if w < 8 || h < 8 { break; }+        out.push(RgbImage::from_fn(w, h, |x, y| { let mut s = [0u32; 3]; for (dx, dy) in [(0, 0), (1, 0), (0, 1), (1, 1)] { let p = a.get_pixel(x * 2 + dx, y * 2 + dy); for k in 0..3 { s[k] += p[k] as u32; } } image::Rgb([(s[0] / 4) as u8, (s[1] / 4) as u8, (s[2] / 4) as u8]) }));+    }+    out+}++fn pts_of(v: &Value) -> Vec<(f64, f64)> { v.as_array().into_iter().flatten().map(|q| (q[0].as_f64().unwrap_or(0.0), q[1].as_f64().unwrap_or(0.0))).collect() }++pub fn load(flow: &Path) -> Result<Scene, String> {+    let copper = crate::items::read_json(&flow.join("copper.json"))?;+    let mut faces: [Option<Vec<RgbImage>>; 2] = [None, None];+    let (mut wmm, mut hmm) = (0.0f64, 0.0f64);+    for (k, side) in SIDES.iter().enumerate() {+        let p = flow.join(format!("board/{side}.jpg"));+        if !p.is_file() { continue; }+        let img = image::open(&p).map_err(|e| format!("{}: {e}", p.display()))?.to_rgb8();+        wmm = wmm.max(img.width() as f64 / RES); hmm = hmm.max(img.height() as f64 / RES);+        faces[k] = Some(dimmed(&img));+    }+    if faces[0].is_none() && faces[1].is_none() { return Err("no board/top.jpg or board/bottom.jpg: `scale` writes them".into()); }+    let mut net_ix: HashMap<String, usize> = HashMap::new();+    let mut ix = |n: &str| -> usize { let k = net_ix.len(); *net_ix.entry(n.to_string()).or_insert(k) };+    let side_of = |layer: Option<&str>| if layer == Some("B.Cu") { 1 } else { 0 };+    let rung_of = |v: &Value| rank(v["netEvidence"]["rung"].as_str().unwrap_or("none"));+    let (mut zones, mut tracks, mut vias) = (vec![], vec![], vec![]);+    // a face whose copper failed `check copper` is not drawn as copper of any grade: nothing on it was proven, and the panel says so+    let unread = crate::notread::faces(flow);+    let not_read: [Option<String>; 2] = [0, 1].map(|k| unread.iter().find(|f| f.side == SIDES[k]).map(crate::notread::short));+    for z in copper["zones"].as_array().into_iter().flatten() {+        if !matches!(z["layer"].as_str(), Some("F.Cu") | Some("B.Cu")) || crate::notread::skips_layer(&unread, z["layer"].as_str().unwrap_or("")) { continue; }+        let mut runs = vec![]; let (mut bb, mut best) = ((f64::MAX, f64::MAX, f64::MIN, f64::MIN), (0u32, (0.0, 0.0)));+        for r in z["rle_px"].as_array().into_iter().flatten() {+            let Some(a) = r.as_array() else { continue }; let y = a[0].as_u64().unwrap_or(0) as u32;+            for s in a[1..].chunks(2) { if s.len() != 2 { continue; } let (x0, x1) = (s[0].as_u64().unwrap_or(0) as u32, s[1].as_u64().unwrap_or(0) as u32); if x1 <= x0 { continue; }+                runs.push((y, x0, x1));+                let (mx0, mx1, my) = (ORIGIN + x0 as f64 / RES, ORIGIN + x1 as f64 / RES, ORIGIN + y as f64 / RES);+                bb = (bb.0.min(mx0), bb.1.min(my), bb.2.max(mx1), bb.3.max(my + 1.0 / RES));+                if x1 - x0 > best.0 { best = (x1 - x0, ((mx0 + mx1) / 2.0, my)); } }+        }+        if runs.is_empty() { continue; }+        zones.push(Zone { net: ix(z["net"].as_str().unwrap_or("")), rung: rung_of(z), side: side_of(z["layer"].as_str()), runs, bb, area: z["areaMm2"].as_f64().unwrap_or(0.0), anchor: best.1 });+    }+    for t in copper["traces"].as_array().into_iter().flatten() {+        if !matches!(t["layer"].as_str(), Some("F.Cu") | Some("B.Cu")) || crate::notread::skips_layer(&unread, t["layer"].as_str().unwrap_or("")) { continue; }+        let pts = pts_of(&t["path_mm"]); if pts.len() < 2 { continue; }+        tracks.push(Track { net: ix(t["net"].as_str().unwrap_or("")), rung: rung_of(t), side: side_of(t["layer"].as_str()), pts, width: t["width_mm"].as_f64().unwrap_or(0.15) });+    }+    for v in copper["vias"].as_array().into_iter().flatten() {+        vias.push(Via { net: ix(v["net"].as_str().unwrap_or("")), rung: rung_of(v), x: v["pos_mm"][0].as_f64().unwrap_or(0.0), y: v["pos_mm"][1].as_f64().unwrap_or(0.0), d: v["diameter_mm"].as_f64().unwrap_or(0.5) });+    }+    // pads as `connect measure` read them: the library outline at the place the photo shows the metal+    let mut pins: HashMap<String, Pin> = HashMap::new();+    if let Ok(ms) = crate::items::read_json(&flow.join("detect/attrib/measure.json")) { for p in ms["pads"].as_array().into_iter().flatten() {+        let num = p["num"].as_str().unwrap_or(""); if num.is_empty() { continue; }+        let layers: Vec<&str> = p["layers"].as_array().into_iter().flatten().filter_map(|l| l.as_str()).collect();+        let both = layers.iter().any(|l| *l == "*.Cu" || *l == "F&B.Cu");+        let sides = [both || layers.contains(&"F.Cu"), both || layers.contains(&"B.Cu")];+        if !sides[0] && !sides[1] { continue; }+        let c = if p["core"].is_object() && p["drill"].as_f64().unwrap_or(0.0) <= 0.0 { &p["core"] } else { p };+        let f = |k: &str| c[k].as_f64().unwrap_or(0.0);+        let part = p["part"].as_str().unwrap_or("").to_string();+        let e = pins.entry(format!("{part}.{num}")).or_insert(Pin { part, x: f("x"), y: f("y"), w: f("w").max(0.2), h: f("h").max(0.2), rot: f("rot"), round: p["round"].as_bool().unwrap_or(false), sides: [false, false] });+        e.sides = [e.sides[0] || sides[0], e.sides[1] || sides[1]];+    } }+    let parts_doc = crate::items::read_json(&flow.join("parts.json")).unwrap_or(json!({}));+    let parts = crate::kicad_out::bodies_of(&crate::kicad_out::cache_dir(flow), parts_doc["parts"].as_array().map(|v| v.as_slice()).unwrap_or(&[])).into_iter()+        .map(|b| Part { corners: b.corners(), name: b.part, side: if b.layer == "B.Cu" { 1 } else { 0 } }).collect();+    let name = flow.parent().and_then(|p| p.file_name()).map(|f| f.to_string_lossy().to_string()).unwrap_or_default();+    Ok(Scene { name, wmm, hmm, faces, zones, tracks, vias, pins, parts, net_ix, not_read })+}++// ---------------------------------------------------------------- a panel: one face through a window++#[derive(Clone, Debug)]+pub struct View { side: usize, win: (f64, f64, f64, f64), x: f64, y: f64, w: usize, h: usize }+impl View {+    fn scale(&self) -> f64 { self.w as f64 / (self.win.2 - self.win.0) }+    fn local(&self, p: (f64, f64)) -> (f64, f64) { ((p.0 - self.win.0) * self.scale(), (p.1 - self.win.1) * self.scale()) }+    fn at(&self, p: (f64, f64)) -> (f64, f64) { let q = self.local(p); (self.x + q.0, self.y + q.1) }+    fn holds(&self, p: (f64, f64)) -> bool { p.0 >= self.win.0 && p.0 <= self.win.2 && p.1 >= self.win.1 && p.1 <= self.win.3 }+    /// The largest panel showing `win` whole inside the room given, centred in it.+    fn fit(side: usize, win: (f64, f64, f64, f64), room: (f64, f64, f64, f64)) -> View {+        let s = (room.2 / (win.2 - win.0)).min(room.3 / (win.3 - win.1));+        let (w, h) = (((win.2 - win.0) * s).floor().max(1.0), ((win.3 - win.1) * s).floor().max(1.0));+        View { side, win, x: (room.0 + (room.2 - w) / 2.0).round(), y: (room.1 + (room.3 - h) / 2.0).round(), w: w as usize, h: h as usize }+    }+}++fn whole(s: &Scene) -> (f64, f64, f64, f64) { (ORIGIN, ORIGIN, ORIGIN + s.wmm, ORIGIN + s.hmm) }++/// The window for a set of points: the whole board when they cover much of it, else their box with room round it, in the+/// panel's shape so the panel is filled.+pub fn window(board: (f64, f64, f64, f64), bb: Option<(f64, f64, f64, f64)>, aspect: f64) -> ((f64, f64, f64, f64), bool) {+    let Some(b) = bb else { return (board, false) };+    let pad = (0.14 * (b.2 - b.0).max(b.3 - b.1)).max(2.5);+    let (mut w, mut h) = ((b.2 - b.0 + 2.0 * pad).max(12.0), (b.3 - b.1 + 2.0 * pad).max(12.0));+    if w / h < aspect { w = h * aspect; } else { h = w / aspect; }+    let (bw, bh) = (board.2 - board.0, board.3 - board.1);+    if w * h > ZOOM_MAX_SHARE * bw * bh || w > bw * 1.05 || h > bh * 1.05 { return (board, false); }+    // keep the window on the board where it can be+    let (mut cx, mut cy) = ((b.0 + b.2) / 2.0, (b.1 + b.3) / 2.0);+    if w <= bw { cx = cx.clamp(board.0 + w / 2.0, board.2 - w / 2.0); } else { cx = (board.0 + board.2) / 2.0; }+    if h <= bh { cy = cy.clamp(board.1 + h / 2.0, board.3 - h / 2.0); } else { cy = (board.1 + board.3) / 2.0; }+    ((cx - w / 2.0, cy - h / 2.0, cx + w / 2.0, cy + h / 2.0), true)+}++fn base(img: &mut RgbImage, s: &Scene, v: &View) {+    let sc = v.scale();+    let Some(levels) = &s.faces[v.side] else { fill_rect(img, v.x, v.y, v.x + v.w as f64, v.y + v.h as f64, [30, 33, 38], 1.0); return };+    let mut k = 0; while k + 1 < levels.len() && RES / (1 << (k + 1)) as f64 >= sc { k += 1; }+    let (lv, res) = (&levels[k], RES / (1 << k) as f64);+    let get = |u: f64, w: f64| -> Option<[f32; 3]> {+        if u < -0.5 || w < -0.5 || u > lv.width() as f64 - 0.5 || w > lv.height() as f64 - 0.5 { return None; }+        let (u, w) = (u.clamp(0.0, lv.width() as f64 - 1.001), w.clamp(0.0, lv.height() as f64 - 1.001));+        let (x0, y0) = (u.floor() as u32, w.floor() as u32); let (fx, fy) = ((u - x0 as f64) as f32, (w - y0 as f64) as f32);+        let p = |x: u32, y: u32| lv.get_pixel(x.min(lv.width() - 1), y.min(lv.height() - 1));+        let (a, b, c, d) = (p(x0, y0), p(x0 + 1, y0), p(x0, y0 + 1), p(x0 + 1, y0 + 1));+        Some([0, 1, 2].map(|i| (a[i] as f32 * (1.0 - fx) + b[i] as f32 * fx) * (1.0 - fy) + (c[i] as f32 * (1.0 - fx) + d[i] as f32 * fx) * fy))+    };+    for py in 0..v.h { for px in 0..v.w {+        let mut acc = [0f32; 3]; let mut n = 0f32;+        for (dx, dy) in [(0.25, 0.25), (0.75, 0.25), (0.25, 0.75), (0.75, 0.75)] {+            let (mx, my) = (v.win.0 + (px as f64 + dx) / sc, v.win.1 + (py as f64 + dy) / sc);+            if let Some(c) = get((mx - ORIGIN) * res - 0.5, (my - ORIGIN) * res - 0.5) { for i in 0..3 { acc[i] += c[i]; } n += 1.0; }+        }+        let c = if n > 0.0 { [0, 1, 2].map(|i| (acc[i] / n * (n / 4.0) + PAPER[i] as f32 * (1.0 - n / 4.0)) as u8) } else { PAPER };+        img.put_pixel((v.x as i64 + px as i64) as u32, (v.y as i64 + py as i64) as u32, image::Rgb(c));+    } }+}++/// Paint the copper `pick` chooses, each piece in the style whose index it returns. Pours carry the pattern, tracks+/// are dashed for the weaker styles and never thinner than `min_half` so they stay visible when the board is small.+fn copper(img: &mut RgbImage, s: &Scene, v: &View, unit: f64, styles: &[Style], pick: &dyn Fn(usize, usize) -> Option<usize>, alpha: f32, min_half: f64) {+    let sc = v.scale();+    let hit = |bb: (f64, f64, f64, f64)| bb.2 >= v.win.0 && bb.0 <= v.win.2 && bb.3 >= v.win.1 && bb.1 <= v.win.3;+    let mut zs: Vec<Vec<&Zone>> = vec![vec![]; styles.len()]; let mut ts: Vec<Vec<&Track>> = vec![vec![]; styles.len()];+    for z in s.zones.iter().filter(|z| z.side == v.side && hit(z.bb)) { if let Some(k) = pick(z.net, z.rung) { zs[k].push(z); } }+    for t in s.tracks.iter().filter(|t| t.side == v.side) { if let Some(k) = pick(t.net, t.rung) { if t.pts.iter().any(|p| v.holds(*p)) { ts[k].push(t); } } }+    let mut cov = Cover::new(v.w, v.h);+    // the weakest styles first, so what is better proven is never hidden under what is less+    for k in (0..styles.len()).rev() {+        if !zs[k].is_empty() {+            cov.clear();+            for z in &zs[k] { for (y, x0, x1) in &z.runs {+                let a = v.local((ORIGIN + *x0 as f64 / RES, ORIGIN + *y as f64 / RES)); let b = v.local((ORIGIN + *x1 as f64 / RES, ORIGIN + (*y + 1) as f64 / RES));+                cov.add_rect(a.0, a.1, b.0, b.1);+            } }+            cov.paint(img, v.x as i64, v.y as i64, &styles[k], alpha, unit, true);+        }+        if !ts[k].is_empty() {+            cov.clear();+            for t in &ts[k] {+                let half = (t.width * sc / 2.0).max(min_half);+                let pts: Vec<(f64, f64)> = t.pts.iter().map(|p| v.local(*p)).collect();+                match styles[k].dash { Some((on, off)) => for (a, b) in dashes(&pts, on * unit, off * unit) { cov.capsule(a, b, half); }, None => for q in pts.windows(2) { cov.capsule(q[0], q[1], half); } }+            }+            cov.paint(img, v.x as i64, v.y as i64, &styles[k], alpha.max(0.92), unit, false);+        }+    }+    for via in s.vias.iter().filter(|q| v.holds((q.x, q.y))) {+        let Some(k) = pick(via.net, via.rung) else { continue };+        let (m, r) = (v.at((via.x, via.y)), (via.d * sc / 2.0).max(2.3 * unit));+        disc(img, m, r + 1.1 * unit, INK, 0.9); disc(img, m, r, styles[k].color, 1.0); disc(img, m, r * 0.36, INK, 0.9);+    }+    not_read_banner(img, s, v);+}++/// Over a face whose copper was not read: a band across the panel with the words, so no picture of that face reads as "no copper here".+fn not_read_banner(img: &mut RgbImage, s: &Scene, v: &View) {+    let Some(words) = &s.not_read[v.side] else { return };+    let size = (v.w as f64 / 52.0).clamp(11.0, 44.0);+    let lines = wrap(words, size, v.w as f64 - 2.0 * size, true);+    let lh = font(true).line_height(size) * 1.15;+    let (bh, y0) = (lh * lines.len() as f64 + size, v.y + (v.h as f64 - lh * lines.len() as f64 - size) / 2.0);+    fill_rect(img, v.x, y0.max(v.y), v.x + v.w as f64, (y0 + bh).min(v.y + v.h as f64), INK, 0.82);+    for (i, l) in lines.iter().enumerate() { let tw = text_width(size, l, true); text(img, v.x + (v.w as f64 - tw) / 2.0, y0 + size / 2.0 + lh * i as f64, size, l, [240, 80, 20], true); }+}++fn pin_shape(v: &View, p: &Pin, unit: f64) -> Vec<(f64, f64)> {+    let sc = v.scale(); let m = v.at((p.x, p.y));+    let (hw, hh) = ((p.w * sc / 2.0).max(3.6 * unit), (p.h * sc / 2.0).max(3.6 * unit));+    let (c, s) = (p.rot.to_radians().cos(), p.rot.to_radians().sin());+    [(-hw, -hh), (hw, -hh), (hw, hh), (-hw, hh)].iter().map(|(lx, ly)| (m.0 + lx * c + ly * s, m.1 - lx * s + ly * c)).collect()+}+/// A pin: filled when it is seen joined to the net's copper, hollow when it is on the net by inference only. A large+/// pad is not filled whole, since that would hide the copper under it: it gets a heavy outline and a filled middle.+fn pin_mark(img: &mut RgbImage, v: &View, p: &Pin, seen: bool, color: C3, unit: f64) {+    if !v.holds((p.x, p.y)) { return; }+    let m = v.at((p.x, p.y)); let big = p.w.min(p.h) * v.scale() > 20.0 * unit;+    if p.round {+        let r = (p.w.max(p.h) * v.scale() / 2.0).max(4.2 * unit);+        if !seen { ring(img, m, r, 4.6 * unit, INK, 0.95); ring(img, m, r, 2.2 * unit, color, 1.0); }+        else if big { ring(img, m, r, 6.4 * unit, INK, 0.95); ring(img, m, r, 4.0 * unit, color, 1.0); disc(img, m, 6.2 * unit, INK, 0.95); disc(img, m, 5.0 * unit, color, 1.0); }+        else { disc(img, m, r + 1.2 * unit, INK, 0.95); disc(img, m, r, color, 1.0); }+        return;+    }+    let q = pin_shape(v, p, unit);+    let room = (v.x, v.y, v.x + v.w as f64, v.y + v.h as f64);+    let edge = |img: &mut RgbImage, half: f64, c: C3| for i in 0..q.len() { if let Some((a, b)) = clip(q[i], q[(i + 1) % q.len()], room) { capsule(img, a, b, half, c, if c == INK { 0.95 } else { 1.0 }); } };+    if !seen { edge(img, 2.4 * unit, INK); edge(img, 1.15 * unit, color); }+    else if big { edge(img, 3.3 * unit, INK); edge(img, 2.1 * unit, color); disc(img, m, 6.2 * unit, INK, 0.95); disc(img, m, 5.0 * unit, color, 1.0); }+    else { edge(img, 1.6 * unit, INK); fill_convex(img, &q, color, 1.0); }+}++/// Labels that keep out of each other's way and inside their panel.+struct Labels { taken: Vec<(f64, f64, f64, f64)>, clip: (f64, f64, f64, f64) }+impl Labels {+    fn new(v: &View) -> Labels { Labels { taken: vec![], clip: (v.x + 2.0, v.y + 2.0, v.x + v.w as f64 - 2.0, v.y + v.h as f64 - 2.0) } }+    fn keep(&mut self, b: (f64, f64, f64, f64)) { self.taken.push(b); }+    fn place(&mut self, img: &mut RgbImage, at: (f64, f64), off: f64, s: &str, px: f64, c: C3, bold: bool) -> bool {+        let (w, h) = (text_width(px, s, bold), font(bold).line_height(px));+        for (x, y) in [(at.0 + off, at.1 - h / 2.0), (at.0 - off - w, at.1 - h / 2.0), (at.0 - w / 2.0, at.1 - off - h), (at.0 - w / 2.0, at.1 + off), (at.0 + off, at.1 - off - h), (at.0 - off - w, at.1 + off),+            (at.0 + off, at.1 + off), (at.0 - off - w, at.1 - off - h), (at.0 - w / 2.0, at.1 - 2.2 * off - 2.0 * h), (at.0 - w / 2.0, at.1 + 2.2 * off + h), (at.0 + off, at.1 - 2.2 * off - 2.0 * h), (at.0 - off - w, at.1 + 2.2 * off + h)] {+            let b = (x - 2.0, y, x + w + 2.0, y + h);+            if b.0 < self.clip.0 || b.1 < self.clip.1 || b.2 > self.clip.2 || b.3 > self.clip.3 { continue; }+            if self.taken.iter().any(|t| b.0 < t.2 && b.2 > t.0 && b.1 < t.3 && b.3 > t.1) { continue; }+            text_halo(img, x, y, px, s, c, bold); self.taken.push(b); return true;+        }+        false+    }+}++fn panel_frame(img: &mut RgbImage, v: &View, unit: f64, zoomed: bool) {+    frame(img, v.x - unit, v.y - unit, v.x + v.w as f64 + unit, v.y + v.h as f64 + unit, (1.5 * unit).max(1.0), [120, 126, 134], 1.0);+    let label = if zoomed { format!("{}  (zoomed in)", SIDE_TITLES[v.side]) } else { SIDE_TITLES[v.side].to_string() };+    let px = 17.0 * unit; let w = text_width(px, &label, true) + 16.0 * unit;+    fill_rect(img, v.x, v.y, v.x + w, v.y + 27.0 * unit, INK, 0.82);+    text(img, v.x + 8.0 * unit, v.y + 3.0 * unit, px, &label, WHITE, true);+}++fn part_outlines(img: &mut RgbImage, s: &Scene, v: &View, unit: f64, only: Option<&BTreeSet<&str>>, alpha: f32) {+    for p in s.parts.iter().filter(|p| p.side == v.side && only.map(|o| o.contains(p.name.as_str())).unwrap_or(true)) {+        if !p.corners.iter().any(|c| v.holds(*c)) { continue; }+        let q: Vec<(f64, f64)> = p.corners.iter().map(|c| v.at(*c)).collect();+        // a connector that hangs over the board's edge is cut at the panel's edge+        let room = (v.x, v.y, v.x + v.w as f64, v.y + v.h as f64);+        for i in 0..q.len() { if let Some((a, b)) = clip(q[i], q[(i + 1) % q.len()], room) { capsule(img, a, b, 0.75 * unit, SOFT, alpha); } }+    }+}++/// The part of a line inside a rectangle.+pub fn clip(a: (f64, f64), b: (f64, f64), r: (f64, f64, f64, f64)) -> Option<((f64, f64), (f64, f64))> {+    let (dx, dy) = (b.0 - a.0, b.1 - a.1); let (mut t0, mut t1) = (0.0f64, 1.0f64);+    for (p, q) in [(-dx, a.0 - r.0), (dx, r.2 - a.0), (-dy, a.1 - r.1), (dy, r.3 - a.1)] {+        if p == 0.0 { if q < 0.0 { return None; } continue; }+        let t = q / p;+        if p < 0.0 { if t > t1 { return None; } t0 = t0.max(t); } else { if t < t0 { return None; } t1 = t1.min(t); }+    }+    Some(((a.0 + t0 * dx, a.1 + t0 * dy), (a.0 + t1 * dx, a.1 + t1 * dy)))+}++fn level_name(l: &str) -> &'static str { match l { "copper" => "copper", "via" => "via", "inferred" => "inferred", "group" => "group", "conflict" => "conflict", _ => "none" } }+fn level_words(l: &str) -> &'static str {+    match l { "copper" => "every pin seen joined by continuous copper", "via" => "joined through verified vias at the weakest point", "inferred" => "the weakest link joining its pins is inferred", "group" => "unnamed copper joined into a group", "conflict" => "two nets meet and nothing settles it", _ => "no evidence" }+}++/// A level as a tag: its pattern, then its name.+fn badge(img: &mut RgbImage, x: f64, y: f64, level: &str, px: f64, unit: f64) -> f64 {+    let h = font(true).line_height(px); let sw = h * 0.82;+    let w = sw + 10.0 * unit + text_width(px, level_name(level), true) + 20.0 * unit;+    fill_rect(img, x, y, x + w, y + h + 6.0 * unit, [44, 48, 54], 1.0);+    swatch(img, x + 6.0 * unit, y + (h + 6.0 * unit - sw) / 2.0, sw, &RUNGS[rank(level)], unit);+    text(img, x + sw + 14.0 * unit, y + 3.0 * unit, px, level_name(level), RUNGS[rank(level)].color, true);+    x + w+}++fn s<'a>(v: &'a Value, k: &str) -> &'a str { v[k].as_str().unwrap_or("") }+fn n(v: &Value, k: &str) -> usize { v[k].as_u64().unwrap_or(0) as usize }+fn pt(v: &Value) -> Option<(f64, f64)> { Some((v[0].as_f64()?, v[1].as_f64()?)) }++/// The box round a net's copper and pins, both faces.+fn net_box(sc: &Scene, nets: &BTreeSet<usize>, pins: &[&Pin]) -> Option<(f64, f64, f64, f64)> {+    let mut b = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);+    let mut add = |x: f64, y: f64| { b = (b.0.min(x), b.1.min(y), b.2.max(x), b.3.max(y)); };+    for z in sc.zones.iter().filter(|z| nets.contains(&z.net)) { add(z.bb.0, z.bb.1); add(z.bb.2, z.bb.3); }+    for t in sc.tracks.iter().filter(|t| nets.contains(&t.net)) { for p in &t.pts { add(p.0, p.1); } }+    for v in sc.vias.iter().filter(|v| nets.contains(&v.net)) { add(v.x, v.y); }+    for p in pins { add(p.x, p.y); }+    (b.0 <= b.2).then_some(b)+}++fn pins_of(row: &Value) -> Vec<(String, bool)> {+    let mut v: Vec<(String, bool)> = row["seenPins"].as_array().into_iter().flatten().filter_map(|p| p.as_str().map(|x| (x.to_string(), true))).collect();+    v.extend(row["inferredMembers"].as_array().into_iter().flatten().filter_map(|p| p["pin"].as_str().map(|x| (x.to_string(), false))));+    v+}++fn pins_line(row: &Value) -> String {+    let (p, seen) = (n(row, "pins"), n(row, "pinsSeen"));+    match p { 0 => "no pins".into(), 1 => format!("1 pin, {}", if seen == 1 { "seen on its copper" } else { "by inference only" }), _ => format!("{p} pins: {seen} seen joined to its copper, {} by inference only", p - seen) }+}++/// One net: its copper, vias and pins lit on both faces.+fn net_picture(sc: &Scene, row: &Value, path: &Path) -> Result<(), String> {+    let (wd, ht, u) = (1920u32, 1080u32, 1.0f64);+    let mut img = RgbImage::from_pixel(wd, ht, px(PAPER));+    let name = s(row, "net"); let level = s(row, "level");+    let ni = sc.net_ix.get(name).copied();+    let nets: BTreeSet<usize> = ni.into_iter().collect();+    let pins = pins_of(row);+    let shapes: Vec<(&String, &Pin, bool)> = pins.iter().filter_map(|(p, seen)| sc.pins.get(p).map(|q| (p, q, *seen))).collect();+    let sides: Vec<usize> = (0..2).filter(|k| sc.faces[*k].is_some()).collect();+    let (top, bottom, gap) = (124.0, 150.0, 16.0);+    let room_w = (wd as f64 - 24.0 - gap * (sides.len() as f64 - 1.0)) / sides.len() as f64; let room_h = ht as f64 - top - bottom;+    let (win, zoomed) = window(whole(sc), net_box(sc, &nets, &shapes.iter().map(|x| x.1).collect::<Vec<_>>()), room_w / room_h);+    let views: Vec<View> = sides.iter().enumerate().map(|(i, k)| View::fit(*k, win, (12.0 + i as f64 * (room_w + gap), top, room_w, room_h))).collect();+    let parts_on: BTreeSet<&str> = shapes.iter().map(|x| x.1.part.as_str()).collect();+    let weak = &row["weakestLink"];+    for v in &views {+        base(&mut img, sc, v);+        copper(&mut img, sc, v, u, &RUNGS, &|net, rung| (Some(net) == ni).then_some(rung), 0.9, 1.5);+        if parts_on.len() <= 60 { part_outlines(&mut img, sc, v, u, Some(&parts_on), 0.75); }+        for (_, p, seen) in shapes.iter().filter(|x| x.1.sides[v.side]) { pin_mark(&mut img, v, p, *seen, WHITE, u); }+        let mut lab = Labels::new(v);+        lab.keep((v.x, v.y, v.x + 420.0, v.y + 30.0));+        // the weakest link, ringed, on the face it is on+        if level != "copper" { if let Some(at) = pt(&weak["at"]) { if v.holds(at) && weak["side"].as_str().map(|x| x == SIDES[v.side]).unwrap_or(true) {+            let m = v.at(at);+            ring(&mut img, m, 21.0 * u, 7.0 * u, INK, 0.9); ring(&mut img, m, 21.0 * u, 3.4 * u, [255, 235, 90], 1.0);+            lab.place(&mut img, m, 27.0 * u, "weakest link", 19.0 * u, [255, 235, 90], true);+        } } }+        for d in row["disputes"].as_array().into_iter().flatten() { if let Some(at) = pt(&d["at"]) { if v.holds(at) {+            let m = v.at(at); let r = 13.0 * u;+            for (a, b) in [((m.0 - r, m.1 - r), (m.0 + r, m.1 + r)), ((m.0 - r, m.1 + r), (m.0 + r, m.1 - r))] { capsule(&mut img, a, b, 3.6 * u, INK, 0.9); capsule(&mut img, a, b, 1.9 * u, RUNGS[5].color, 1.0); }+            let with: Vec<&str> = d["with"].as_array().into_iter().flatten().filter_map(|x| x.as_str()).collect();+            lab.place(&mut img, m, 18.0 * u, &format!("meets {}", with.join(", ")), 17.0 * u, RUNGS[5].color, true);+        } } }+        let here: Vec<&(&String, &Pin, bool)> = shapes.iter().filter(|x| x.1.sides[v.side] && v.holds((x.1.x, x.1.y))).collect();+        if here.len() <= 18 { for (p, q, _) in here { lab.place(&mut img, v.at((q.x, q.y)), 9.0 * u + (q.w.max(q.h) * v.scale() / 2.0).min(30.0), p, 18.0 * u, WHITE, false); } }+        else if parts_on.len() <= 40 { let mut done: BTreeSet<&str> = BTreeSet::new(); for (_, q, _) in here { if done.insert(q.part.as_str()) { lab.place(&mut img, v.at((q.x, q.y)), 9.0 * u, &q.part, 17.0 * u, WHITE, false); } } }+        panel_frame(&mut img, v, u, zoomed);+    }+    // title: the net, its level, its pins, then the weakest link in words+    let x = text(&mut img, 16.0, 6.0, 46.0, &fit(name, 46.0, 760.0, true), WHITE, true);+    let x = badge(&mut img, x + 22.0, 10.0, level, 32.0, u);+    text(&mut img, x + 22.0, 20.0, 25.0, &fit(&pins_line(row), 25.0, wd as f64 - x - 40.0, false), SOFT, false);+    let what = if weak.is_object() { format!("Weakest link: {}{}", s(weak, "what"), if n(weak, "sameKindOnPath") > 1 { format!(" ({} links like it)", n(weak, "sameKindOnPath")) } else { String::new() }) } else { "No pins, so nothing to join".to_string() };+    let lines = wrap(&what, 21.0, wd as f64 - 32.0, false);+    for (i, l) in lines.iter().take(2).enumerate() { let l = if i == 1 && lines.len() > 2 { fit(&format!("{l} {}", lines[2]), 21.0, wd as f64 - 32.0, false) } else { l.clone() }; text(&mut img, 16.0, 62.0 + 26.0 * i as f64, 21.0, &l, [236, 238, 242], false); }+    // legend: what the colours and marks mean, and how much of the net's copper sits on each rung+    let y0 = ht as f64 - bottom + 14.0;+    let has_copper = row["copperByRung"].as_object().map(|m| !m.is_empty()).unwrap_or(false);+    text(&mut img, 16.0, y0, 19.0, if has_copper { "Copper of this net, by how each piece was attributed" } else { "No copper is attributed to this net" }, SOFT, true);+    let mut x = 16.0;+    if !has_copper { x = text(&mut img, 16.0, y0 + 36.0, 20.0, "Only its pins are shown: the pin map names them,", WHITE, false).max(x); text(&mut img, 16.0, y0 + 62.0, 20.0, "and no copper seen carries the net's name.", WHITE, false); }+    for (k, r) in LADDER.iter().enumerate().take(if has_copper { 3 } else { 0 }) {+        let share = row["copperByRung"][*r]["share"].as_f64();+        swatch(&mut img, x, y0 + 34.0, 34.0, &RUNGS[k], u);+        let label = match *r { "copper" => "seen touching a pad", "via" => "through verified vias", _ => "inferred" };+        let x1 = text(&mut img, x + 44.0, y0 + 30.0, 21.0, label, WHITE, true);+        text(&mut img, x + 44.0, y0 + 56.0, 18.0, &share.map(|v| if v > 0.0 && v < 0.005 { "under 1% of its copper".to_string() } else { format!("{:.0}% of its copper", v * 100.0) }).unwrap_or_else(|| "none of its copper".into()), SOFT, false);+        x = x1.max(x + 44.0 + 150.0) + 34.0;+    }+    let mx = x.max(760.0) + 10.0;+    text(&mut img, mx, y0, 19.0, "Marks", SOFT, true);+    let mark = |img: &mut RgbImage, x: f64, seen: bool| { let q = [(x, y0 + 38.0), (x + 22.0, y0 + 38.0), (x + 22.0, y0 + 60.0), (x, y0 + 60.0)]; if seen { outline(img, &q, 1.6, INK, 0.95); fill_convex(img, &q, WHITE, 1.0); } else { outline(img, &q, 2.4, INK, 0.95); outline(img, &q, 1.15, WHITE, 1.0); } };+    mark(&mut img, mx, true); let x1 = text(&mut img, mx + 32.0, y0 + 36.0, 20.0, "pin seen joined", WHITE, false);+    mark(&mut img, x1 + 28.0, false); let x2 = text(&mut img, x1 + 60.0, y0 + 36.0, 20.0, "pin by inference only", WHITE, false);+    ring(&mut img, (x2 + 44.0, y0 + 49.0), 13.0, 3.2, [255, 235, 90], 1.0); let x3 = text(&mut img, x2 + 66.0, y0 + 36.0, 20.0, "weakest link", WHITE, false);+    let _ = x3;+    if row["disputes"].as_array().map(|a| !a.is_empty()).unwrap_or(false) { let m = (mx + 11.0, y0 + 79.0); for (a, b) in [((m.0 - 10.0, m.1 - 10.0), (m.0 + 10.0, m.1 + 10.0)), ((m.0 - 10.0, m.1 + 10.0), (m.0 + 10.0, m.1 - 10.0))] { capsule(&mut img, a, b, 1.9, RUNGS[5].color, 1.0); } text(&mut img, mx + 32.0, y0 + 66.0, 20.0, "a place where this net meets another, not settled", WHITE, false); }+    let foot = format!("{}   |   level {}: {}   |   the level is the weakest link joining the net's pins, not the share of copper seen", sc.name, level_name(level), level_words(level));+    text(&mut img, 16.0, ht as f64 - 36.0, 18.0, &fit(&foot, 18.0, wd as f64 - if zoomed { 260.0 } else { 32.0 }, false), [150, 156, 164], false);+    // where on the board a zoomed picture is+    if zoomed {+        let lv = View::fit(sides[0], whole(sc), (wd as f64 - 232.0, y0 - 6.0, 220.0, bottom - 14.0));+        base(&mut img, sc, &lv);+        copper(&mut img, sc, &lv, u, &RUNGS, &|net, rung| (Some(net) == ni).then_some(rung), 1.0, 0.9);+        let (a, b) = (lv.at((win.0, win.1)), lv.at((win.2, win.3)));+        frame(&mut img, a.0.max(lv.x), a.1.max(lv.y), b.0.min(lv.x + lv.w as f64), b.1.min(lv.y + lv.h as f64), 2.0, WHITE, 1.0);+        frame(&mut img, lv.x - 1.0, lv.y - 1.0, lv.x + lv.w as f64 + 1.0, lv.y + lv.h as f64 + 1.0, 1.0, [120, 126, 134], 1.0);+        let t = "where this is"; text(&mut img, lv.x - text_width(17.0, t, false) - 12.0, lv.y + lv.h as f64 / 2.0 - 10.0, 17.0, t, SOFT, false);+    }+    save_jpeg(&img, path, 90)+}++/// A sheet of up to eight nets, each in its own hue and labelled on the board.+fn section_sheet(sc: &Scene, rows: &HashMap<&str, &Value>, title: &str, sub: &str, nets: &[String], path: &Path) -> Result<(), String> {+    let u = 2.0f64; let (wd, ht) = (3840u32, 2160u32);+    let mut img = RgbImage::from_pixel(wd, ht, px(PAPER));+    let ids: Vec<Option<usize>> = nets.iter().map(|x| sc.net_ix.get(x).copied()).collect();+    let slot: HashMap<usize, usize> = ids.iter().enumerate().filter_map(|(k, i)| i.map(|i| (i, k))).collect();+    let styles: Vec<Style> = (0..nets.len()).map(|k| Style { color: NET_HUES[k % NET_HUES.len()], pattern: Pattern::Solid, dash: None }).collect();+    let all_pins: Vec<(usize, String, bool)> = nets.iter().enumerate().flat_map(|(k, x)| rows.get(x.as_str()).map(|r| pins_of(r)).unwrap_or_default().into_iter().map(move |(p, seen)| (k, p, seen))).collect();+    let shapes: Vec<(usize, &String, &Pin, bool)> = all_pins.iter().filter_map(|(k, p, seen)| sc.pins.get(p).map(|q| (*k, p, q, *seen))).collect();+    let sides: Vec<usize> = (0..2).filter(|k| sc.faces[*k].is_some()).collect();+    let key_w = 430.0 * u; let (top, bottom, gap) = (104.0 * u, 44.0 * u, 14.0 * u);+    let room_w = (wd as f64 - key_w - 24.0 * u - gap * (sides.len() as f64 - 1.0)) / sides.len() as f64; let room_h = ht as f64 - top - bottom;+    let set: BTreeSet<usize> = slot.keys().copied().collect();+    let (win, zoomed) = window(whole(sc), net_box(sc, &set, &shapes.iter().map(|x| x.2).collect::<Vec<_>>()), room_w / room_h);+    let views: Vec<View> = sides.iter().enumerate().map(|(i, k)| View::fit(*k, win, (12.0 * u + i as f64 * (room_w + gap), top, room_w, room_h))).collect();+    for v in &views {+        base(&mut img, sc, v);+        copper(&mut img, sc, v, u, &styles, &|net, _| slot.get(&net).copied(), 0.88, 1.4 * u);+        for (k, _, p, seen) in shapes.iter().filter(|x| x.2.sides[v.side]) { pin_mark(&mut img, v, p, *seen, styles[*k].color, u); }+        // each net's name where it has most copper on this face, else at one of its pins+        let mut lab = Labels::new(v); lab.keep((v.x, v.y, v.x + 440.0 * u, v.y + 30.0 * u));+        for (k, id) in ids.iter().enumerate() {+            let zone = sc.zones.iter().filter(|z| Some(z.net) == *id && z.side == v.side && v.holds(z.anchor)).max_by(|a, b| a.area.partial_cmp(&b.area).unwrap()).map(|z| z.anchor);+            let track = || sc.tracks.iter().filter(|t| Some(t.net) == *id && t.side == v.side).map(|t| t.pts[t.pts.len() / 2]).find(|p| v.holds(*p));+            let pin = || shapes.iter().filter(|x| x.0 == k && x.2.sides[v.side]).map(|x| (x.2.x, x.2.y)).find(|p| v.holds(*p));+            if let Some(at) = zone.or_else(track).or_else(pin) { lab.place(&mut img, v.at(at), 8.0 * u, &nets[k], 19.0 * u, styles[k].color, true); }+        }+        panel_frame(&mut img, v, u, zoomed);+    }+    text(&mut img, 16.0 * u, 8.0 * u, 44.0 * u, &fit(title, 44.0 * u, wd as f64 - 40.0 * u, true), WHITE, true);+    text(&mut img, 16.0 * u, 64.0 * u, 23.0 * u, &fit(sub, 23.0 * u, wd as f64 - 40.0 * u, false), SOFT, false);+    // the key: hue, name, level, pins+    let kx = wd as f64 - key_w + 6.0 * u; let row_h = ((room_h - 40.0 * u) / PER_SHEET as f64).min(112.0 * u);+    text(&mut img, kx, top, 21.0 * u, "Nets on this sheet", SOFT, true);+    for (k, name) in nets.iter().enumerate() {+        let y = top + 40.0 * u + k as f64 * row_h;+        fill_rect(&mut img, kx, y + 4.0 * u, kx + 40.0 * u, y + 44.0 * u, styles[k].color, 1.0);+        frame(&mut img, kx, y + 4.0 * u, kx + 40.0 * u, y + 44.0 * u, 1.5 * u, WHITE, 0.6);+        text(&mut img, kx + 52.0 * u, y, 29.0 * u, &fit(name, 29.0 * u, key_w - 70.0 * u, true), WHITE, true);+        if let Some(r) = rows.get(name.as_str()) {+            let x = badge(&mut img, kx + 52.0 * u, y + 42.0 * u, s(r, "level"), 19.0 * u, u);+            text(&mut img, x + 12.0 * u, y + 44.0 * u, 19.0 * u, &format!("{} of {} pins seen", n(r, "pinsSeen"), n(r, "pins")), SOFT, false);+        }+    }+    let foot = format!("{}   |   filled mark: pin seen joined to the net's copper; hollow mark: pin on the net by inference only   |   the level is the weakest link joining a net's pins", sc.name);+    text(&mut img, 16.0 * u, ht as f64 - 34.0 * u, 17.0 * u, &fit(&foot, 17.0 * u, wd as f64 - 40.0 * u, false), [150, 156, 164], false);+    save_jpeg(&img, path, 90)+}++#[derive(Clone, Copy, PartialEq)]+enum Mode { ByNet, ByPiece }++/// Both faces (or one, at full size) with every piece of copper in the colour and pattern of its level, and the legend.+fn confidence_sheet(sc: &Scene, conf: &Value, mode: Mode, one_face: Option<usize>, path: &Path) -> Result<(), String> {+    let sides: Vec<usize> = match one_face { Some(k) => vec![k], None => (0..2).filter(|k| sc.faces[*k].is_some()).collect() };+    // the level each net is drawn at: its own for a named net, its class for copper with no name+    let mut net_rank: Vec<usize> = vec![4; sc.net_ix.len()];+    let levels: HashMap<&str, &str> = conf["nets"].as_array().into_iter().flatten().map(|r| (s(r, "net"), s(r, "level"))).collect();+    for (name, k) in &sc.net_ix { net_rank[*k] = match netconf::unnamed_class(name) { Some(c) => rank(c), None => rank(levels.get(name.as_str()).copied().unwrap_or("none")) }; }+    let pick = |net: usize, rung: usize| -> Option<usize> { Some(if mode == Mode::ByNet { rung.max(net_rank[net]) } else { rung }) };+    let (u, wd, ht, legend_h, views): (f64, u32, u32, f64, Vec<View>) = match one_face {+        None => { let u = 2.0; let (wd, ht) = (3840u32, 2160u32); let (top, lh, gap) = (108.0 * u, 238.0 * u, 16.0 * u);+            let room_w = (wd as f64 - 24.0 * u - gap * (sides.len() as f64 - 1.0)) / sides.len() as f64;+            (u, wd, ht, lh, sides.iter().enumerate().map(|(i, k)| View::fit(*k, whole(sc), (12.0 * u + i as f64 * (room_w + gap), top, room_w, ht as f64 - top - lh))).collect()) }+        Some(k) => { let (pw, ph) = ((sc.wmm * RES).round(), (sc.hmm * RES).round()); let u = (pw / 1500.0).max(1.2); let (top, lh, m) = (108.0 * u, 238.0 * u, 12.0 * u);+            (u, (pw + 2.0 * m) as u32, (ph + top + lh) as u32, lh, vec![View { side: k, win: whole(sc), x: m, y: top, w: pw as usize, h: ph as usize }]) }+    };+    let mut img = RgbImage::from_pixel(wd, ht, px(PAPER));+    for v in &views {+        base(&mut img, sc, v);+        copper(&mut img, sc, v, u, &RUNGS, &pick, 0.86, 1.25 * u);+        part_outlines(&mut img, sc, v, u, None, 0.5);+        // reference designators where the part is large enough to carry one+        let mut lab = Labels::new(v); lab.keep((v.x, v.y, v.x + 440.0 * u, v.y + 30.0 * u));+        for p in sc.parts.iter().filter(|p| p.side == v.side) {+            let q: Vec<(f64, f64)> = p.corners.iter().map(|c| v.at(*c)).collect();+            let (x0, x1, y0, y1) = (q.iter().map(|a| a.0).fold(f64::MAX, f64::min), q.iter().map(|a| a.0).fold(f64::MIN, f64::max), q.iter().map(|a| a.1).fold(f64::MAX, f64::min), q.iter().map(|a| a.1).fold(f64::MIN, f64::max));+            let size = 15.0 * u; let (tw, th) = (text_width(size, &p.name, true), font(true).line_height(size));+            if tw + 4.0 * u <= x1 - x0 && th <= y1 - y0 { let b = ((x0 + x1 - tw) / 2.0, (y0 + y1 - th) / 2.0); text_halo(&mut img, b.0, b.1, size, &p.name, WHITE, true); lab.keep((b.0, b.1, b.0 + tw, b.1 + th)); }+        }+        panel_frame(&mut img, v, u, false);+    }+    let sm = &conf["summary"];+    let (title, sub) = match mode {+        Mode::ByNet => ("Net confidence: each net is as proven as the weakest link joining its pins".to_string(),+            format!("{}   |   {} nets: {} copper, {} via, {} inferred   |   copper with no net: {} groups, {} isolated islands, {} in conflict", sc.name, n(sm, "nets"), n(&sm["levels"], "copper"), n(&sm["levels"], "via"), n(&sm["levels"], "inferred"), n(&sm["unnamed"]["group"], "names"), n(sm, "unnamedIslands"), n(&sm["unnamed"]["conflict"], "names"))),+        Mode::ByPiece => ("Attribution: how each piece of copper got its net".to_string(), format!("{}   |   the evidence under the net confidence picture: a net can be inferred as a whole while most of its copper was seen", sc.name)),+    };+    text(&mut img, 16.0 * u, 8.0 * u, 42.0 * u, &fit(&title, 42.0 * u, wd as f64 - 32.0 * u, true), WHITE, true);+    text(&mut img, 16.0 * u, 62.0 * u, 23.0 * u, &fit(&sub, 23.0 * u, wd as f64 - 32.0 * u, false), SOFT, false);+    // the legend: six levels, three to a row, each with what it means, how many and how much of the copper+    let which = if mode == Mode::ByNet { "copperAsDrawn" } else { "copperByOwnRung" };+    let total_area: f64 = LADDER.iter().map(|r| sm[which][*r]["areaMm2"].as_f64().unwrap_or(0.0)).sum();+    let pct = |r: &str| if total_area > 0.0 { format!("{:.0}% of copper area", 100.0 * sm[which][r]["areaMm2"].as_f64().unwrap_or(0.0) / total_area) } else { "no copper".to_string() };+    let entry = |r: &str| -> (&'static str, String) {+        match (mode, r) {+            (Mode::ByNet, "copper") => ("Copper", format!("every pin seen joined by continuous copper. {} nets ({} of one pin), {}", n(&sm["levels"], "copper"), n(&sm["netsOfOnePin"], "copper"), pct(r))),+            (Mode::ByNet, "via") => ("Via", format!("pins joined, the weakest join through verified vias. {} nets, {}", n(&sm["levels"], "via"), pct(r))),+            (Mode::ByNet, "inferred") => ("Inferred", format!("at least one pin or join rests on inference, however much copper was seen. {} nets, {}", n(&sm["levels"], "inferred"), pct(r))),+            (Mode::ByNet, "group") => ("Group, no name", format!("unnamed copper joined to other unnamed copper. {} groups, {}", n(&sm["unnamed"]["group"], "names"), pct(r))),+            (Mode::ByNet, "none") => ("None", format!("isolated copper: no pad, no valid via. {} unnamed islands, {}", n(sm, "unnamedIslands"), pct(r))),+            (Mode::ByNet, _) => ("Conflict", format!("two nets meet and nothing settles it; no name is forced. {} places, {}", n(&sm["unnamed"]["conflict"], "names"), pct(r))),+            (_, "copper") => ("Copper", format!("touches a pad of its net with no gap. {}", pct(r))),+            (_, "via") => ("Via", format!("reached through verified vias. {}", pct(r))),+            (_, "inferred") => ("Inferred", format!("one via track to track, or a pad whose edge does not settle it. {}", pct(r))),+            (_, "group") => ("Group, no name", format!("joined to other unnamed copper. {}", pct(r))),+            (_, "none") => ("None", format!("no pad and no valid via. {}", pct(r))),+            _ => ("Conflict", format!("two nets meet here. {}", pct(r))),+        }+    };+    let ly = ht as f64 - legend_h + 18.0 * u; let col = (wd as f64 - 32.0 * u) / 3.0;+    for (k, r) in LADDER.iter().enumerate() {+        let (x, y) = (16.0 * u + (k % 3) as f64 * col, ly + (k / 3) as f64 * 106.0 * u);+        swatch(&mut img, x, y, 62.0 * u, &RUNGS[k], u);+        let (head, words) = entry(r);+        text(&mut img, x + 76.0 * u, y - 4.0 * u, 26.0 * u, head, RUNGS[k].color, true);+        for (i, line) in wrap(&words, 19.0 * u, col - 100.0 * u, false).iter().take(3).enumerate() { text(&mut img, x + 76.0 * u, y + (28.0 + 23.0 * i as f64) * u, 19.0 * u, line, [226, 229, 234], false); }+    }+    save_jpeg(&img, path, 91)+}++fn weakest_short(row: &Value) -> String {+    let w = &row["weakestLink"]; let cnt = n(w, "sameKindOnPath");+    let pins: Vec<&str> = w["pins"].as_array().into_iter().flatten().filter_map(|x| x.as_str()).collect();+    match s(w, "kind") {+        "pad-on-copper" => "all joins seen as continuous copper".to_string(),+        "verified-vias" => "verified vias between two pieces".to_string(),+        "single-via-track-to-track" => "one via, track to track".to_string(),+        "single-via-onto-pour" => "one unconfirmed via and the pin map".to_string(),+        "pad-edge-unsettled" => format!("pad edge of {} does not settle it{}", pins.first().unwrap_or(&""), if cnt > 1 { format!(" (+{})", cnt - 1) } else { String::new() }),+        "pin-map-only" => format!("{} by the pin map only{}", pins.first().unwrap_or(&""), if cnt > 1 { format!(" (+{} more)", cnt - 1) } else { String::new() }),+        _ => "no pins".to_string(),+    }+}++/// The table of every net with its level, as pictures. Returns how many pages.+fn table_sheets(sc: &Scene, conf: &Value, dir: &Path) -> Result<usize, String> {+    let mut rows: Vec<&Value> = conf["nets"].as_array().into_iter().flatten().collect();+    rows.sort_by(|a, b| rank(s(a, "level")).cmp(&rank(s(b, "level"))).then(n(b, "pins").cmp(&n(a, "pins"))).then_with(|| nat_cmp(s(a, "net"), s(b, "net"))));+    let (wd, ht) = (1920u32, 1080u32); let (per_col, cols) = (27usize, 2usize);+    let pages = rows.len().div_ceil(per_col * cols).max(1);+    for page in 0..pages {+        let mut img = RgbImage::from_pixel(wd, ht, px(PAPER));+        text(&mut img, 16.0, 8.0, 40.0, "Every net and how well it is proven", WHITE, true);+        let sm = &conf["summary"];+        let sub = format!("{}   |   {} nets: {} copper, {} via, {} inferred   |   level = the weakest link joining the net's pins   |   page {} of {pages}", sc.name, n(sm, "nets"), n(&sm["levels"], "copper"), n(&sm["levels"], "via"), n(&sm["levels"], "inferred"), page + 1);+        text(&mut img, 16.0, 58.0, 21.0, &fit(&sub, 21.0, wd as f64 - 32.0, false), SOFT, false);+        let col_w = (wd as f64 - 32.0 - 24.0) / cols as f64;+        for c in 0..cols {+            let x = 16.0 + c as f64 * (col_w + 24.0); let y = 104.0;+            fill_rect(&mut img, x, y, x + col_w, y + 32.0, [44, 48, 54], 1.0);+            for (dx, h) in [(8.0, "Net"), (268.0, "Level"), (420.0, "Pins seen"), (540.0, "Weakest link")] { text(&mut img, x + dx, y + 4.0, 19.0, h, SOFT, true); }+            for (i, r) in rows.iter().skip(page * per_col * cols + c * per_col).take(per_col).enumerate() {+                let ry = y + 38.0 + i as f64 * 34.0;+                if i % 2 == 1 { fill_rect(&mut img, x, ry - 2.0, x + col_w, ry + 32.0, [26, 30, 36], 1.0); }+                let lv = s(r, "level");+                text(&mut img, x + 8.0, ry + 1.0, 22.0, &fit(s(r, "net"), 22.0, 250.0, true), WHITE, true);+                swatch(&mut img, x + 268.0, ry + 3.0, 24.0, &RUNGS[rank(lv)], 1.0);+                text(&mut img, x + 300.0, ry + 2.0, 21.0, level_name(lv), RUNGS[rank(lv)].color, true);+                text(&mut img, x + 420.0, ry + 3.0, 20.0, &format!("{} of {}", n(r, "pinsSeen"), n(r, "pins")), WHITE, false);+                text(&mut img, x + 540.0, ry + 4.0, 18.0, &fit(&weakest_short(r), 18.0, col_w - 548.0, false), SOFT, false);+            }+        }+        save_jpeg(&img, &dir.join(format!("confidence-table-{}.jpg", page + 1)), 92)?;+    }+    Ok(pages)+}++fn slug(t: &str) -> String {+    let mut out = String::new();+    for ch in t.chars() { if ch.is_ascii_alphanumeric() { out.push(ch.to_ascii_lowercase()); } else if ch == '+' { out.push_str("plus"); } else if !out.ends_with('-') && !out.is_empty() { out.push('-'); } }+    out.trim_end_matches('-').to_string()+}++/// Run `f` over the jobs on at most four threads, and return the first error.+fn in_parallel<T: Sync>(jobs: &[T], f: &(dyn Fn(&T) -> Result<(), String> + Sync)) -> Result<(), String> {+    let next = std::sync::atomic::AtomicUsize::new(0);+    let errs: Vec<String> = std::thread::scope(|sc| {+        let hs: Vec<_> = (0..4.min(jobs.len().max(1))).map(|_| sc.spawn(|| { let mut e = vec![]; loop { let k = next.fetch_add(1, std::sync::atomic::Ordering::SeqCst); if k >= jobs.len() { break; } if let Err(x) = f(&jobs[k]) { e.push(x); } } e })).collect();+        hs.into_iter().flat_map(|h| h.join().unwrap_or_else(|_| vec!["a drawing thread stopped".to_string()])).collect()+    });+    match errs.into_iter().next() { Some(e) => Err(e), None => Ok(()) }+}++fn clear_pictures(dir: &Path) { if let Ok(rd) = std::fs::read_dir(dir) { for e in rd.flatten() { let p = e.path(); if p.extension().map(|x| x == "jpg").unwrap_or(false) { std::fs::remove_file(p).ok(); } } } }++/// Grade the nets and draw everything. `only`: draw just this net's picture (the confidence file is still written).+pub fn run(flow: &Path, only: Option<&str>) -> Result<Value, String> {+    let conf = netconf::compute(flow)?;+    let sc = load(flow)?;+    let dir = flow.join("nets");+    std::fs::create_dir_all(dir.join("net")).map_err(|e| e.to_string())?; std::fs::create_dir_all(dir.join("sections")).map_err(|e| e.to_string())?;+    let rows: Vec<&Value> = conf["nets"].as_array().into_iter().flatten().collect();+    if let Some(name) = only {+        let r = rows.iter().find(|r| s(r, "net") == name).ok_or(format!("no net named {name} in nets.json"))?;+        let p = dir.join(format!("net/{}.jpg", s(r, "file")));+        net_picture(&sc, r, &p)?;+        return Ok(json!({"only": name, "picture": p.display().to_string(), "level": r["level"]}));+    }+    clear_pictures(&dir.join("net")); clear_pictures(&dir.join("sections")); clear_pictures(&dir);+    let by_name: HashMap<&str, &Value> = rows.iter().map(|r| (s(r, "net"), *r)).collect();+    let nets_doc = crate::items::read_json(&flow.join("nets.json"))?;+    let named: Vec<(String, Vec<String>)> = nets_doc["nets"].as_array().into_iter().flatten().map(|x| (s(x, "name").to_string(), x["pins"].as_array().into_iter().flatten().filter_map(|p| p.as_str().map(str::to_string)).collect())).collect();+    let blocks = crate::items::read_json(&flow.join("blocks.json")).unwrap_or(json!({}));+    let sections: Vec<Section> = netsections::assign(&named, &blocks);+    // the overview pictures+    confidence_sheet(&sc, &conf, Mode::ByNet, None, &dir.join("confidence.jpg"))?;+    confidence_sheet(&sc, &conf, Mode::ByPiece, None, &dir.join("attribution.jpg"))?;+    for k in 0..2 { if sc.faces[k].is_some() { confidence_sheet(&sc, &conf, Mode::ByNet, Some(k), &dir.join(format!("confidence-{}.jpg", SIDES[k])))?; } }+    let pages = table_sheets(&sc, &conf, &dir)?;+    // one picture per net+    in_parallel(&rows, &|r: &&Value| net_picture(&sc, r, &dir.join(format!("net/{}.jpg", s(r, "file")))))?;+    // the section sheets+    struct Sheet { file: String, title: String, sub: String, nets: Vec<String> }+    let mut sheets: Vec<Sheet> = vec![]; let mut sec_rows = vec![];+    for (i, sec) in sections.iter().enumerate() {+        let chunks: Vec<&[String]> = sec.nets.chunks(PER_SHEET).collect(); let mut files = vec![];+        let mut tally: BTreeMap<&str, usize> = BTreeMap::new(); for x in &sec.nets { *tally.entry(by_name.get(x.as_str()).map(|r| s(r, "level")).unwrap_or("none")).or_default() += 1; }+        let lv: Vec<String> = LADDER.iter().filter_map(|l| tally.get(l).map(|c| format!("{c} {l}"))).collect();+        for (j, c) in chunks.iter().enumerate() {+            let file = if chunks.len() > 1 { format!("{:02}-{}-{}.jpg", i + 1, slug(&sec.title), j + 1) } else { format!("{:02}-{}.jpg", i + 1, slug(&sec.title)) };+            let title = if chunks.len() > 1 { format!("{} ({} of {})", sec.title, j + 1, chunks.len()) } else { sec.title.clone() };+            let sub = format!("{}   |   section {} of {}: {} net{}, {}   |   grouped because: {}", sc.name, i + 1, sections.len(), sec.nets.len(), if sec.nets.len() == 1 { "" } else { "s" }, lv.join(", "), sec.why);+            files.push(file.clone()); sheets.push(Sheet { file, title, sub, nets: c.to_vec() });+        }+        sec_rows.push(json!({"n": i + 1, "kind": sec.kind, "title": sec.title, "why": sec.why, "nets": sec.nets, "levels": tally, "sheets": files.iter().map(|f| format!("sections/{f}")).collect::<Vec<_>>()}));+    }+    in_parallel(&sheets, &|sh: &Sheet| section_sheet(&sc, &by_name, &sh.title, &sh.sub, &sh.nets, &dir.join("sections").join(&sh.file)))?;+    let doc = json!({"sections": sec_rows, "pictures": rows.iter().map(|r| (s(r, "net").to_string(), json!(format!("net/{}.jpg", s(r, "file"))))).collect::<serde_json::Map<_, _>>()});+    std::fs::write(dir.join("sections.json"), serde_json::to_string_pretty(&doc).unwrap() + "\n").map_err(|e| e.to_string())?;+    std::fs::write(dir.join("index.md"), index(&sc, &conf, &sec_rows, &by_name, pages)).map_err(|e| e.to_string())?;+    crate::netview::write(&sc, &conf, &sec_rows, &dir.join("view.html"))?;+    Ok(json!({"copperNotRead": conf["summary"]["copperNotRead"], "nets": rows.len(), "levels": conf["summary"]["levels"], "netsOfTwoOrMorePins": conf["summary"]["netsOfTwoOrMorePins"], "netsOfOnePin": conf["summary"]["netsOfOnePin"], "unnamedIslands": conf["summary"]["unnamedIslands"],+        "sections": sections.len(), "sheets": sheets.len(), "tablePages": pages, "netPictures": rows.len()}))+}++/// What was drawn, in one line.+pub fn one_line(s: &Value) -> String {+    if let Some(name) = s["only"].as_str() { return format!("net {name} drawn at level {}: {}", s["level"].as_str().unwrap_or(""), s["picture"].as_str().unwrap_or("")); }+    let l = &s["levels"];+    format!("nets drawn under reverse-flow/nets/: {} nets, {} copper, {} via, {} inferred by their weakest link; {} section sheets, {} net pictures", s["nets"], l["copper"], l["via"], l["inferred"], s["sheets"], s["netPictures"])+}+pub fn hints(s: &Value) -> Vec<String> {+    if s["only"].is_string() { return vec![]; }+    let mut v: Vec<String> = s["copperNotRead"].as_array().into_iter().flatten().filter_map(|x| x.as_str()).map(|side| format!("The {side} face has no copper in these pictures and figures (not photographed, or read and failed `check copper`): a picture of that face says so, and no net is claimed through it.")).collect();+    v.extend(["Open nets/index.md, then look at nets/confidence.jpg against the photos: a net is as proven as the weakest link joining its pins.".to_string(),+        format!("Of the nets with two or more pins: {} copper, {} via, {} inferred. Of those with one pin: {} copper, {} inferred. {} isolated islands of copper carry no net.", s["netsOfTwoOrMorePins"]["copper"], s["netsOfTwoOrMorePins"]["via"], s["netsOfTwoOrMorePins"]["inferred"], s["netsOfOnePin"]["copper"], s["netsOfOnePin"]["inferred"], s["unnamedIslands"]),+        "net-confidence.json gives each net's weakest link and where it is: that is the place to probe first. These levels are per net, not the run's two scores: a join no photo shows between groups of pins is a hidden connection (unrouted.json), counts in the bench score, and has a continuity probe in `bench plan`.".to_string()]);+    v+}++fn index(sc: &Scene, conf: &Value, sections: &[Value], by_name: &HashMap<&str, &Value>, pages: usize) -> String {+    let sm = &conf["summary"]; let c = |v: &Value, k: &str| n(v, k);+    let mut md = format!("# Nets of {}\n\nWritten by `adom-aiflow-reverse nets draw` from the run's files. Do not edit by hand: run it again.\n\n", sc.name);+    for k in 0..2 { if let Some(words) = &sc.not_read[k] { md += &format!("**{words}.** No copper of that face is in any picture or area figure here, a picture of that face carries a band saying so, and no net is claimed through it.\n\n"); } }+    md += "A net's level is the rung of the **weakest link** in the set of links that joins its pins, strongest links first. It is not the share of its copper that was seen: one inferred join makes the net inferred. `../net-confidence.json` holds each net's level, weakest link and place, pins seen and copper by rung; docs/method.md gives the definition.\n\n";+    md += "These levels are per net and are not the run's scores: `confidence` prints the photo score (what photos can show) and the bench score (what needs the real board or outside documents). An inferred link between groups of pins is a hidden connection: it is left unrouted and listed in `../unrouted.json`, it counts in the bench score, and `bench plan` writes a continuity probe for it.\n\n";+    md += "## Confidence\n\n| Level | Nets | Of two or more pins | Of one pin | What it means |\n|---|---|---|---|---|\n";+    for l in ["copper", "via", "inferred", "none"] { md += &format!("| {l} | {} | {} | {} | {} |\n", c(&sm["levels"], l), c(&sm["netsOfTwoOrMorePins"], l), c(&sm["netsOfOnePin"], l), match l { "none" => "no pins", _ => level_words(l) }); }+    md += &format!("\n{} nets in all, {} of them also meet another net somewhere (listed as disputes, drawn with a cross). A net of one pin has nothing to join, so it is graded by how that pin meets its copper.\n\n", c(sm, "nets"), c(sm, "netsDisputed"));+    let dw = &sm["datasheetWitness"];+    if dw["chips"].as_u64().unwrap_or(0) > 0 { md += &format!("**Datasheet witness.** {} pin(s) of {} identified chip(s) were held against the copper: {} agree, {} disagree, {} not visible. {} of {} nets have a witness ({} agree, {} disagree); the column below says which. It is a second opinion beside the level and never changes it. Findings with crops: `../evidence/witness/findings.md`.\n\n", n(dw, "pins"), n(dw, "chips"), n(dw, "agree"), n(dw, "disagree"), n(dw, "notVisible"), n(dw, "netsWithWitness"), n(dw, "nets"), n(dw, "netsAgree"), n(dw, "netsDisagree")); }+    md += &format!("Copper that carries no net is not given a picture each. It is three categories in the confidence picture: **{} isolated islands** with no pad and no valid via (`none`, {} mm2), **{} groups** joined by vias that no pad names (`group`, {} mm2), and **{} pieces in conflict** where two nets meet (`conflict`).\n\n",+        c(sm, "unnamedIslands"), sm["unnamed"]["none"]["areaMm2"], c(&sm["unnamed"]["group"], "names"), sm["unnamed"]["group"]["areaMm2"], c(&sm["unnamed"]["conflict"], "names"));+    md += "| Picture | What it shows |\n|---|---|\n| [confidence.jpg](confidence.jpg) | both faces, every piece of copper by how well its net is proven, with the legend |\n";+    for k in 0..2 { if sc.faces[k].is_some() { md += &format!("| [confidence-{0}.jpg](confidence-{0}.jpg) | the {0} face alone at 40 px/mm, for looking closely |\n", SIDES[k]); } }+    md += "| [attribution.jpg](attribution.jpg) | the evidence under it: each piece of copper by its own rung |\n";+    for p in 1..=pages { md += &format!("| [confidence-table-{p}.jpg](confidence-table-{p}.jpg) | every net with its level, page {p} of {pages} |\n"); }+    md += "| [view.html](view.html) | the same in a browser: a net list beside the board, a click lights a net on both faces, with a switch between by confidence and by net |\n";+    md += "\nColours and patterns: copper is solid sky blue, via is green in level bands, inferred is orange in diagonal stripes, group is purple in a grid, none is grey dots, conflict is vermillion with dark stripes. Tracks are too thin for a pattern, so inferred, group and conflict tracks are dashed. The bottom face is shown turned over, so it lines up with the top.\n\n";+    md += "## Sections\n\n| # | Section | Nets | Levels | Sheets |\n|---|---|---|---|---|\n";+    for sec in sections {+        let lv: Vec<String> = LADDER.iter().filter_map(|l| sec["levels"][*l].as_u64().map(|k| format!("{k} {l}"))).collect();+        let sheets: Vec<String> = sec["sheets"].as_array().into_iter().flatten().filter_map(|f| f.as_str()).enumerate().map(|(i, f)| format!("[{}]({f})", i + 1)).collect();+        md += &format!("| {} | [{}](#section-{}) | {} | {} | {} |\n", sec["n"], s(sec, "title"), sec["n"], sec["nets"].as_array().map(|a| a.len()).unwrap_or(0), lv.join(", "), sheets.join(" "));+    }+    for sec in sections {+        md += &format!("\n<a id=\"section-{}\"></a>\n\n### {} {}\n\nGrouped because: {}. Sheets: {}\n\n| Net | Level | Known from | Pins seen | Weakest link | Datasheet | Picture |\n|---|---|---|---|---|---|---|\n", sec["n"], sec["n"], s(sec, "title"), s(sec, "why"),+            sec["sheets"].as_array().into_iter().flatten().filter_map(|f| f.as_str()).map(|f| format!("[{}]({f})", f.trim_start_matches("sections/"))).collect::<Vec<_>>().join(", "));+        for name in sec["nets"].as_array().into_iter().flatten().filter_map(|x| x.as_str()) {+            let Some(r) = by_name.get(name) else { continue };+            let dw = &r["datasheetWitness"];+            let witness = match s(dw, "witness") { "agree" => format!("agrees ({} pin(s))", n(dw, "agree")), "disagree" => format!("**disagrees**: {}", dw["findings"].as_array().into_iter().flatten().filter_map(|f| f["finding"].as_str()).collect::<Vec<_>>().join("; ").replace('|', "/")), _ if s(dw, "netList") == "disagree" => format!("none from copper; the net list **disagrees**: {}", dw["findings"].as_array().into_iter().flatten().filter_map(|f| f["finding"].as_str()).collect::<Vec<_>>().join("; ").replace('|', "/")), _ if s(dw, "netList") == "agree" => "none from copper; the net list follows the datasheet".to_string(), _ => "none".to_string() };+            let kf = &r["knownFrom"];+            let known = match s(kf, "from") { "pinout only" => format!("**pinout only**, {} ({})", s(kf, "tag"), kf["rules"].as_array().into_iter().flatten().filter_map(|x| x.as_str()).collect::<Vec<_>>().join(", ")), "" => String::new(), other => if kf["tag"].is_string() { format!("{other} ({})", s(kf, "tag")) } else { other.to_string() } };+            md += &format!("| {name} | {} | {known} | {} of {} | {} | {witness} | [{}.jpg](net/{}.jpg) |\n", s(r, "level"), n(r, "pinsSeen"), n(r, "pins"), s(&r["weakestLink"], "what").replace('|', "/"), s(r, "file"), s(r, "file"));+        }+    }+    md+}++#[cfg(test)]+mod tests {+    use super::*;++    #[test]+    fn a_small_net_is_zoomed_and_a_wide_one_shows_the_whole_board() {+        let board = (100.0, 100.0, 173.65, 161.78);+        let (w, zoomed) = window(board, Some((120.0, 120.0, 124.0, 121.0)), 1.15);+        assert!(zoomed); assert!((w.2 - w.0) >= 12.0 && (w.2 - w.0) < 20.0, "{w:?}");+        assert!(((w.2 - w.0) / (w.3 - w.1) - 1.15).abs() < 1e-6, "the window has the panel's shape");+        assert!(w.0 <= 120.0 && w.2 >= 124.0 && w.1 <= 120.0 && w.3 >= 121.0, "the net is inside it");+        // near a corner the window stays on the board+        let (w, _) = window(board, Some((100.5, 100.5, 103.0, 102.0)), 1.15);+        assert!(w.0 >= 100.0 - 1e-9 && w.1 >= 100.0 - 1e-9, "{w:?}");+        assert_eq!(window(board, Some((101.0, 101.0, 170.0, 160.0)), 1.15), (board, false));+        assert_eq!(window(board, None, 1.15), (board, false));+    }++    #[test]+    fn a_view_puts_board_points_on_the_sheet() {+        let v = View::fit(0, (100.0, 100.0, 150.0, 140.0), (10.0, 20.0, 1000.0, 400.0));+        assert_eq!((v.w, v.h), (500, 400)); assert_eq!(v.scale(), 10.0);+        assert_eq!(v.at((100.0, 100.0)), (260.0, 20.0)); assert_eq!(v.at((150.0, 140.0)), (760.0, 420.0));+        assert!(v.holds((125.0, 120.0)) && !v.holds((151.0, 120.0)));+    }++    #[test]+    fn a_line_is_cut_at_the_panel() {+        let r = (0.0, 0.0, 10.0, 10.0);+        assert_eq!(clip((-5.0, 5.0), (5.0, 5.0), r), Some(((0.0, 5.0), (5.0, 5.0))));+        assert_eq!(clip((2.0, 2.0), (8.0, 8.0), r), Some(((2.0, 2.0), (8.0, 8.0))));+        assert_eq!(clip((-5.0, -1.0), (15.0, -1.0), r), None);+    }++    #[test]+    fn section_files_have_plain_names() {+        assert_eq!(slug("Supply rail +3V3"), "supply-rail-plus3v3"); assert_eq!(slug("Other nets of the block: Gate driver"), "other-nets-of-the-block-gate-driver"); assert_eq!(slug("USB3-0 nets"), "usb3-0-nets");+    }+}
src/netpaint.rsadded+304
@@ -0,0 +1,304 @@+//! What the `nets` pictures are painted with: text from the program's own font atlas, smooth-edged lines, discs and+//! rings, and a coverage layer that fills copper in a colour and a pattern.+//!+//! Every confidence level has a hue and a pattern, so the levels can be told apart without colour: the hues are the+//! Okabe-Ito set, picked to stay apart for colour-blind viewers, and the patterns are coarse enough (a period of ten+//! pixels at 1080p) to survive video compression. Tracks are too thin to carry a pattern, so the weaker levels are+//! drawn dashed.+use image::{Rgb, RgbImage};+use std::collections::HashMap;+use std::sync::OnceLock;++pub type C3 = [u8; 3];+pub const WHITE: C3 = [255, 255, 255];+pub const INK: C3 = [12, 14, 18];+pub const PAPER: C3 = [18, 21, 26];+pub const SOFT: C3 = [196, 202, 210];++// ---------------------------------------------------------------- text++struct Glyph { x: f64, w: f64, left: f64, adv: f64 }+pub struct Font { w: usize, h: usize, sat: Vec<f64>, size: f64, glyphs: HashMap<char, Glyph> }++impl Font {+    fn load(png: &[u8], table: &str) -> Font {+        let a = image::load_from_memory(png).expect("label font atlas").to_luma8();+        let (w, h) = (a.width() as usize, a.height() as usize);+        let mut sat = vec![0f64; (w + 1) * (h + 1)];+        for y in 0..h { let mut row = 0f64; for x in 0..w { row += a.get_pixel(x as u32, y as u32)[0] as f64; sat[(y + 1) * (w + 1) + x + 1] = sat[y * (w + 1) + x + 1] + row; } }+        let t: serde_json::Value = serde_json::from_str(table).expect("label font table");+        let glyphs = t["glyphs"].as_object().unwrap().iter().filter_map(|(k, g)| Some((k.chars().next()?, Glyph { x: g["x"].as_f64()?, w: g["w"].as_f64()?, left: g["left"].as_f64()?, adv: g["adv"].as_f64()? }))).collect();+        Font { w, h, sat, size: t["size"].as_f64().unwrap_or(56.0), glyphs }+    }+    /// The atlas summed from its corner to a point between pixels: exact for any box, so text scales to any size cleanly.+    fn sum_to(&self, x: f64, y: f64) -> f64 {+        let (x, y) = (x.clamp(0.0, self.w as f64), y.clamp(0.0, self.h as f64));+        let (x0, y0) = ((x.floor() as usize).min(self.w - 1), (y.floor() as usize).min(self.h - 1));+        let (fx, fy) = (x - x0 as f64, y - y0 as f64);+        let s = |i: usize, j: usize| self.sat[j * (self.w + 1) + i];+        s(x0, y0) * (1.0 - fx) * (1.0 - fy) + s(x0 + 1, y0) * fx * (1.0 - fy) + s(x0, y0 + 1) * (1.0 - fx) * fy + s(x0 + 1, y0 + 1) * fx * fy+    }+    fn glyph(&self, ch: char) -> &Glyph { self.glyphs.get(&ch).or(self.glyphs.get(&'?')).expect("the atlas has a question mark") }+    pub fn width(&self, px: f64, s: &str) -> f64 { s.chars().map(|c| self.glyph(c).adv).sum::<f64>() * px / self.size }+    /// Draw `s` with the top of its line at `y`, `px` being the size of the type. Returns where the next letter would start.+    pub fn draw(&self, img: &mut RgbImage, x: f64, y: f64, px: f64, s: &str, c: C3, alpha: f32) -> f64 {+        let k = px / self.size; let mut pen = x;+        for ch in s.chars() {+            let g = self.glyph(ch);+            let (dx0, dw, dh) = (pen + g.left * k, g.w * k, self.h as f64 * k);+            for iy in y.floor() as i64..(y + dh).ceil() as i64 { for ix in dx0.floor() as i64..(dx0 + dw).ceil() as i64 {+                let (sx0, sy0) = (g.x + (ix as f64 - dx0) / k, (iy as f64 - y) / k);+                let (ax, bx) = (sx0.max(g.x), (sx0 + 1.0 / k).min(g.x + g.w)); let (ay, by) = (sy0.max(0.0), (sy0 + 1.0 / k).min(self.h as f64));+                if bx <= ax || by <= ay { continue; }+                let v = (self.sum_to(bx, by) - self.sum_to(ax, by) - self.sum_to(bx, ay) + self.sum_to(ax, ay)) * k * k / 255.0;+                if v > 0.004 { blend(img, ix, iy, c, alpha * v.min(1.0) as f32); }+            } }+            pen += g.adv * k;+        }+        pen+    }+    pub fn line_height(&self, px: f64) -> f64 { self.h as f64 * px / self.size }+}++pub fn font(bold: bool) -> &'static Font {+    static REGULAR: OnceLock<Font> = OnceLock::new(); static BOLD: OnceLock<Font> = OnceLock::new();+    if bold { BOLD.get_or_init(|| Font::load(include_bytes!("../assets/label-font-bold.png"), include_str!("../assets/label-font-bold.json"))) }+    else { REGULAR.get_or_init(|| Font::load(include_bytes!("../assets/label-font-regular.png"), include_str!("../assets/label-font-regular.json"))) }+}++pub fn text(img: &mut RgbImage, x: f64, y: f64, px: f64, s: &str, c: C3, bold: bool) -> f64 { font(bold).draw(img, x, y, px, s, c, 1.0) }+/// Text over a photo: a dark edge all round keeps it readable on anything.+pub fn text_halo(img: &mut RgbImage, x: f64, y: f64, px: f64, s: &str, c: C3, bold: bool) -> f64 {+    let r = (px / 11.0).max(1.2);+    for (dx, dy) in [(-1.0, 0.0), (1.0, 0.0), (0.0, -1.0), (0.0, 1.0), (-0.7, -0.7), (0.7, -0.7), (-0.7, 0.7), (0.7, 0.7)] { font(bold).draw(img, x + dx * r, y + dy * r, px, s, INK, 0.9); }+    font(bold).draw(img, x, y, px, s, c, 1.0)+}+pub fn text_width(px: f64, s: &str, bold: bool) -> f64 { font(bold).width(px, s) }+/// `s`, cut with three dots if it is wider than `max`.+pub fn fit(s: &str, px: f64, max: f64, bold: bool) -> String {+    if text_width(px, s, bold) <= max { return s.to_string(); }+    let mut out: String = s.to_string();+    while !out.is_empty() && text_width(px, &format!("{out}..."), bold) > max { out.pop(); }+    format!("{}...", out.trim_end())+}+/// Break `s` into lines no wider than `max`.+pub fn wrap(s: &str, px: f64, max: f64, bold: bool) -> Vec<String> {+    let mut lines: Vec<String> = vec![]; let mut cur = String::new();+    for word in s.split_whitespace() {+        let trial = if cur.is_empty() { word.to_string() } else { format!("{cur} {word}") };+        if text_width(px, &trial, bold) <= max || cur.is_empty() { cur = trial; } else { lines.push(std::mem::take(&mut cur)); cur = word.to_string(); }+    }+    if !cur.is_empty() { lines.push(cur); }+    lines+}++// ---------------------------------------------------------------- shapes++#[inline]+pub fn blend(img: &mut RgbImage, x: i64, y: i64, c: C3, a: f32) {+    if x < 0 || y < 0 || x >= img.width() as i64 || y >= img.height() as i64 || a <= 0.0 { return; }+    let a = a.min(1.0); let p = img.get_pixel_mut(x as u32, y as u32);+    for k in 0..3 { p[k] = (p[k] as f32 * (1.0 - a) + c[k] as f32 * a + 0.5) as u8; }+}++pub fn fill_rect(img: &mut RgbImage, x0: f64, y0: f64, x1: f64, y1: f64, c: C3, a: f32) {+    for y in y0.round() as i64..y1.round() as i64 { for x in x0.round() as i64..x1.round() as i64 { blend(img, x, y, c, a); } }+}+pub fn frame(img: &mut RgbImage, x0: f64, y0: f64, x1: f64, y1: f64, th: f64, c: C3, a: f32) {+    fill_rect(img, x0, y0, x1, y0 + th, c, a); fill_rect(img, x0, y1 - th, x1, y1, c, a); fill_rect(img, x0, y0 + th, x0 + th, y1 - th, c, a); fill_rect(img, x1 - th, y0 + th, x1, y1 - th, c, a);+}++fn seg_dist(p: (f64, f64), a: (f64, f64), b: (f64, f64)) -> f64 {+    let (vx, vy) = (b.0 - a.0, b.1 - a.1); let l2 = vx * vx + vy * vy;+    let t = if l2 > 0.0 { (((p.0 - a.0) * vx + (p.1 - a.1) * vy) / l2).clamp(0.0, 1.0) } else { 0.0 };+    ((p.0 - a.0 - t * vx).powi(2) + (p.1 - a.1 - t * vy).powi(2)).sqrt()+}+/// A line with round ends, `half` its half width.+pub fn capsule(img: &mut RgbImage, a: (f64, f64), b: (f64, f64), half: f64, c: C3, alpha: f32) {+    let (x0, x1) = ((a.0.min(b.0) - half - 1.0).floor() as i64, (a.0.max(b.0) + half + 1.0).ceil() as i64);+    let (y0, y1) = ((a.1.min(b.1) - half - 1.0).floor() as i64, (a.1.max(b.1) + half + 1.0).ceil() as i64);+    for y in y0.max(0)..=y1.min(img.height() as i64 - 1) { for x in x0.max(0)..=x1.min(img.width() as i64 - 1) {+        let v = (half - seg_dist((x as f64 + 0.5, y as f64 + 0.5), a, b) + 0.5).clamp(0.0, 1.0);+        if v > 0.0 { blend(img, x, y, c, alpha * v as f32); }+    } }+}+pub fn disc(img: &mut RgbImage, m: (f64, f64), r: f64, c: C3, alpha: f32) { capsule(img, m, m, r, c, alpha); }+pub fn ring(img: &mut RgbImage, m: (f64, f64), r: f64, th: f64, c: C3, alpha: f32) {+    let o = r + th / 2.0 + 1.0;+    for y in (m.1 - o).floor() as i64..=(m.1 + o).ceil() as i64 { for x in (m.0 - o).floor() as i64..=(m.0 + o).ceil() as i64 {+        let d = ((x as f64 + 0.5 - m.0).powi(2) + (y as f64 + 0.5 - m.1).powi(2)).sqrt();+        let v = (th / 2.0 - (d - r).abs() + 0.5).clamp(0.0, 1.0);+        if v > 0.0 { blend(img, x, y, c, alpha * v as f32); }+    } }+}+pub fn outline(img: &mut RgbImage, pts: &[(f64, f64)], half: f64, c: C3, alpha: f32) { for i in 0..pts.len() { capsule(img, pts[i], pts[(i + 1) % pts.len()], half, c, alpha); } }+/// Fill a convex shape (a turned pad).+pub fn fill_convex(img: &mut RgbImage, pts: &[(f64, f64)], c: C3, alpha: f32) {+    let (x0, x1) = (pts.iter().map(|p| p.0).fold(f64::MAX, f64::min).floor() as i64, pts.iter().map(|p| p.0).fold(f64::MIN, f64::max).ceil() as i64);+    let (y0, y1) = (pts.iter().map(|p| p.1).fold(f64::MAX, f64::min).floor() as i64, pts.iter().map(|p| p.1).fold(f64::MIN, f64::max).ceil() as i64);+    let area: f64 = (0..pts.len()).map(|i| { let (a, b) = (pts[i], pts[(i + 1) % pts.len()]); a.0 * b.1 - b.0 * a.1 }).sum();+    let sign = if area >= 0.0 { 1.0 } else { -1.0 };+    for y in y0..=y1 { for x in x0..=x1 {+        let p = (x as f64 + 0.5, y as f64 + 0.5);+        let inside = (0..pts.len()).map(|i| { let (a, b) = (pts[i], pts[(i + 1) % pts.len()]); let n = ((b.0 - a.0).powi(2) + (b.1 - a.1).powi(2)).sqrt().max(1e-9); sign * ((b.0 - a.0) * (p.1 - a.1) - (b.1 - a.1) * (p.0 - a.0)) / n }).fold(f64::MAX, f64::min);+        let v = (inside + 0.5).clamp(0.0, 1.0);+        if v > 0.0 { blend(img, x, y, c, alpha * v as f32); }+    } }+}+/// Cut a line into dashes: `on` drawn, `off` skipped, measured along it.+pub fn dashes(pts: &[(f64, f64)], on: f64, off: f64) -> Vec<((f64, f64), (f64, f64))> {+    let mut out = vec![]; let (mut pos, period) = (0.0, on + off);+    for q in pts.windows(2) {+        let l = ((q[1].0 - q[0].0).powi(2) + (q[1].1 - q[0].1).powi(2)).sqrt(); if l <= 0.0 { continue; }+        let at = |t: f64| (q[0].0 + (q[1].0 - q[0].0) * t / l, q[0].1 + (q[1].1 - q[0].1) * t / l);+        let mut t = 0.0;+        while t < l {+            // always move on: a phase a rounding error short of a boundary would otherwise stand still+            let phase = (pos + t) % period;+            let end = (t + (if phase < on { on - phase } else { period - phase }).max(1e-6)).min(l);+            if phase < on && end - t > 1e-6 { out.push((at(t), at(end))); }+            t = end;+        }+        pos += l;+    }+    out+}++// ---------------------------------------------------------------- copper in a colour and a pattern++#[derive(Clone, Copy, Debug, PartialEq)]+pub enum Pattern { Solid, Bands, Diagonal, Grid, Dots, Hazard }+#[derive(Clone, Copy, Debug)]+pub struct Style { pub color: C3, pub pattern: Pattern, /// dashes for tracks, drawn and skipped, in pixels at 1080p; None is a solid line+    pub dash: Option<(f64, f64)> }++/// The six rungs, in the ladder's order: hue and pattern both carry the level.+pub const RUNGS: [Style; 6] = [+    Style { color: [86, 180, 233], pattern: Pattern::Solid, dash: None },                 // copper: sky blue, solid+    Style { color: [0, 205, 150], pattern: Pattern::Bands, dash: None },                  // via: bluish green, level bands+    Style { color: [245, 170, 20], pattern: Pattern::Diagonal, dash: Some((9.0, 6.0)) },  // inferred: orange, diagonal+    Style { color: [220, 125, 190], pattern: Pattern::Grid, dash: Some((3.0, 5.0)) },     // group: reddish purple, grid+    Style { color: [190, 190, 190], pattern: Pattern::Dots, dash: None },                 // none: grey, dots+    Style { color: [240, 80, 20], pattern: Pattern::Hazard, dash: Some((9.0, 6.0)) },     // conflict: vermillion, dark stripes+];+/// Eight hues to tell nets apart on one sheet (Okabe-Ito, lifted for a dark ground). The sheet also labels each net.+pub const NET_HUES: [C3; 8] = [[245, 170, 20], [86, 180, 233], [0, 205, 150], [240, 228, 66], [70, 130, 255], [240, 80, 20], [220, 125, 190], [245, 245, 245]];++/// How strongly the pattern shows at a pixel: 1 on a stroke, less between strokes, negative for the dark stripe of a hazard.+pub fn pattern_at(p: Pattern, x: i64, y: i64, unit: f64) -> f32 {+    let per = (10.0 * unit).round().max(4.0) as i64; let m = |v: i64, n: i64| v.rem_euclid(n);+    match p {+        Pattern::Solid => 1.0,+        Pattern::Bands => if m(y, per) < per * 6 / 10 { 1.0 } else { 0.38 },+        Pattern::Diagonal => if m(x + y, per) < per / 2 { 1.0 } else { 0.28 },+        Pattern::Grid => if m(x, per) < per * 3 / 10 || m(y, per) < per * 3 / 10 { 1.0 } else { 0.22 },+        Pattern::Dots => if m(x, per) < per * 4 / 10 && m(y, per) < per * 4 / 10 { 1.0 } else { 0.3 },+        Pattern::Hazard => if m(x - y, per * 3 / 2) < per * 3 / 4 { 1.0 } else { -1.0 },+    }+}++/// How much of each pixel of a panel is covered by the copper being painted.+pub struct Cover { pub w: usize, pub h: usize, pub d: Vec<f32> }+impl Cover {+    pub fn new(w: usize, h: usize) -> Cover { Cover { w, h, d: vec![0.0; w * h] } }+    pub fn clear(&mut self) { self.d.iter_mut().for_each(|v| *v = 0.0); }+    /// Add a rectangle given in panel pixels, each pixel by the share of it the rectangle covers.+    pub fn add_rect(&mut self, x0: f64, y0: f64, x1: f64, y1: f64) {+        let (x0, y0, x1, y1) = (x0.max(0.0), y0.max(0.0), x1.min(self.w as f64), y1.min(self.h as f64));+        if x1 <= x0 || y1 <= y0 { return; }+        for y in y0.floor() as usize..(y1.ceil() as usize).min(self.h) {+            let wy = ((y + 1) as f64).min(y1) - (y as f64).max(y0);+            let row = y * self.w;+            for x in x0.floor() as usize..(x1.ceil() as usize).min(self.w) { let wx = ((x + 1) as f64).min(x1) - (x as f64).max(x0); self.d[row + x] += (wx * wy) as f32; }+        }+    }+    pub fn capsule(&mut self, a: (f64, f64), b: (f64, f64), half: f64) {+        let (x0, x1) = ((a.0.min(b.0) - half - 1.0).floor().max(0.0) as usize, ((a.0.max(b.0) + half + 1.0).ceil().max(0.0) as usize).min(self.w.saturating_sub(1)));+        let (y0, y1) = ((a.1.min(b.1) - half - 1.0).floor().max(0.0) as usize, ((a.1.max(b.1) + half + 1.0).ceil().max(0.0) as usize).min(self.h.saturating_sub(1)));+        if a.0.max(b.0) + half < 0.0 || a.1.max(b.1) + half < 0.0 { return; }+        for y in y0..=y1 { for x in x0..=x1 { let v = (half - seg_dist((x as f64 + 0.5, y as f64 + 0.5), a, b) + 0.5).clamp(0.0, 1.0) as f32; let e = &mut self.d[y * self.w + x]; if v > *e { *e = v; } } }+    }+    /// Paint what is covered onto the picture at (ox, oy). `patterned`: pours carry the pattern, tracks do not.+    pub fn paint(&self, img: &mut RgbImage, ox: i64, oy: i64, s: &Style, alpha: f32, unit: f64, patterned: bool) {+        for y in 0..self.h { for x in 0..self.w {+            let v = self.d[y * self.w + x].min(1.0); if v <= 0.003 { continue; }+            let (gx, gy) = (ox + x as i64, oy + y as i64);+            let k = if patterned { pattern_at(s.pattern, gx, gy, unit) } else { 1.0 };+            if k < 0.0 { blend(img, gx, gy, INK, alpha * v); } else { blend(img, gx, gy, s.color, alpha * v * k); }+        } }+    }+}++/// A square of a style, for a legend.+pub fn swatch(img: &mut RgbImage, x: f64, y: f64, size: f64, s: &Style, unit: f64) {+    fill_rect(img, x, y, x + size, y + size, [44, 48, 54], 1.0);+    for py in y.round() as i64..(y + size).round() as i64 { for px in x.round() as i64..(x + size).round() as i64 {+        let k = pattern_at(s.pattern, px, py, unit);+        if k < 0.0 { blend(img, px, py, INK, 0.92); } else { blend(img, px, py, s.color, 0.92 * k); }+    } }+    frame(img, x, y, x + size, y + size, (unit * 1.5).max(1.0), WHITE, 0.55);+}++pub fn save_jpeg(img: &RgbImage, path: &std::path::Path, quality: u8) -> Result<(), String> {+    if let Some(d) = path.parent() { std::fs::create_dir_all(d).map_err(|e| e.to_string())?; }+    let f = std::fs::File::create(path).map_err(|e| format!("{}: {e}", path.display()))?;+    let mut w = std::io::BufWriter::new(f);+    image::codecs::jpeg::JpegEncoder::new_with_quality(&mut w, quality).encode_image(img).map_err(|e| format!("{}: {e}", path.display()))+}++pub fn px(c: C3) -> Rgb<u8> { Rgb(c) }++#[cfg(test)]+mod tests {+    use super::*;++    #[test]+    fn text_has_a_width_and_leaves_ink() {+        let mut img = RgbImage::from_pixel(400, 60, px(INK));+        let end = text(&mut img, 10.0, 8.0, 28.0, "GND 3V3", WHITE, true);+        assert!((end - 10.0 - text_width(28.0, "GND 3V3", true)).abs() < 1e-6);+        assert!(text_width(28.0, "GND 3V3", true) > 80.0 && text_width(28.0, "GND 3V3", true) < 200.0);+        let lit = img.pixels().filter(|p| p[0] > 200).count();+        assert!(lit > 300, "letters should leave bright pixels, got {lit}");+        // nothing is drawn above the line or past the end of the text+        assert!((0..400).all(|x| img.get_pixel(x, 2)[0] < 40));+        assert!((end.ceil() as u32 + 3..400).all(|x| (0..60).all(|y| img.get_pixel(x, y)[0] < 40)));+        // a letter the atlas does not hold falls back, it does not panic+        text(&mut img, 10.0, 8.0, 20.0, "\u{2014}", WHITE, false);+    }++    #[test]+    fn long_text_is_cut_or_wrapped_to_its_room() {+        let s = "every pin meets this net's copper with no gap";+        let cut = fit(s, 20.0, 200.0, false);+        assert!(cut.ends_with("...") && text_width(20.0, &cut, false) <= 200.0);+        assert_eq!(fit("GND", 20.0, 200.0, false), "GND");+        let lines = wrap(s, 20.0, 200.0, false);+        assert!(lines.len() >= 2 && lines.iter().all(|l| text_width(20.0, l, false) <= 200.0));+        assert_eq!(lines.join(" "), s);+    }++    #[test]+    fn every_level_has_its_own_hue_and_its_own_pattern() {+        for a in 0..6 { for b in a + 1..6 {+            assert_ne!(RUNGS[a].pattern, RUNGS[b].pattern, "levels {a} and {b} share a pattern");+            let d: i32 = (0..3).map(|k| (RUNGS[a].color[k] as i32 - RUNGS[b].color[k] as i32).abs()).sum();+            assert!(d > 90, "levels {a} and {b} are too close in colour");+        } }+        // a pattern is not flat, except the solid one: some pixels of a tile are strong and some weak+        for s in &RUNGS[1..] { let v: Vec<f32> = (0..20).flat_map(|y| (0..20).map(move |x| (x, y))).map(|(x, y)| pattern_at(s.pattern, x, y, 1.0)).collect(); assert!(v.iter().any(|k| *k >= 1.0) && v.iter().any(|k| *k < 0.5)); }+    }++    #[test]+    fn cover_counts_shares_of_pixels_and_dashes_keep_their_length() {+        let mut c = Cover::new(4, 4);+        c.add_rect(0.5, 0.0, 2.0, 1.0);+        assert!((c.d[0] - 0.5).abs() < 1e-6 && (c.d[1] - 1.0).abs() < 1e-6 && c.d[2] == 0.0);+        c.add_rect(-5.0, -5.0, 50.0, 50.0); // clipped to the panel, never out of bounds+        let d = dashes(&[(0.0, 0.0), (10.0, 0.0), (10.0, 10.0)], 3.0, 2.0);+        let drawn: f64 = d.iter().map(|(a, b)| ((b.0 - a.0).powi(2) + (b.1 - a.1).powi(2)).sqrt()).sum();+        assert!((drawn - 12.0).abs() < 1e-4, "{drawn}");+        // lengths that do not divide evenly still end: this stood still once, at a phase just short of a dash's end+        for k in 1..400 { let (on, off) = (9.0 * 1.964, 6.0 * 1.964); let l = k as f64 * 0.7313; let d = dashes(&[(0.0, 0.0), (l, 0.0), (l, l * 1.37)], on, off); assert!(d.len() <= 2 * (l * 2.37 / (on + off)) as usize + 6, "{} dashes on a line of {}", d.len(), l * 2.37); }+    }+}
src/netsections.rsadded+156
@@ -0,0 +1,156 @@+//! The nets in sections a person can page through: ground, each supply rail, each bus or interface, then what is+//! left by the functional block that holds most of its pins, then the rest.+//!+//! Names decide first, because a name a person gave (or a connector's pin name) says what a net is for. A net whose+//! name says nothing (`N012`, `CU_R4_1`) or that shares a stem with no other net goes to the block in blocks.json+//! that holds most of its pins. Nothing here reads a photo or changes a net.+use crate::netconf::nat_cmp;+use serde_json::Value;+use std::collections::{BTreeMap, BTreeSet, HashMap};++#[derive(Clone, Debug, PartialEq)]+pub struct Section { pub kind: &'static str, pub title: String, pub why: String, pub nets: Vec<String> }++const GROUND: [&str; 12] = ["GND", "AGND", "DGND", "PGND", "SGND", "GNDA", "GNDD", "VSS", "VSSA", "GROUND", "EARTH", "0V"];+const SUPPLY: [&str; 22] = ["VIN", "VCC", "VDD", "VBAT", "VBATT", "VBUS", "VSYS", "VREF", "VPP", "VM", "VS", "VOUT", "VDDA", "VDDIO", "VCCIO", "AVDD", "DVDD", "AVCC", "VCCA", "VSUP", "VMOT", "VPWR"];+/// A name with one of these is a signal about a rail, not the rail.+const CONTROL: [&str; 20] = ["EN", "ENABLE", "ON", "OFF", "OVERRIDE", "SENSE", "SNS", "FB", "ADJ", "PG", "PGOOD", "OK", "ADC", "DET", "SW", "BST", "BOOT", "DISABLE", "NDISABLE", "FAULT"];++fn tokens(name: &str) -> Vec<String> { name.split(|c: char| c == '_' || c == '-' || c == ' ' || c == '/').filter(|t| !t.is_empty()).map(|t| t.to_uppercase()).collect() }++/// `3V3`, `5V`, `12V`, `1.8V`, `3V3A`, with or without a sign.+fn is_voltage(t: &str) -> bool {+    let t = t.trim_start_matches(['+', '-']);+    let Some(v) = t.find('V') else { return false };+    let (a, b) = (&t[..v], &t[v + 1..]);+    !a.is_empty() && a.chars().all(|c| c.is_ascii_digit() || c == '.') && a.chars().next().unwrap().is_ascii_digit()+        && b.trim_end_matches(|c: char| c.is_ascii_uppercase()).chars().all(|c| c.is_ascii_digit()) && b.chars().filter(|c| c.is_ascii_uppercase()).count() <= 2+}++/// A name that says nothing about the net: one the program made up.+pub fn is_auto(name: &str) -> bool {+    let u = name.to_uppercase();+    let numbered = |p: &str| u.strip_prefix(p).map(|r| !r.is_empty() && r.chars().all(|c| c.is_ascii_digit())).unwrap_or(false);+    u.is_empty() || u.starts_with("CU_") || u.starts_with("UNK") || u.starts_with("NET-(") || numbered("N") || numbered("NET") || numbered("NET_") || numbered("N$")+}+pub fn is_ground(name: &str) -> bool { let t = tokens(name); t.iter().any(|x| GROUND.contains(&x.as_str())) || t.first().map(|x| x.starts_with("GND")).unwrap_or(false) }+pub fn is_supply(name: &str) -> bool {+    let t = tokens(name);+    if t.is_empty() || t.iter().any(|x| CONTROL.contains(&x.as_str()) || CONTROL.contains(&x.trim_start_matches('N'))) { return false; }+    let rail = |x: &String| SUPPLY.contains(&x.as_str()) || is_voltage(x);+    let signed = name.starts_with(['+', '-']) && name.chars().nth(1).map(|c| c.is_ascii_digit()).unwrap_or(false);+    signed || rail(&t[0]) || rail(&t[t.len() - 1])+}++fn strip_digits(s: &str) -> &str { s.trim_end_matches(|c: char| c.is_ascii_digit()) }++/// Letters that tell the members of one family apart: phases, channels, high and low, the two wires of a pair.+const FAMILIES: [&str; 6] = ["ABCD", "UVW", "XYZ", "HL", "PN", "RST"];++/// `GHA` gives (`GH`, `A`): a stem and the letter that would tell this net from its siblings.+fn lettered(name: &str) -> Option<(String, char)> {+    let t = tokens(name); let bare = strip_digits(t.first()?);+    let last = bare.chars().last()?;+    (bare.len() >= 3 && last.is_ascii_uppercase()).then(|| (bare[..bare.len() - 1].to_string(), last))+}++/// The stems a signal's name could share with others, most specific first, each with how many nets must share it.+/// `family`: whether a stem has members told apart by one letter of a known set.+fn stems(name: &str, family: &dyn Fn(&str) -> bool) -> Vec<(String, usize)> {+    let t = tokens(name);+    let Some(t0) = t.first() else { return vec![] };+    let mut out: Vec<(String, usize)> = vec![];+    // two wires that carry their bus in the name of the wire, not in a prefix+    let bare = strip_digits(t0);+    if t.len() == 1 && (bare == "SCL" || bare == "SDA") { out.push((format!("I2C{}", &t0[bare.len()..]), 2)); }+    if t.len() == 1 && ["SWDIO", "SWCLK", "SWO", "SWDCLK"].contains(&t0.as_str()) { out.push(("SWD".into(), 2)); }+    if t.len() == 1 && ["TCK", "TMS", "TDI", "TDO", "NTRST"].contains(&t0.as_str()) { out.push(("JTAG".into(), 2)); }+    if t.len() >= 2 && t[1].chars().all(|c| c.is_ascii_digit()) { out.push((format!("{t0}-{}", t[1]), 2)); }+    // GHA, GHB, GHC: one letter tells the members apart+    if let Some((stem, _)) = lettered(name) { if family(&stem) { out.push((stem, 2)); } }+    if bare.len() != t0.len() { out.push((t0.clone(), 3)); }+    if bare.len() >= 2 { out.push((bare.to_string(), 2)); }+    out+}++/// Sort the nets into sections. `nets`: name and pins; `blocks`: blocks.json.+pub fn assign(nets: &[(String, Vec<String>)], blocks: &Value) -> Vec<Section> {+    let mut ground: Vec<String> = vec![]; let mut supply: Vec<(String, usize)> = vec![]; let mut rest: Vec<&(String, Vec<String>)> = vec![];+    for n in nets {+        if is_auto(&n.0) { rest.push(n); } else if is_ground(&n.0) { ground.push(n.0.clone()); } else if is_supply(&n.0) { supply.push((n.0.clone(), n.1.len())); } else { rest.push(n); }+    }+    let mut letters: HashMap<String, BTreeSet<char>> = HashMap::new();+    for n in rest.iter().filter(|n| !is_auto(&n.0)) { if let Some((stem, l)) = lettered(&n.0) { letters.entry(stem).or_default().insert(l); } }+    let family = |stem: &str| letters.get(stem).map(|l| l.len() >= 2 && FAMILIES.iter().any(|f| l.iter().all(|c| f.contains(*c)))).unwrap_or(false);+    let mut share: HashMap<String, usize> = HashMap::new();+    for n in rest.iter().filter(|n| !is_auto(&n.0)) { for (s, _) in stems(&n.0, &family) { *share.entry(s).or_default() += 1; } }+    let mut buses: BTreeMap<String, Vec<String>> = BTreeMap::new(); let mut left: Vec<&(String, Vec<String>)> = vec![];+    for n in rest {+        let hit = if is_auto(&n.0) { None } else { stems(&n.0, &family).into_iter().find(|(s, need)| share.get(s).copied().unwrap_or(0) >= *need) };+        match hit { Some((s, _)) => buses.entry(s).or_default().push(n.0.clone()), None => left.push(n) }+    }+    // what names do not place goes to the block that holds most of its pins+    let block_list: Vec<(String, Vec<String>)> = blocks["blocks"].as_array().into_iter().flatten().map(|b| (b["name"].as_str().or(b["id"].as_str()).unwrap_or("block").to_string(), b["parts"].as_array().into_iter().flatten().filter_map(|p| p.as_str().map(str::to_string)).collect())).collect();+    let block_of: HashMap<&str, usize> = block_list.iter().enumerate().flat_map(|(k, b)| b.1.iter().map(move |p| (p.as_str(), k))).collect();+    let mut by_block: Vec<Vec<String>> = vec![vec![]; block_list.len()]; let mut other: Vec<String> = vec![];+    for n in left {+        let mut votes = vec![0usize; block_list.len()];+        for p in &n.1 { if let Some(k) = block_of.get(p.split('.').next().unwrap_or("")) { votes[*k] += 1; } }+        match (0..votes.len()).filter(|k| votes[*k] > 0).max_by(|a, b| votes[*a].cmp(&votes[*b]).then(b.cmp(a))) { Some(k) => by_block[k].push(n.0.clone()), None => other.push(n.0.clone()) }+    }+    let sorted = |mut v: Vec<String>| { v.sort_by(|a, b| nat_cmp(a, b)); v };+    let mut out = vec![];+    if !ground.is_empty() { out.push(Section { kind: "ground", title: "Ground".into(), why: "named as a ground".into(), nets: sorted(ground) }); }+    supply.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| nat_cmp(&a.0, &b.0)));+    for (n, _) in supply { out.push(Section { kind: "supply", title: format!("Supply rail {n}"), why: "named as a supply rail".into(), nets: vec![n] }); }+    let mut bus_list: Vec<(String, Vec<String>)> = buses.into_iter().collect();+    bus_list.sort_by(|a, b| nat_cmp(&a.0, &b.0));+    for (s, v) in bus_list { out.push(Section { kind: "bus", title: format!("{s} nets"), why: format!("names that share the stem {s}"), nets: sorted(v) }); }+    for (k, v) in by_block.into_iter().enumerate() { if !v.is_empty() { out.push(Section { kind: "block", title: format!("Other nets of the block: {}", block_list[k].0), why: "the name places it nowhere, and this block holds most of its pins".into(), nets: sorted(v) }); } }+    if !other.is_empty() { out.push(Section { kind: "other", title: "Other nets".into(), why: "no name pattern and no block".into(), nets: sorted(other) }); }+    out+}++#[cfg(test)]+mod tests {+    use super::*;+    use serde_json::json;+    fn n(name: &str, pins: &[&str]) -> (String, Vec<String>) { (name.to_string(), pins.iter().map(|p| p.to_string()).collect()) }+    fn find<'a>(s: &'a [Section], title: &str) -> &'a Section { s.iter().find(|x| x.title == title).unwrap_or_else(|| panic!("no section {title}: {:?}", s.iter().map(|x| &x.title).collect::<Vec<_>>())) }++    #[test]+    fn grounds_and_rails_are_told_from_signals_about_them() {+        for g in ["GND", "AGND", "GND_ISO", "VSS", "PWR_GND"] { assert!(is_ground(g), "{g}"); }+        for s in ["+3V3", "+3V3A", "+12V", "VIN", "VIN_LOGIC", "VBAT", "CM5_3.3V", "CM5_1.8V", "GPIO_VREF", "DRV_DVDD", "-5V"] { assert!(is_supply(s), "{s}"); }+        for s in ["VBUS_EN", "SD_VDD_OVERRIDE", "VSENSE", "SD_PWR_ON", "LDO5_ADJ", "BUCK_SW", "GPIO5", "HDMI1_TX2_P", "PHASE_X1", "WL_nDisable"] { assert!(!is_supply(s), "{s}"); assert!(!is_ground(s), "{s}"); }+        for a in ["N001", "CU_R4_1", "UNK_G3", "NET12"] { assert!(is_auto(a), "{a}"); }+        for a in ["NRST", "NTC", "NET_OK"] { assert!(!is_auto(a), "{a}"); }+    }++    #[test]+    fn buses_come_from_shared_stems_and_stay_apart_by_index() {+        let nets: Vec<(String, Vec<String>)> = ["GND", "+5V", "HDMI0_TX0_P", "HDMI0_TX0_N", "HDMI0_SDA", "HDMI1_TX0_P", "HDMI1_TX0_N", "HDMI1_SCL", "GPIO4", "GPIO17", "USB3-0-RX_N", "USB3-0-RX_P", "USB3-1-RX_N", "USB3-1-RX_P", "USB_P", "USB_N",+            "SCL0", "SDA0", "GHA", "GHB", "GHC", "SWDIO", "SWCLK", "PHASE_X1", "PHASE_X2", "LED1_K", "LED2_K", "NRST"].iter().map(|x| n(x, &[])).collect();+        let s = assign(&nets, &json!({}));+        assert_eq!(s[0].kind, "ground"); assert_eq!(s[1].title, "Supply rail +5V");+        assert_eq!(find(&s, "HDMI0 nets").nets.len(), 3); assert_eq!(find(&s, "HDMI1 nets").nets.len(), 3);+        assert_eq!(find(&s, "GPIO nets").nets, vec!["GPIO4", "GPIO17"]);+        assert_eq!(find(&s, "USB3-0 nets").nets.len(), 2); assert_eq!(find(&s, "USB3-1 nets").nets.len(), 2); assert_eq!(find(&s, "USB nets").nets, vec!["USB_N", "USB_P"]);+        assert_eq!(find(&s, "I2C0 nets").nets, vec!["SCL0", "SDA0"]); assert_eq!(find(&s, "GH nets").nets.len(), 3); assert_eq!(find(&s, "SWD nets").nets.len(), 2);+        assert_eq!(find(&s, "PHASE nets").nets.len(), 2); assert_eq!(find(&s, "LED nets").nets.len(), 2);+        assert_eq!(find(&s, "Other nets").nets, vec!["NRST"]);+        assert_eq!(s.iter().map(|x| x.nets.len()).sum::<usize>(), nets.len(), "every net is in one section");+        // a letter only makes a family when it is one of a set: NTC and NTC_ADC are the NTC nets, CAN and CAM stay apart+        let s = assign(&["NTC", "NTC_ADC", "CAN_H", "CAN_L", "CAM_GPIO0", "CAM_GPIO1", "SOA", "SOA_ADC", "SOB", "SOB_ADC"].iter().map(|x| n(x, &[])).collect::<Vec<_>>(), &json!({}));+        assert_eq!(find(&s, "NTC nets").nets.len(), 2); assert_eq!(find(&s, "CAN nets").nets.len(), 2); assert_eq!(find(&s, "CAM nets").nets.len(), 2); assert_eq!(find(&s, "SO nets").nets.len(), 4);+    }++    #[test]+    fn a_name_that_says_nothing_goes_to_the_block_holding_most_of_its_pins() {+        let blocks = json!({"blocks": [{"id": "mcu", "name": "Microcontroller", "parts": ["U1", "C1"]}, {"id": "can", "name": "CAN", "parts": ["U6", "J2"]}]});+        let s = assign(&[n("N001", &["U1.3", "C1.1", "U6.2"]), n("NRST", &["U6.1"]), n("N002", &["X9.1"])], &blocks);+        assert_eq!(find(&s, "Other nets of the block: Microcontroller").nets, vec!["N001"]);+        assert_eq!(find(&s, "Other nets of the block: CAN").nets, vec!["NRST"]);+        assert_eq!(find(&s, "Other nets").nets, vec!["N002"]);+    }+}
src/netview.rsadded+144
@@ -0,0 +1,144 @@+//! The nets as a page: a net list beside the board, a click lights a net on both faces, and a switch between+//! "by confidence" and "by net". `nets draw` writes it to nets/view.html and records it in the flow window as a+//! numbered view, like every other stage's view.+//!+//! The page carries everything it needs but the two face photos: each face's copper as a small map (which net and+//! which rung at every pixel, 20 px/mm, as a PNG inside the page) and the nets with their levels. So a recorded view+//! still shows the nets as they were when the run moves on.+use crate::netconf::{self, rank, LADDER};+use crate::netdraw::Scene;+use crate::netpaint::{NET_HUES, RUNGS};+use serde_json::{json, Value};+use std::collections::HashMap;+use std::path::Path;++const PAGE: &str = include_str!("../assets/nets-view.html");+/// Pixels per mm of the copper maps: half the working resolution, enough for a 0.1 mm track to show.+const MAP_RES: f64 = 20.0;+const ORIGIN: f64 = crate::board::ORIGIN;++fn base64(data: &[u8]) -> String {+    const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";+    let mut out = String::with_capacity(data.len().div_ceil(3) * 4);+    for c in data.chunks(3) {+        let v = (c[0] as u32) << 16 | (*c.get(1).unwrap_or(&0) as u32) << 8 | *c.get(2).unwrap_or(&0) as u32;+        out.push(T[(v >> 18) as usize & 63] as char); out.push(T[(v >> 12) as usize & 63] as char);+        out.push(if c.len() > 1 { T[(v >> 6) as usize & 63] as char } else { '=' }); out.push(if c.len() > 2 { T[v as usize & 63] as char } else { '=' });+    }+    out+}++/// One face's copper as pixels: red and green hold the net's number plus one, blue the rung plus one, black is no copper.+pub fn copper_map(sc: &Scene, side: usize) -> image::RgbImage {+    let (w, h) = ((sc.wmm * MAP_RES).round() as u32, (sc.hmm * MAP_RES).round() as u32);+    let mut img = image::RgbImage::new(w.max(1), h.max(1));+    let k = crate::board::RES / MAP_RES;+    let px = |net: usize, rung: usize| image::Rgb([((net + 1) & 255) as u8, ((net + 1) >> 8) as u8, rung as u8 + 1]);+    let mut dot = |img: &mut image::RgbImage, x: f64, y: f64, r: f64, c: image::Rgb<u8>| {+        let (cx, cy) = ((x - ORIGIN) * MAP_RES, (y - ORIGIN) * MAP_RES);+        for v in (cy - r).floor() as i64..=(cy + r).ceil() as i64 { for u in (cx - r).floor() as i64..=(cx + r).ceil() as i64 {+            if u >= 0 && v >= 0 && (u as u32) < w && (v as u32) < h && (u as f64 + 0.5 - cx).powi(2) + (v as f64 + 0.5 - cy).powi(2) <= r * r { img.put_pixel(u as u32, v as u32, c); }+        } }+    };+    for z in sc.zones.iter().filter(|z| z.side == side) { let c = px(z.net, z.rung); for (y, x0, x1) in &z.runs {+        let v = (*y as f64 / k) as u32; if v >= h { continue; }+        for u in (*x0 as f64 / k) as u32..=((*x1 - 1) as f64 / k) as u32 { if u < w { img.put_pixel(u, v, c); } }+    } }+    for t in sc.tracks.iter().filter(|t| t.side == side) { let c = px(t.net, t.rung); let r = (t.width * MAP_RES / 2.0).max(1.1);+        for q in t.pts.windows(2) { let n = (crate::geom::dist(q[0], q[1]) * MAP_RES * 2.0).ceil().max(1.0) as usize; for i in 0..=n { let a = i as f64 / n as f64; dot(&mut img, q[0].0 + (q[1].0 - q[0].0) * a, q[0].1 + (q[1].1 - q[0].1) * a, r, c); } }+    }+    for v in &sc.vias { dot(&mut img, v.x, v.y, (v.d * MAP_RES / 2.0).max(2.2), px(v.net, v.rung)); }+    img+}++/// Write the page. `sections`: the rows `nets draw` built for sections.json.+pub fn write(sc: &Scene, conf: &Value, sections: &[Value], path: &Path) -> Result<(), String> {+    let mut maps = serde_json::Map::new();+    for (k, side) in ["top", "bottom"].iter().enumerate() {+        if sc.faces[k].is_none() { continue; }+        let m = copper_map(sc, k); let mut png: Vec<u8> = vec![];+        image::ImageEncoder::write_image(image::codecs::png::PngEncoder::new(&mut png), m.as_raw(), m.width(), m.height(), image::ExtendedColorType::Rgb8).map_err(|e| e.to_string())?;+        maps.insert(side.to_string(), json!(format!("data:image/png;base64,{}", base64(&png))));+    }+    // every number a map pixel can hold: a named net, or the class of copper with no net+    let mut ids = vec![json!({"u": 4}); sc.net_ix.len()];+    for (name, k) in &sc.net_ix { ids[*k] = match netconf::unnamed_class(name) { Some(c) => json!({"u": rank(c)}), None => json!({"n": name}) }; }+    let rows: Vec<&Value> = conf["nets"].as_array().into_iter().flatten().collect();+    let section_of: HashMap<&str, usize> = sections.iter().enumerate().flat_map(|(i, s)| s["nets"].as_array().into_iter().flatten().filter_map(move |n| n.as_str().map(|n| (n, i)))).collect();+    let mut pins = serde_json::Map::new();+    let nets: Vec<Value> = rows.iter().map(|r| {+        let name = r["net"].as_str().unwrap_or("");+        let mut list = vec![];+        for (p, seen) in r["seenPins"].as_array().into_iter().flatten().filter_map(|p| p.as_str()).map(|p| (p, 1)).chain(r["inferredMembers"].as_array().into_iter().flatten().filter_map(|p| p["pin"].as_str()).map(|p| (p, 0))) {+            if let Some(q) = sc.pins.get(p) { list.push(json!([(q.x * 100.0).round() / 100.0, (q.y * 100.0).round() / 100.0, q.sides[0] as u8 + 2 * q.sides[1] as u8, seen, p])); }+        }+        pins.insert(name.to_string(), json!(list));+        let w = &r["weakestLink"];+        json!({"name": name, "level": r["level"], "pins": r["pins"], "seen": r["pinsSeen"], "single": r["singlePin"], "weakest": w["what"], "at": w["at"], "side": w["side"], "file": r["file"],+            "section": section_of.get(name), "disputes": r["disputes"].as_array().map(|a| a.len()).unwrap_or(0)})+    }).collect();+    let sm = &conf["summary"];+    let data = json!({"name": sc.name, "wmm": sc.wmm, "hmm": sc.hmm, "res": MAP_RES, "ladder": LADDER, "rungColors": RUNGS.iter().map(|s| s.color).collect::<Vec<_>>(), "netHues": NET_HUES,+        "ids": ids, "nets": nets, "pins": pins, "sections": sections.iter().map(|s| json!({"title": s["title"], "kind": s["kind"], "nets": s["nets"]})).collect::<Vec<_>>(),+        "unnamed": {"islands": sm["unnamedIslands"], "groups": sm["unnamed"]["group"]["names"], "conflicts": sm["unnamed"]["conflict"]["names"]}});+    // "</" inside a script would end it early+    let safe = |v: String| v.replace("</", "<\\/");+    let page = PAGE.replace("/*DATA*/null/*END*/", &safe(data.to_string())).replace("/*MAPS*/{}/*END*/", &safe(Value::Object(maps).to_string()));+    std::fs::write(path, page).map_err(|e| format!("{}: {e}", path.display()))+}++/// Record nets/view.html as a numbered view of `step`, the way `views::record` records the board. An unchanged page+/// reuses the file recorded before. Returns the hints to print.+pub fn record(flow: &Path, step: &str, thread: &str) -> Vec<String> {+    if crate::views::disabled() { return vec![]; }+    let src = flow.join("nets/view.html");+    if !src.is_file() { return vec![]; }+    let path = flow.join("views.json");+    let mut doc = crate::items::read_json(&path).unwrap_or(json!({"entries": []}));+    if !doc["entries"].is_array() { doc["entries"] = json!([]); }+    let n = doc["entries"].as_array().map(|a| a.len()).unwrap_or(0) + 1;+    let sha = crate::sha256(&src);+    let before = doc["entries"].as_array().into_iter().flatten().rev().flat_map(|e| e["views"].as_array().cloned().unwrap_or_default()).find(|v| v["kind"] == json!("nets") && v["sha"] == json!(sha)).and_then(|v| v["file"].as_str().map(str::to_string)).filter(|f| flow.join(f).is_file());+    let file = match before { Some(f) => f, None => {+        let f = format!("views/{n:03}-{step}-nets.html");+        if let Err(e) = std::fs::create_dir_all(flow.join("views")).and_then(|_| std::fs::copy(&src, flow.join(&f)).map(|_| ())) { return vec![format!("flow window: the nets view was not recorded: {e}")]; }+        f+    } };+    doc["entries"].as_array_mut().unwrap().push(json!({"n": n, "at": crate::ledger::now(), "step": step, "reason": "nets drawn", "aiThread": thread, "views": [{"kind": "nets", "file": file, "source": "nets/view.html", "sha": sha}]}));+    if let Err(e) = std::fs::write(&path, serde_json::to_string_pretty(&doc).unwrap() + "\n") { return vec![format!("flow window: views.json not written: {e}")]; }+    vec![format!("flow window: view {n} recorded for {step} (nets): a net list beside the board, by confidence or by net")]+}++#[cfg(test)]+mod tests {+    use super::*;++    #[test]+    fn base64_pads_the_last_group() {+        assert_eq!(base64(b""), ""); assert_eq!(base64(b"f"), "Zg=="); assert_eq!(base64(b"fo"), "Zm8="); assert_eq!(base64(b"foo"), "Zm9v"); assert_eq!(base64(b"foobar"), "Zm9vYmFy");+    }++    #[test]+    fn the_page_has_a_place_for_its_data_and_its_maps() {+        assert_eq!(PAGE.matches("/*DATA*/null/*END*/").count(), 1); assert_eq!(PAGE.matches("/*MAPS*/{}/*END*/").count(), 1);+    }++    #[test]+    fn a_nets_view_is_numbered_after_what_is_already_recorded_and_an_unchanged_page_is_not_copied_twice() {+        let d = std::env::temp_dir().join(format!("netview-test-{}", std::process::id()));+        std::fs::create_dir_all(d.join("nets")).unwrap();+        std::fs::write(d.join("nets/view.html"), "<html>one</html>").unwrap();+        std::fs::write(d.join("views.json"), r#"{"entries": [{"n": 1, "step": "parts", "views": [{"kind": "photos"}]}]}"#).unwrap();+        let h = record(&d, "report", "t");+        assert!(h[0].contains("view 2 recorded for report (nets)"), "{h:?}");+        assert!(d.join("views/002-report-nets.html").is_file());+        record(&d, "report", "t");+        let doc = crate::items::read_json(&d.join("views.json")).unwrap();+        let e = doc["entries"].as_array().unwrap();+        assert_eq!(e.len(), 3); assert_eq!(e[2]["views"][0]["file"], "views/002-report-nets.html", "the same page is not copied again");+        std::fs::write(d.join("nets/view.html"), "<html>two</html>").unwrap();+        record(&d, "report", "t");+        assert!(d.join("views/004-report-nets.html").is_file());+        std::fs::remove_dir_all(&d).ok();+    }+}
src/notread.rsadded+252
@@ -0,0 +1,252 @@+//! A face whose copper the reader got wrong, and the honest way on.+//!+//! `check copper` judges the traced copper as a whole (`copperchecks`): one island holding two thirds of a face's pins, a face that+//! is all copper, levels that miss the photo's edges. Nothing a runner may supply changes such a reading: `copper trace` takes no+//! setting, and nobody edits copper by hand. Until 2026-11-11 the run stopped there for good, and never wrote its review, tests,+//! report, bench plan or video. Now the check records that face's copper as NOT READ, with its own message, in+//! `checks/copper.ok` (`summary.notRead`), and the run goes on with the failure carried everywhere a reader would look:+//!+//! - `connect attribute` takes no join from that face, so no net is claimed through it;+//! - the copper and connectivity scores count the face as failed, and no audit sample is drawn on it;+//! - the review lists it as a finding, `tests generate` writes a test for it, and the report, the net pictures, the schematic+//!   and the video say it in plain words.+//!+//! The record lives in the check's own file, so it goes stale with the copper: a new trace is judged afresh.+//!+//! Since copper round 3 the reader gives up on such a face itself at trace time (`faceread`: its islands are taken out, and the+//! accounting carries `unread` with why). That is the same thing found earlier, and it is carried the same way: `declared` reads+//! it, `check copper` writes it into the same record, and every sentence is the same. What only the check finds (one island with+//! most of a large part's pins) still comes through `split`. A face no photo shows at all is `missing`: NOT PHOTOGRAPHED.+use crate::{flow, items};+use serde_json::{json, Value};+use std::path::Path;++pub const SIDES: [(&str, &str); 2] = [("top", "F.Cu"), ("bottom", "B.Cu")];++/// `photographed` is false for a face no photo in the run shows: nothing was read there because there was nothing to read.+#[derive(Clone, Debug, PartialEq)]+pub struct Face { pub side: String, pub layer: String, pub messages: Vec<String>, pub photographed: bool }++/// The side a whole-face reader fault names: `copperchecks` starts each of its sentences with "<side> face:".+fn side_of(message: &str) -> Option<(&'static str, &'static str)> { SIDES.iter().copied().find(|(s, _)| message.starts_with(&format!("{s} face:"))) }++/// Split what `copperchecks` found into faces not read, one per side with every sentence about it. A sentence that names no face+/// stays an error: it is not this kind of fault.+pub fn split(errs: Vec<String>) -> (Vec<Face>, Vec<String>) {+    let (mut faces, mut rest): (Vec<Face>, Vec<String>) = (vec![], vec![]);+    for e in errs {+        match side_of(&e) {+            Some((side, layer)) => match faces.iter_mut().find(|f| f.side == side) { Some(f) => f.messages.push(e), None => faces.push(Face { side: side.into(), layer: layer.into(), messages: vec![e], photographed: true }) },+            None => rest.push(e),+        }+    }+    faces.sort_by_key(|f| SIDES.iter().position(|s| s.0 == f.side));+    (faces, rest)+}++pub fn to_json(faces: &[Face]) -> Value { json!(faces.iter().map(|f| json!({"side": f.side, "layer": f.layer, "messages": f.messages})).collect::<Vec<_>>()) }++fn from_json(v: &Value) -> Vec<Face> {+    v.as_array().into_iter().flatten().filter_map(|f| Some(Face { side: f["side"].as_str()?.to_string(), layer: f["layer"].as_str()?.to_string(), messages: f["messages"].as_array().into_iter().flatten().filter_map(|m| m.as_str().map(str::to_string)).collect(), photographed: true })).collect()+}++/// The faces `check copper` recorded as not read, while that check still stands for the copper as traced.+pub fn recorded(flow_dir: &Path) -> Vec<Face> {+    if !flow::exists(flow_dir, "checks/copper.ok") { return vec![]; }+    items::read_json(&flow_dir.join("checks/copper.ok")).map(|v| from_json(&v["summary"]["notRead"])).unwrap_or_default()+}++/// A face no photo shows: the frame is made (`scale` wrote the board image of one face) and the other face has no image. Board 11+/// of the 2026-11-10 set: the stitcher could not join the two photos of its other face, and the run died at `connect measure`+/// with "board/bottom.jpg is missing". Such a run finishes on the face it has, with the other recorded as NOT PHOTOGRAPHED.+pub fn missing(flow_dir: &Path) -> Vec<Face> {+    let has = |s: &str| flow_dir.join(format!("board/{s}.jpg")).is_file();+    if !flow_dir.join("scale.json").is_file() || !SIDES.iter().any(|(s, _)| has(s)) { return vec![]; }+    SIDES.iter().filter(|(s, _)| !has(s)).map(|(s, l)| Face { side: s.to_string(), layer: l.to_string(), photographed: false,+        messages: vec![format!("{s} face: no photo of this face is in the run (the photos given show one face, or the photos of the other could not be joined into it; stitch.json says which were left out)")] }).collect()+}++/// The faces the reader itself gave up on at trace time (`faceread`): it took their islands out and wrote why into the+/// accounting (`detect/copper-island-accounting.json` perFace[].unread). The same thing as a face `check copper` fails, found+/// earlier: one concept, one record, one set of words.+pub fn declared(flow_dir: &Path) -> Vec<Face> {+    let acc = items::read_json(&flow_dir.join("detect/copper-island-accounting.json")).unwrap_or(Value::Null);+    acc["perFace"].as_array().into_iter().flatten().filter_map(|f| {+        let (side, layer) = SIDES.iter().copied().find(|(s, _)| f["side"].as_str() == Some(s))?;+        Some(Face { side: side.into(), layer: layer.into(), messages: vec![format!("{side} face: {}", f["unread"].as_str()?)], photographed: true })+    }).collect()+}++/// Every face whose copper the run does not have: not photographed, given up on by the reader, or read and failed at the check.+pub fn faces(flow_dir: &Path) -> Vec<Face> {+    let mut v = missing(flow_dir);+    for f in declared(flow_dir).into_iter().chain(recorded(flow_dir)) { if !v.iter().any(|x| x.side == f.side) { v.push(f); } }+    v.sort_by_key(|f| SIDES.iter().position(|s| s.0 == f.side));+    v+}++/// What attribution was run against: "" when every face was read, else the sides, in order.+pub fn key(flow_dir: &Path) -> String { faces(flow_dir).iter().map(|f| f.side.clone()).collect::<Vec<_>>().join(",") }++pub fn skips(faces: &[Face], side: &str) -> bool { faces.iter().any(|f| f.side == side) }+pub fn skips_layer(faces: &[Face], layer: &str) -> bool { faces.iter().any(|f| f.layer == layer) }++/// The id tests and findings use for a face.+pub fn item_id(side: &str) -> String { format!("face:{side}") }+pub fn test_id(side: &str) -> String { format!("T-copper-not-read-{side}") }++/// One plain sentence per face, the same words in the check, the review, the report, the pictures and the video.+pub fn sentence(f: &Face) -> String {+    if !f.photographed { return format!("The {} face was NOT PHOTOGRAPHED: this run read one face only. No part, pour, track or net of the {} face is in it, no via is claimed (a via needs both faces to show it), and no join between the faces is claimed.", f.side, f.side); }+    format!("The copper of the {} face was NOT READ, so no pour, track, join or net on this face is claimed: its pins join nothing there, and their nets must come from the other face, a datasheet or the bench. Why: {}", f.side, f.messages.join(" "))+}++/// The short form, for a picture's banner and the video's caption.+pub fn short(f: &Face) -> String {+    if !f.photographed { return format!("{} face: NOT PHOTOGRAPHED; this run read one face only", f.side.to_uppercase()); }+    format!("{} face: copper NOT READ; nothing on this face is claimed", f.side.to_uppercase())+}++pub fn sentences(flow_dir: &Path) -> Vec<String> { faces(flow_dir).iter().map(sentence).collect() }++/// The line `check copper` answers with when it records a failed reading, or `None` when every face was read.+pub fn headline(summary: &Value) -> Option<String> {+    let f = from_json(&summary["notRead"]);+    if f.is_empty() { return None; }+    let sides: Vec<String> = f.iter().map(|x| x.side.clone()).collect();+    Some(format!("check copper: the copper of the {} face is NOT READ, which is recorded as a failure, not passed. Only a fix to the reader or a better photo can change that, so the run goes on with the failure carried: no net is claimed through that face, the copper and connectivity scores count it as failed, and the review, tests, report, net pictures and video say so", sides.join(" and the ")))+}++/// The test for each face not read: what a person with the board, or a fixed reader, would do to settle it.+pub fn tests(flow_dir: &Path) -> Vec<Value> {+    faces(flow_dir).iter().map(|f| if !f.photographed { json!({+        "id": test_id(&f.side), "settles": [item_id(&f.side)], "generated": true,+        "method": format!("The {} face was not photographed. Photograph it as the other face was (close-up tiles that overlap by a third, and one photo of the whole face), add the photos to the run and start again from `init --photos`, so both faces are read and the vias between them can be found", f.side),+        "equipment": "camera with even light",+        "expected": format!("`stitch` builds both faces, and `copper trace` and `check copper` pass on the {} face", f.side),+        "ifNot": "the run stays a one-face run: parts, copper and nets of the other face, every via and every join between the faces come only from the bench",+    }) } else { json!({+        "id": test_id(&f.side), "settles": [item_id(&f.side)], "generated": true,+        "method": format!("The copper of the {} face was not read from the photos ({}). Photograph that face again, square on, evenly lit and filling the frame, and run `copper trace` again, on a build whose reader reads it and passes `check copper`; or, with the board in hand, trace each track of that face with a continuity meter from pad to pad", f.side, f.messages.first().map(String::as_str).unwrap_or("")),+        "equipment": "camera with even light, or DMM in continuity mode with fine probes",+        "expected": format!("`check copper` passes on the {} face with no island holding most of its pins, and each pad joins only the pads its track reaches", f.side),+        "ifNot": "the face stays NOT READ: nothing on it may be claimed, and its nets come only from the bench",+    }) }).collect()+}++/// The ceiling both the copper and the connectivity score carry while a face is not read: the share of photographed faces whose+/// copper passed. One of two faces failed is 50, both is 0.+pub fn ceiling(flow_dir: &Path) -> Option<(f64, String)> {+    let f = faces(flow_dir);+    if f.is_empty() { return None; }+    let read = SIDES.len().saturating_sub(f.len());+    Some((100.0 * read as f64 / SIDES.len() as f64, format!("{read} of the board's {} faces have copper that was read and passed `check copper`; without: {}", SIDES.len(), f.iter().map(|x| format!("{} ({})", x.side, if x.photographed { x.messages.first().map(String::as_str).unwrap_or("") } else { "not photographed" })).collect::<Vec<_>>().join("; "))))+}++pub const CEILING: &str = "faces whose copper was read";+pub const RAISE: &str = "only a fix to the copper reader, or a photo (or a better photo) of that face, raises this: then `copper trace` and `check copper` again. Nothing a runner may type changes it";++/// The section the report tables carry, and that report.md must keep word for word (`required`).+pub fn report_section(flow_dir: &Path) -> String {+    let f = faces(flow_dir);+    if f.is_empty() { return String::new(); }+    let mut md = if f.iter().all(|x| !x.photographed) { "## A face not photographed\n\n" } else { "## Copper not read\n\n" }.to_string();+    for x in &f { md += &format!("- {}\n", required(x)); }+    md += "\nWhat follows from it, everywhere in this report: no net is claimed through that face, the copper and connectivity scores count the face as failed, and no via or join between the faces rests on it. Where a face was read and failed, its copper is left out of the net pictures and marked there, and the KiCad copy still holds what the reader drew on that face, which is not to be trusted. ";+    md += &format!("Tests: {}.\n\n", f.iter().map(|x| test_id(&x.side)).collect::<Vec<_>>().join(", "));+    for x in &f { for m in &x.messages { md += &format!("> {m}\n\n"); } }+    md+}++/// The sentence report.md must carry for a face, word for word.+pub fn required(f: &Face) -> String {+    if !f.photographed { return format!("The {} face was NOT PHOTOGRAPHED: this run read one face only, and nothing on the {} face, no via and no join between the faces is claimed.", f.side, f.side); }+    format!("The copper of the {} face was NOT READ, and nothing on that face is claimed.", f.side)+}++#[cfg(test)]+mod tests_notread {+    use super::*;++    fn dir(name: &str) -> std::path::PathBuf {+        let d = std::env::temp_dir().join(format!("notread-{name}-{}", std::process::id())).join("reverse-flow");+        let _ = std::fs::remove_dir_all(&d);+        std::fs::create_dir_all(d.join("checks")).unwrap(); std::fs::create_dir_all(d.join("board")).unwrap();+        d+    }++    #[test]+    fn a_whole_face_fault_becomes_a_face_not_read_and_anything_else_stays_an_error() {+        let (f, rest) = split(vec![+            "bottom face: island 2 holds 117 of the 148 pins joined on this face (79 percent). No one conductor holds two thirds".into(),+            "zone z9: no polygon_mm: sketch it".into(),+            "bottom face: island 2 joins 20 of the 24 pins of J2.".into(),+            "top face: 97 percent of what can be seen read as copper.".into()]);+        assert_eq!(f.len(), 2);+        assert_eq!((f[0].side.as_str(), f[0].layer.as_str(), f[0].messages.len()), ("top", "F.Cu", 1));+        assert_eq!((f[1].side.as_str(), f[1].layer.as_str(), f[1].messages.len()), ("bottom", "B.Cu", 2));+        assert_eq!(rest, vec!["zone z9: no polygon_mm: sketch it".to_string()]);+        assert!(headline(&json!({"notRead": to_json(&f)})).unwrap().contains("top and the bottom face"));+        assert!(headline(&json!({"zones": 3})).is_none());+    }++    #[test]+    fn the_record_lives_in_the_check_and_goes_stale_with_the_copper() {+        let d = dir("record");+        std::fs::write(d.join("copper.json"), json!({"zones": [{"id": "z", "polygon_mm": [[0, 0], [1, 0], [1, 1]]}]}).to_string()).unwrap();+        for s in ["top", "bottom"] { std::fs::write(d.join(format!("board/{s}.jpg")), "x").unwrap(); }+        std::fs::write(d.join("scale.json"), "{}").unwrap();+        assert!(faces(&d).is_empty() && key(&d).is_empty() && ceiling(&d).is_none() && report_section(&d).is_empty() && tests(&d).is_empty());+        let (f, _) = split(vec!["bottom face: island 9 holds 35 of the 43 pins joined on this face (81 percent).".into()]);+        let g = crate::attrib::geometry_hash(&d.join("copper.json"));+        std::fs::write(d.join("checks/copper.ok"), json!({"check": "copper", "files": {"copper.json": g}, "summary": {"notRead": to_json(&f)}}).to_string()).unwrap();+        assert_eq!(faces(&d), f);+        assert_eq!(key(&d), "bottom");+        assert!(skips(&faces(&d), "bottom") && !skips(&faces(&d), "top") && skips_layer(&faces(&d), "B.Cu"));+        // one of two faces failed: both scores carry 50, with the check's own words+        let (v, why) = ceiling(&d).unwrap();+        assert_eq!(v, 50.0);+        assert!(why.contains("1 of the board's 2 faces") && why.contains("island 9 holds 35 of the 43"), "{why}");+        // the test and the report name the face and settle an id `check tests` knows+        let t = tests(&d);+        assert_eq!(t[0]["id"], "T-copper-not-read-bottom"); assert_eq!(t[0]["settles"][0], "face:bottom");+        let md = report_section(&d);+        assert!(md.contains(&required(&f[0])) && md.contains("T-copper-not-read-bottom") && md.contains("island 9 holds 35 of the 43"));+        assert!(sentence(&f[0]).contains("NOT READ") && short(&f[0]).starts_with("BOTTOM face"));+        // the copper traced again: the record no longer stands, and the new copper is judged afresh+        std::fs::write(d.join("copper.json"), json!({"zones": [{"id": "z", "polygon_mm": [[0, 0], [2, 0], [1, 1]]}]}).to_string()).unwrap();+        assert!(faces(&d).is_empty());+        // the reader gave the face up itself at trace time (`faceread`): the same face, the same words, before any check has run+        std::fs::create_dir_all(d.join("detect")).unwrap();+        std::fs::write(d.join("detect/copper-island-accounting.json"), json!({"perFace": [{"side": "top", "copperShare": 0.4}, {"side": "bottom", "unread": "island 9 held 35 of the 43 pins joined on this face (81 percent)"}]}).to_string()).unwrap();+        let g = faces(&d);+        assert_eq!(g.len(), 1);+        assert_eq!((g[0].side.as_str(), g[0].photographed), ("bottom", true));+        assert_eq!(g[0].messages, vec!["bottom face: island 9 held 35 of the 43 pins joined on this face (81 percent)".to_string()]);+        assert_eq!(required(&g[0]), required(&f[0]), "one concept: the report's sentence is the same whoever found the face unreadable");+        assert_eq!(ceiling(&d).unwrap().0, 50.0);+        std::fs::remove_dir_all(d.parent().unwrap()).ok();+    }++    #[test]+    fn a_face_with_no_photo_is_recorded_as_not_photographed_once_the_frame_is_made() {+        let d = dir("onefaced");+        std::fs::write(d.join("board/top.jpg"), "x").unwrap();+        // before the frame exists nothing is said: the photos are not sorted yet+        assert!(faces(&d).is_empty());+        std::fs::write(d.join("scale.json"), "{}").unwrap();+        let f = faces(&d);+        assert_eq!(f.len(), 1);+        assert_eq!((f[0].side.as_str(), f[0].layer.as_str(), f[0].photographed), ("bottom", "B.Cu", false));+        assert_eq!(key(&d), "bottom");+        assert_eq!(ceiling(&d).unwrap().0, 50.0);+        assert!(ceiling(&d).unwrap().1.contains("bottom (not photographed)"));+        assert!(sentence(&f[0]).contains("NOT PHOTOGRAPHED") && sentence(&f[0]).contains("no via is claimed") && !sentence(&f[0]).contains("NOT READ"));+        assert!(required(&f[0]).contains("NOT PHOTOGRAPHED") && report_section(&d).starts_with("## A face not photographed"));+        let t = tests(&d);+        assert_eq!(t[0]["settles"][0], "face:bottom"); assert!(t[0]["method"].as_str().unwrap().contains("not photographed"));+        // it is not something `check copper` recorded, so that check's answer does not name it+        assert!(recorded(&d).is_empty());+        std::fs::remove_dir_all(d.parent().unwrap()).ok();+    }+}
src/outline.rsadded+114
@@ -0,0 +1,114 @@+//! A tight board outline. `prep::board_corners` takes every pixel of the green family as board, and two things outside a board pass that+//! test: the grain of a dark shadow under the board's edge (value 0.07 to 0.12, any hue) and the grain of a blue table (hue 190 to 200).+//! Closed into a solid strip they moved an edge out by 0.9 mm on a 38 mm board and by 2.3 mm on a 101 mm one. Here the mask is this photo's+//! own hue and its own darkest value, and a pixel counts only where most of its neighbours count too.+use crate::geom::P;+use crate::prep;+use crate::raster::{self, Mask, Plane};+use image::{imageops, RgbImage};++/// How far from the peak of the mask's hue a pixel may lie, degrees. Pours and bare laminate of one board lay within 20 degrees of the peak on the+/// USB hub and MOSFET board photos of 2026-11-10 (peaks 137 to 148); the blue table's grain began 43 degrees away.+pub const HUE_REACH: f32 = 30.0;+/// The floor on value, as a share of the 2nd percentile of the mask's own value. On those photos the mask's 2nd percentile was 0.14 to 0.36+/// and the shadow's grain reached 0.12 at its 99th percentile. Never under the 0.07 the wide test uses, so a dark board reads as before.+pub const VALUE_FLOOR_SHARE: f32 = 0.8;+/// A pixel is board where more than this share of the 9 x 9 pixels round it (at a quarter of the photo's size) pass: grain passes at 0.2 to 0.5, mask at 1.+pub const DENSITY: f32 = 0.6;++/// The board mask at 1/`k` of the photo's size, and the value floor used.+pub fn board_mask(img: &RgbImage, k: u32) -> (Mask, f32) {+    let small = imageops::resize(img, img.width() / k, img.height() / k, imageops::FilterType::Triangle);+    let tone = prep::mask_tone(img);+    let near = |h: f32| (h - tone.hue).abs() < HUE_REACH;+    let mut vs: Vec<f32> = small.pixels().filter_map(|p| { let (h, s, v) = raster::hsv(p); (near(h) && s > 0.18 && v > 0.07 && v < tone.v_hi).then_some(v) }).collect();+    vs.sort_by(|a, b| a.partial_cmp(b).unwrap());+    let p02 = if vs.is_empty() { 0.0 } else { vs[(vs.len() - 1) / 50] };+    let floor = (VALUE_FLOOR_SHARE * p02).max(0.07);+    let raw = raster::mask_from(&small, |p| { let (h, s, v) = raster::hsv(p); near(h) && s > 0.18 && v > floor && v < tone.v_hi });+    let dens = raster::blur(&Plane { w: raw.w, h: raw.h, d: raw.d.iter().map(|b| if *b { 1.0 } else { 0.0 }).collect() }, 4, 1);+    (Mask { w: raw.w, h: raw.h, d: dens.d.iter().map(|v| *v > DENSITY).collect() }.close(3.0), floor)+}++/// The four outer corners (TL, TR, BR, BL) of the board in photo pixels, from the tight mask.+pub fn corners(img: &RgbImage) -> Result<[P; 4], String> {+    let k = 4u32;+    prep::corners_of_mask(&board_mask(img, k).0, k)+}++/// For each edge of an outline (corner i to the next), how much solder mask lies beyond it, as a share of the outline's own area. A true edge has the table beyond it;+/// an edge fitted along parts that hide the board (a row of terminal blocks) has more board beyond it, between and round those parts.+pub fn mask_beyond(img: &RgbImage, c: &[P; 4]) -> [f64; 4] {+    let k = 4u32;+    let (m, _) = board_mask(img, k);+    let mid = ((c[0].0 + c[1].0 + c[2].0 + c[3].0) / 4.0, (c[0].1 + c[1].1 + c[2].1 + c[3].1) / 4.0);+    let area = 0.5 * ((c[2].0 - c[0].0) * (c[3].1 - c[1].1) - (c[3].0 - c[1].0) * (c[2].1 - c[0].1)).abs();+    let mut n = [0f64; 4];+    for y in 0..m.h { for x in 0..m.w { if !m.d[y * m.w + x] { continue; }+        let p = ((x as f64 + 0.5) * k as f64, (y as f64 + 0.5) * k as f64);+        let mut worst: Option<(usize, f64)> = None;+        for e in 0..4 { let (a, b) = (c[e], c[(e + 1) % 4]);+            let side = |q: P| ((b.0 - a.0) * (q.1 - a.1) - (b.1 - a.1) * (q.0 - a.0)) / crate::geom::dist(a, b).max(1e-9);+            let d = side(p) * -side(mid).signum();+            if d > BEYOND_PX && worst.map_or(true, |w| d > w.1) { worst = Some((e, d)); } }+        if let Some((e, _)) = worst { n[e] += (k * k) as f64; }+    } }+    n.map(|v| v / area.max(1.0))+}+/// The sides of an outline (top, right, bottom, left) that lie along the photo's own edge: the board runs out of the picture there, so that side is where the+/// photo stops and not where the board does. Both ends of the side within `CUT_PX` of one border of the photo.+pub fn cut_sides(c: &[P; 4], w: f64, h: f64) -> Vec<&'static str> {+    let at = |p: P| [p.1 < CUT_PX, p.0 > w - CUT_PX, p.1 > h - CUT_PX, p.0 < CUT_PX];+    ["top", "right", "bottom", "left"].into_iter().enumerate().filter(|(e, _)| { let (a, b) = (at(c[*e]), at(c[(*e + 1) % 4])); a[*e] && b[*e] }).map(|x| x.1).collect()+}+/// How near the photo's border an outline's side must run to be the photo's edge, photo pixels: the mask is made at a quarter of the photo's size and closed by 3 of those pixels.+pub const CUT_PX: f64 = 16.0;++/// Mask counts as beyond an edge from this far out, photo pixels: about 0.5 mm at the 65 to 77 px/mm of the 2026-11-10 sets, past a rounded corner and a ragged edge.+pub const BEYOND_PX: f64 = 32.0;+/// An edge is not the board's when more than this share of the outline's area is mask beyond it. Terminal blocks over one edge left 7 percent beyond the fitted line; true edges under 0.1.+pub const OPEN_EDGE_SHARE: f64 = 0.01;++#[cfg(test)]+mod tests {+    use super::*;+    use image::Rgb;++    #[test]+    fn a_shadow_and_a_blue_table_are_not_board() {+        // a board 100..700 x 80..500 on a blue table whose grain passes the wide green test in places, and a dark grainy shadow 20 px deep under its lower edge+        let mut seed = 4242u64; let mut next = move || { seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); (seed >> 33) % 100 };+        let img = RgbImage::from_fn(800, 600, |x, y| {+            let r = next();+            if (100..700).contains(&x) && (80..500).contains(&y) { Rgb([40, 150, 90]) }+            else if (90..710).contains(&x) && (500..520).contains(&y) { if r < 45 { Rgb([8, 26, 20]) } else { Rgb([6, 8, 14]) } }+            else if r < 30 { Rgb([40, 190, 200]) } else { Rgb([40, 110, 200]) }+        });+        let wide = prep::board_corners(&img).unwrap();+        let tight = corners(&img).unwrap();+        assert!(wide[2].1 > 512.0, "the wide test should take the shadow: {wide:?}");+        for (c, t) in tight.iter().zip([(100.0, 80.0), (700.0, 80.0), (700.0, 500.0), (100.0, 500.0)]) { assert!(crate::geom::dist(*c, t) < 6.0, "{c:?} vs {t:?}"); }+    }++    #[test]+    fn a_board_that_runs_out_of_the_photo_has_cut_sides() {+        // the board fills the photo from left to right; its upper and lower edges are in the picture+        let mut img = RgbImage::from_pixel(800, 600, Rgb([40, 110, 200]));+        for y in 100..500 { for x in 0..800 { img.put_pixel(x, y, Rgb([40, 150, 90])); } }+        let c = corners(&img).unwrap();+        assert_eq!(cut_sides(&c, 800.0, 600.0), vec!["right", "left"], "{c:?}");+        let whole = [(100.0, 80.0), (700.0, 80.0), (700.0, 500.0), (100.0, 500.0)];+        assert!(cut_sides(&whole, 800.0, 600.0).is_empty());+    }++    #[test]+    fn a_dark_board_keeps_its_outline() {+        // the Driver's exposure: mask value 0.2, where the floor stays at 0.07+        let mut img = RgbImage::from_pixel(800, 600, Rgb([110, 60, 30]));+        for y in 80..520 { for x in 100..700 { img.put_pixel(x, y, Rgb([14, 52, 36])); } }+        let (_, floor) = board_mask(&img, 4);+        assert!(floor < 0.17, "{floor}");+        let c = corners(&img).unwrap();+        assert!(crate::geom::dist(c[0], (100.0, 80.0)) < 6.0 && crate::geom::dist(c[2], (700.0, 520.0)) < 6.0, "{c:?}");+    }+}
src/padjoin.rsadded+260
@@ -0,0 +1,260 @@+//! A pad, its solder and the copper that leaves it are one conductor.+//!+//! The copper reader sees mask-covered copper. A pad is exposed metal under a part's end, with a fillet, a glint and often the part's shadow beside it, none of which+//! is read, so the copper stopped a few pixels short of every pad and a pad joined its own track only when a ring test round it happened to pass: on the USB hub+//! board's top 63 pins joined anything, and of 31 joins labelled by eye from the photo 9 were reproduced (scratch sample of 2026-11-10, 40 pads on an 8 x 5 grid).+//!+//! Here each pad is a seed: the footprint's pad as placed, or, on a face with no parts placed yet, a blob of exposed metal. Every seed grows outward through what+//! the photo cannot show (metal, fillet, glint, shadow, print) and stops at anything it can show to be bare laminate or a gap line. The islands it meets within+//! `REACH_PX` are the pad's copper: they are joined to each other and to the pad, and the pad's own area and the way to each island take the island's label. A pixel+//! belongs to the nearest seed, so a pin never reaches round its neighbour.+use crate::raster::Mask;+use std::collections::{HashMap, VecDeque};++/// How far from a pad's metal its copper may begin, frame pixels at 40 px/mm: 0.3 mm. On the hub board's top (development half of the labelled pads) the copper+/// read for a pad's track began 4 to 11 px from the footprint's pad: the reader's 3 px halo round bright metal, the fillet, and the part's shadow on one side.+pub const REACH_PX: u16 = 12;+/// The way from a pad to its copper is drawn this wide to each side, frame pixels: 3 px across, enough to be one piece with both ends. It crosses pixels the photo+/// does not show, so it is kept thin: drawn 5 px across it cost the CM5 0.003 and 0.007 of its copper score against the photo.+const PATH_HALF: i64 = 1;+/// On a face with no parts placed, a blob of exposed metal larger than this is not one pad: 9 mm2, a 3 mm square lug.+const MAX_BLOB_PX: usize = 14400;++/// When both ends of a part reach one island, the nearer end keeps it if the other is this much further off, frame pixels: 0.15 mm, the least clearance a board+/// house makes, so an island the far end only reaches across unseen pixels of that width is not its copper.+const NEARER_BY: u16 = 6;++/// A pad lies on an island it meets within this many pixels of its own metal: 2, the rim the reader leaves round bright metal less a pixel.+const ON_PX: u16 = 2;++/// A pad as placed, in frame pixels.+#[derive(Clone, Debug)]+pub struct Seed { pub cx: f64, pub cy: f64, pub hw: f64, pub hh: f64, pub rot_deg: f64, pub round: bool, /// the part this pad belongs to, any number that is the same for its pads+    pub part: u32 }++impl Seed {+    fn inside(&self, x: f64, y: f64) -> bool {+        let (dx, dy) = (x - self.cx, y - self.cy); let (c, s) = (self.rot_deg.to_radians().cos(), self.rot_deg.to_radians().sin());+        let (lx, ly) = (dx * c - dy * s, dx * s + dy * c);+        if self.round { (lx / self.hw.max(0.5)).powi(2) + (ly / self.hh.max(0.5)).powi(2) <= 1.0 } else { lx.abs() <= self.hw && ly.abs() <= self.hh }+    }+}++/// Seeds from pads given as (part, centre x, centre y, width, height, rotation in degrees, round), in frame pixels.+pub fn seeds_from<'a>(pads: impl Iterator<Item = (&'a str, f64, f64, f64, f64, f64, bool)>) -> Vec<Seed> {+    let mut ids: HashMap<String, u32> = HashMap::new();+    pads.map(|(part, cx, cy, w, h, rot_deg, round)| { let n = ids.len() as u32; Seed { cx, cy, hw: w / 2.0, hh: h / 2.0, rot_deg, round, part: *ids.entry(part.to_string()).or_insert(n) } }).collect()+}++/// Each pixel's seed, 1-based, 0 for none. Where two pads overlap the first keeps the pixel.+pub fn seed_map(seeds: &[Seed], w: usize, h: usize) -> Vec<u32> {+    let mut m = vec![0u32; w * h];+    for (k, s) in seeds.iter().enumerate() {+        let r = s.hw.hypot(s.hh).ceil() as i64 + 1;+        for y in (s.cy as i64 - r).max(0)..=(s.cy as i64 + r).min(h as i64 - 1) { for x in (s.cx as i64 - r).max(0)..=(s.cx as i64 + r).min(w as i64 - 1) {+            let i = y as usize * w + x as usize; if m[i] == 0 && s.inside(x as f64 + 0.5, y as f64 + 0.5) { m[i] = k as u32 + 1; } } }+    }+    m+}++/// Seeds from the photo alone: each blob of exposed metal no larger than a pad.+pub fn metal_seeds(metal: &Mask) -> Vec<u32> {+    let (lab, cs) = crate::raster::components(metal);+    let ok: Vec<bool> = std::iter::once(false).chain(cs.iter().map(|c| c.area >= 12 && c.area <= MAX_BLOB_PX)).collect();+    lab.iter().map(|l| if ok[*l as usize] { *l } else { 0 }).collect()+}++pub struct Joined { pub pads_joined: usize, pub islands_merged: usize, pub painted: Mask }++/// For each seed (1-based, entry 0 unused) its part and how many pads that part has: (part + 1, pads), (0, 0) for entry 0.+pub fn mates(seeds: &[Seed]) -> Vec<(u32, u32)> {+    let mut n: HashMap<u32, u32> = HashMap::new();+    for s in seeds { *n.entry(s.part).or_default() += 1; }+    std::iter::once((0, 0)).chain(seeds.iter().map(|s| (s.part + 1, n[&s.part]))).collect()+}++/// Join every seed to the islands it reaches. `blocked`: what the photo shows to be no copper (seen laminate, gap lines). `body`: what hides the board (parts, metal).+/// `mate`: from `mates`, or empty when the seeds are blobs of metal and nothing says which belong to one part.+///+/// Two pads of one part that reach the same island are looked at together. On a chip, a track that bends past the next pin lies within reach of both, and joined+/// to both it shorts them (four pins of the hub chip's two USB pairs on the first try): the nearer pad keeps it. The two ends of a two-pad part are never one conductor: the part would be shorted. A strip of board beside a small part's body lies within reach of both its pads,+/// and joined to both it shorted 32 of the hub board's capacitors and resistors on the first try. So an island that both pads of a two-pad part reach from much the same distance joins neither.+/// Without footprints, two blobs with nothing but part between them (a straight line from one to the other that never leaves `body`) are taken for the ends of one part.+pub fn join(labels: &mut [u32], seed: &[u32], blocked: &Mask, body: &Mask, mate: &[(u32, u32)], w: usize, h: usize) -> Joined {+    let n = w * h;+    let mut owner = vec![0u32; n]; let mut depth = vec![u16::MAX; n]; let mut parent = vec![u32::MAX; n];+    let mut q: VecDeque<usize> = VecDeque::new();+    // (seed, island) -> the pixel of the seed's region that touched it, nearest first+    let mut touch: HashMap<(u32, u32), usize> = HashMap::new();+    for i in 0..n { if seed[i] != 0 { owner[i] = seed[i]; depth[i] = 0; q.push_back(i); if labels[i] != 0 { touch.entry((seed[i], labels[i])).or_insert(i); } } }+    while let Some(i) = q.pop_front() {+        let d = depth[i]; let (x, y) = (i % w, i / w);+        // copper already read is where the growth ends; inside the pad itself it goes on, so a pad that lies on its pour is still walked out of+        if labels[i] != 0 && seed[i] == 0 { continue; }+        if d >= REACH_PX { continue; }+        for j in [if x > 0 { i - 1 } else { i }, if x + 1 < w { i + 1 } else { i }, if y > 0 { i - w } else { i }, if y + 1 < h { i + w } else { i }] {+            if j == i || owner[j] != 0 { continue; }+            if blocked.d[j] && labels[j] == 0 { continue; }+            owner[j] = owner[i]; depth[j] = d + 1; parent[j] = i as u32;+            if labels[j] != 0 { touch.entry((owner[i], labels[j])).or_insert(j); }+            q.push_back(j);+        }+    }+    // an island both ends of one part reach joins neither+    { let mut by_island: HashMap<u32, Vec<u32>> = HashMap::new(); for (s, l) in touch.keys() { by_island.entry(*l).or_default().push(*s); }+      let mut cen: HashMap<u32, (f64, f64, f64)> = HashMap::new();+      if mate.is_empty() { for i in 0..n { if seed[i] != 0 { let e = cen.entry(seed[i]).or_insert((0.0, 0.0, 0.0)); e.0 += (i % w) as f64; e.1 += (i / w) as f64; e.2 += 1.0; } } }+      let one_part = |a: u32, b: u32| -> bool {+          if !mate.is_empty() { let (pa, pb) = (mate.get(a as usize).copied().unwrap_or((0, 0)), mate.get(b as usize).copied().unwrap_or((0, 0))); return pa.0 != 0 && pa.0 == pb.0; }+          let (p, q) = (cen[&a], cen[&b]); let (p, q) = ((p.0 / p.2, p.1 / p.2), (q.0 / q.2, q.1 / q.2));+          let len = ((q.0 - p.0).powi(2) + (q.1 - p.1).powi(2)).sqrt(); if len > 200.0 { return false; }+          let steps = len.ceil().max(1.0) as usize;+          (0..=steps).all(|k| { let t = k as f64 / steps as f64; let i = (p.1 + (q.1 - p.1) * t) as usize * w + (p.0 + (q.0 - p.0) * t) as usize; seed[i] != 0 || (body.d[i] && labels[i] == 0) }) };+      // ...unless one end lies on it and the other only comes near: a pad on its pour, with the pour passing the part's other end at a clearance the photo cannot show+      let mut drop: Vec<(u32, u32)> = vec![];+      for (l, ss) in &by_island { for (k, a) in ss.iter().enumerate() { for b in &ss[k + 1..] { if !one_part(*a, *b) { continue; }+          let (da, db) = (depth[touch[&(*a, *l)]], depth[touch[&(*b, *l)]]);+          // a chip may have two pins on one pour, each lying on it: both keep it. Two pins that both only come near one track cannot both be its pin.+          let many = mate.get(*a as usize).map(|m| m.1 > 2).unwrap_or(false);+          if da + NEARER_BY <= db { drop.push((*b, *l)); } else if db + NEARER_BY <= da { drop.push((*a, *l)); } else if !(many && da <= ON_PX && db <= ON_PX) { drop.push((*a, *l)); drop.push((*b, *l)); } } } }+      touch.retain(|k, _| !drop.contains(k)); }+    // islands that share a pad are one conductor+    let max = labels.iter().copied().max().unwrap_or(0) as usize;+    let mut root: Vec<u32> = (0..=max as u32).collect();+    fn find(r: &mut [u32], x: u32) -> u32 { let mut a = x; while r[a as usize] != a { a = r[a as usize]; } let mut c = x; while r[c as usize] != a { let nx = r[c as usize]; r[c as usize] = a; c = nx; } a }+    let mut of_seed: HashMap<u32, Vec<u32>> = HashMap::new();+    let mut keys: Vec<(u32, u32)> = touch.keys().copied().collect(); keys.sort();+    for (s, l) in &keys { of_seed.entry(*s).or_default().push(*l); }+    let mut merged = 0;+    let mut seeds_sorted: Vec<u32> = of_seed.keys().copied().collect(); seeds_sorted.sort();+    // No join may put the two ends of a two-pad part on one island, however many pads lie between: each island carries the pads joined to it, and a join that would+    // bring a pad and its mate together is refused, with the pad that asked for it left off that island.+    let mate_of = |s: u32| -> Option<u32> { let m = mate.get(s as usize)?; if m.1 != 2 { return None; } (1..mate.len() as u32).find(|o| *o != s && mate[*o as usize].0 == m.0) };+    let mates_of: HashMap<u32, u32> = seeds_sorted.iter().filter_map(|s| mate_of(*s).map(|m| (*s, m))).collect();+    let mut on: HashMap<u32, Vec<u32>> = HashMap::new();+    let mut refused: Vec<(u32, u32)> = vec![];+    for s in &seeds_sorted { let ls = of_seed[s].clone(); let mut mine: Option<u32> = None;+        for l in &ls { let b = find(&mut root, *l);+            let clash = |grp: u32, with: &[u32], on: &HashMap<u32, Vec<u32>>| -> bool { let there = on.get(&grp).map(|v| v.as_slice()).unwrap_or(&[]); with.iter().any(|p| mates_of.get(p).is_some_and(|m| there.contains(m))) };+            match mine {+                None => { if clash(b, &[*s], &on) { refused.push((*s, *l)); continue; } on.entry(b).or_default().push(*s); mine = Some(b); }+                Some(a) => { let a = find(&mut root, a); if a == b { continue; }+                    let (va, vb) = (on.get(&a).cloned().unwrap_or_default(), on.get(&b).cloned().unwrap_or_default());+                    if clash(b, &va, &on) || clash(a, &vb, &on) { refused.push((*s, *l)); continue; }+                    let (keep, gone) = (a.min(b), a.max(b)); root[gone as usize] = keep; merged += 1;+                    let mut all = va; all.extend(vb); on.remove(&gone); on.insert(keep, all); mine = Some(keep); } } } }+    touch.retain(|k, _| !refused.contains(k));+    let keys: Vec<(u32, u32)> = keys.into_iter().filter(|k| !refused.contains(k)).collect();+    let mut of_seed: HashMap<u32, Vec<u32>> = HashMap::new(); for (s, l) in &keys { of_seed.entry(*s).or_default().push(*l); }+    let mut seeds_sorted: Vec<u32> = of_seed.keys().copied().collect(); seeds_sorted.sort();+    for l in labels.iter_mut() { if *l != 0 { *l = find(&mut root, *l); } }+    // the pad's own area and the way to each island it met+    let mut painted = Mask::new(w, h);+    let label_of: HashMap<u32, u32> = seeds_sorted.iter().map(|s| (*s, find(&mut root, of_seed[s][0]))).collect();+    for i in 0..n { if seed[i] != 0 { if let Some(l) = label_of.get(&seed[i]) { if labels[i] == 0 { labels[i] = *l; painted.d[i] = true; } } } }+    for (s, l) in &keys { let lab = label_of[s]; let _ = l; let mut i = touch[&(*s, *l)];+        loop { let (x, y) = ((i % w) as i64, (i / w) as i64);+            for dy in -PATH_HALF..=PATH_HALF { for dx in -PATH_HALF..=PATH_HALF { let (u, v) = (x + dx, y + dy); if u < 0 || v < 0 || u >= w as i64 || v >= h as i64 { continue; } let j = v as usize * w + u as usize;+                if labels[j] == 0 && owner[j] == *s && !blocked.d[j] { labels[j] = lab; painted.d[j] = true; } } }+            if parent[i] == u32::MAX { break; } i = parent[i] as usize; } }+    Joined { pads_joined: seeds_sorted.len(), islands_merged: merged, painted }+}++#[cfg(test)]+mod tests {+    use super::*;+    /// A pad at the left, a track that starts 8 px to its right, and a second track that passes 8 px above the pad behind a wall of seen laminate.+    fn face() -> (Vec<u32>, Vec<u32>, Mask, usize, usize) {+        let (w, h) = (120usize, 80usize);+        let mut labels = vec![0u32; w * h]; let mut blocked = Mask::new(w, h);+        for y in 36..44 { for x in 38..110 { labels[y * w + x] = 1; } }+        for y in 14..20 { for x in 5..110 { labels[y * w + x] = 2; } }+        for y in 22..26 { for x in 0..w { blocked.d[y * w + x] = true; } }+        let seed = seed_map(&[Seed { cx: 20.0, cy: 40.0, hw: 10.0, hh: 12.0, rot_deg: 0.0, round: false, part: 0 }], w, h);+        (labels, seed, blocked, w, h)+    }+    #[test]+    fn a_pad_joins_the_track_that_starts_short_of_it_and_not_the_one_behind_laminate() {+        let (mut labels, seed, blocked, w, h) = face();+        let j = join(&mut labels, &seed, &blocked, &Mask::new(w, h), &[], w, h);+        assert_eq!(j.pads_joined, 1);+        assert_eq!(labels[40 * w + 20], 1, "the pad's own area is its track's island");+        assert!((36..44).any(|y| labels[y * w + 34] == 1), "and so is the way between them");+        assert_eq!(labels[16 * w + 20], 2, "the track behind the laminate is untouched");+        assert_eq!(labels[30 * w + 60], 0, "nothing is painted where no pad is");+    }+    #[test]+    fn a_track_further_than_the_reach_is_left_alone_and_two_islands_on_one_pad_become_one() {+        let (w, h) = (160usize, 80usize);+        let mut labels = vec![0u32; w * h]; let blocked = Mask::new(w, h);+        for y in 36..44 { for x in 60..150 { labels[y * w + x] = 1; } }+        let seed = seed_map(&[Seed { cx: 20.0, cy: 40.0, hw: 10.0, hh: 12.0, rot_deg: 0.0, round: false, part: 0 }], w, h);+        let j = join(&mut labels, &seed, &blocked, &Mask::new(w, h), &[], w, h);+        assert_eq!(j.pads_joined, 0, "30 px away is not this pad's copper");+        assert_eq!(labels[40 * w + 20], 0);+        // a track to the right and a pour below, both within reach: one conductor through the pad+        for y in 36..44 { for x in 36..60 { labels[y * w + x] = 1; } }+        for y in 58..78 { for x in 5..60 { labels[y * w + x] = 2; } }+        let j = join(&mut labels, &seed, &blocked, &Mask::new(w, h), &[], w, h);+        assert_eq!((j.pads_joined, j.islands_merged), (1, 1));+        assert_eq!(labels[40 * w + 100], labels[70 * w + 30]);+    }+    #[test]+    fn a_strip_beside_a_two_pad_part_joins_neither_end() {+        // a resistor lying left to right: pads at each end, body between, and a strip of board read as copper along its lower side, within reach of both pads+        let (w, h) = (120usize, 80usize);+        let mut labels = vec![0u32; w * h]; let blocked = Mask::new(w, h); let mut body = Mask::new(w, h);+        for y in 30..50 { for x in 20..100 { body.d[y * w + x] = true; } }+        for y in 54..60 { for x in 24..96 { labels[y * w + x] = 1; } }+        for y in 34..46 { for x in 2..12 { labels[y * w + x] = 2; } }+        let pads = [Seed { cx: 28.0, cy: 40.0, hw: 8.0, hh: 10.0, rot_deg: 0.0, round: false, part: 7 }, Seed { cx: 92.0, cy: 40.0, hw: 8.0, hh: 10.0, rot_deg: 0.0, round: false, part: 7 }];+        let seed = seed_map(&pads, w, h);+        for with_footprints in [true, false] {+            let mut l = labels.clone();+            let j = join(&mut l, &seed, &blocked, &body, &if with_footprints { mates(&pads) } else { vec![] }, w, h);+            assert_eq!(j.pads_joined, 1, "only the left pad joins anything, its own track");+            assert_eq!(l[40 * w + 28], 2); assert_eq!(l[40 * w + 92], 0); assert_eq!(l[56 * w + 60], 1, "the strip is left as it was");+        }+    }+    #[test]+    fn a_track_that_bends_past_the_next_pin_of_a_chip_stays_with_its_own_pin() {+        // three pins of one chip in a row; the middle pin's track leaves upward and bends left over the first pin, 8 px above it+        let (w, h) = (140usize, 90usize);+        let mut labels = vec![0u32; w * h]; let blocked = Mask::new(w, h);+        for y in 20..26 { for x in 20..66 { labels[y * w + x] = 1; } }+        for y in 20..34 { for x in 60..66 { labels[y * w + x] = 1; } }+        let pads: Vec<Seed> = [30.0, 63.0, 96.0].iter().map(|x| Seed { cx: *x, cy: 46.0, hw: 5.0, hh: 12.0, rot_deg: 0.0, round: false, part: 3 }).collect();+        let seed = seed_map(&pads, w, h);+        join(&mut labels, &seed, &blocked, &Mask::new(w, h), &mates(&pads), w, h);+        assert_eq!(labels[46 * w + 63], 1, "the middle pin has its track");+        assert_eq!(labels[46 * w + 30], 0, "the first pin, which the track only passes, does not");+    }+    #[test]+    fn a_third_pad_cannot_join_the_two_ends_of_a_part_either() {+        // a capacitor with its own track at each end, and a chip pin lying between the two tracks, within reach of both+        let (w, h) = (160usize, 80usize);+        let mut labels = vec![0u32; w * h]; let blocked = Mask::new(w, h);+        for y in 37..43 { for x in 5..20 { labels[y * w + x] = 1; } for x in 22..70 { labels[y * w + x] = 1; } }+        for y in 37..43 { for x in 86..150 { labels[y * w + x] = 2; } }+        for y in 10..16 { for x in 5..30 { labels[y * w + x] = 1; } for x in 130..150 { labels[y * w + x] = 2; } }+        let pads = [Seed { cx: 12.0, cy: 13.0, hw: 6.0, hh: 6.0, rot_deg: 0.0, round: false, part: 1 }, Seed { cx: 140.0, cy: 13.0, hw: 6.0, hh: 6.0, rot_deg: 0.0, round: false, part: 1 },+                    Seed { cx: 78.0, cy: 40.0, hw: 4.0, hh: 6.0, rot_deg: 0.0, round: false, part: 2 }];+        // (the capacitor's pads lie on tracks 1 and 2; the chip pin at x 78 is 4 px from the end of each)+        for y in 10..16 { for x in 5..30 { labels[y * w + x] = 1; } }+        let seed = seed_map(&pads, w, h);+        join(&mut labels, &seed, &blocked, &Mask::new(w, h), &mates(&pads), w, h);+        assert_ne!(labels[40 * w + 40], labels[40 * w + 120], "the two tracks stay two islands");+        assert_ne!(labels[40 * w + 78], 0, "the chip pin still joins one of them");+    }+    #[test]+    fn a_pin_does_not_reach_round_its_neighbour() {+        // two pins 8 px apart, a track leaving the right one only, with nothing seen between: the left pin must not take it+        let (w, h) = (120usize, 80usize);+        let mut labels = vec![0u32; w * h]; let blocked = Mask::new(w, h);+        for y in 10..22 { for x in 44..52 { labels[y * w + x] = 1; } }+        let seed = seed_map(&[Seed { cx: 32.0, cy: 40.0, hw: 4.0, hh: 12.0, rot_deg: 0.0, round: false, part: 0 }, Seed { cx: 48.0, cy: 40.0, hw: 4.0, hh: 12.0, rot_deg: 0.0, round: false, part: 0 }], w, h);+        join(&mut labels, &seed, &blocked, &Mask::new(w, h), &[], w, h);+        assert_eq!(labels[40 * w + 48], 1, "the right pin has its track");+        assert_eq!(labels[40 * w + 32], 0, "the left pin has none");+    }+}
src/passives.rs+175−24
@@ -2,10 +2,15 @@ //! fitted with an oriented rectangle. Which way the part lies is read from what lies just beyond the two pairs of opposite sides: //! a solder end looks paler than this part's own tan body, or brighter than its own dark one. The package comes from the body's //! own length and width, the place is the body's centre. So two parts side by side stay two parts: nothing is paired across a gap.-//! Thresholds are in OpenCV's 8-bit HSV (H 0..180, S and V 0..255), where they were read on the CM5 photos.+//!+//! The colour thresholds are each photo's own (`Levels`). The board surface and the bright level come from `tone`. The photo is then+//! read once strictly at a sweep of saturation floors; the bodies that pass (`calibrate`) give this photo's tan hue, the saturation+//! between its solder ends and its bodies, and how bright its bodies and ends are, and the photo is read again at those. A photo with+//! too few such bodies is read at the CM5's numbers scaled by its own bright level, and its candidates say so.+//! Hue, saturation and value are OpenCV's 8-bit ones (H 0..180, S and V 0..255). use crate::board::{to_mm, to_px, RES}; use crate::geom::{self, P};-use crate::prep::{self, Mat};+use crate::tone; use image::RgbImage; use serde_json::{json, Value}; use std::f64::consts::FRAC_PI_2;@@ -34,20 +39,81 @@ pub fn hsv8(r: u8, g: u8, b: u8) -> (u8, u8, u8) {     ((if h < 0 { h + 180 } else { h }) as u8, s as u8, v as u8) } -/// The photo as the detector reads it: 8-bit HSV per pixel and whether the pixel is bare board (solder mask).-pub struct Px { pub w: usize, pub h: usize, pub hsv: Vec<[u8; 3]>, pub board: Vec<bool> }+/// The thresholds one photo is read at.+#[derive(Clone, Debug)]+pub struct Levels {+    /// hue of a tan body, lowest and highest+    pub tan_hue: (u8, u8),+    /// a tan body is at least this saturated and this bright; a dark body is darker than `dark_v` and no further round the hue circle than `dark_hue`+    pub tan_s: f64, pub tan_v: f64, pub dark_v: f64, pub dark_hue: u8,+    /// a solder end is brighter than this, and brighter than its own dark body by `dark_dv`+    pub end_v: f64, pub dark_dv: f64,+    /// how many of this photo's own bodies the levels were measured on; 0 when they are the scaled CM5 numbers+    pub bodies: usize,+}++/// The CM5 photos' numbers (everypin pair, 2026-11-08), which every photo was read at before: tan hue 4 to 22 and saturation from 78,+/// dark hue up to 16. Used only when a photo shows too few bodies of its own to measure (`MIN_BODIES`).+const CM5_TAN_HUE: (u8, u8) = (4, 22); const CM5_TAN_S: f64 = 78.0; const CM5_DARK_HUE: u8 = 16;+/// The CM5's value thresholds as shares of that photo's bright level (247 on its bottom face, where 195 of its 224 passives are):+/// a tan body from 140, an end from 120, an end 35 brighter than its dark body.+const BODY_V: f64 = 140.0 / 247.0; const END_V: f64 = 120.0 / 247.0; const DARK_DV: f64 = 35.0 / 247.0;+/// An end of a tan body is this much less saturated than the body itself. Relative to the part; 15 of 255 was read on the CM5 photos.+const END_DS: f64 = 15.0;+/// The saturation floors the calibration pass tries, and its widest tan hue and lowest value (as a share of the bright level): wide+/// enough for the tan of the CM5 (hue 9 to 17), the Driver (12 to 18, value down to 85 of 190) and board 10 (19 to 24).+const SWEEP_S: [f64; 6] = [50.0, 64.0, 78.0, 92.0, 106.0, 120.0]; const SWEEP_HUE: (u8, u8) = (4, 28); const SWEEP_V: f64 = 0.45;+/// Two strict readings whose centres lie within this (0.15 mm, in board-image pixels) are one body read at two floors.+const SAME_BODY: f64 = 0.15 * RES;+/// A photo's own levels need at least this many strictly read bodies.+pub const MIN_BODIES: usize = 5;+/// Where the saturation floor sits between the photo's ends and its bodies (0 at the ends' median, 1 at the lower quartile of the+/// bodies' own lower quartiles), and the value floor as a share of the bodies' median value. Chosen on the CM5, Driver and the+/// left half of board 10 (2026-11-10): 0.85 and 0.77 put the CM5 bottom at 77.8 and 137, next to the 78 and 140 it was read at by hand.+/// Between 0.85 and 1.0 the count of right sizes over those boards does not move (205 to 206 of 347 chips).+const S_BETWEEN: f64 = 0.85; const V_OF_BODY: f64 = 0.77;+/// The tan hue window is the middle nine tenths of the bodies' own hues with this much to spare either side (a body's pixels spread+/// more than the bodies' medians do). A dark body's hue stops at the 95th percentile itself: on the CM5 the shadow round a part+/// begins right above its tan. Both chosen on the same three boards.+const HUE_SPARE: f64 = 6.0;+/// An end is brighter than this share of the photo's median end.+const V_OF_END: f64 = 0.6;++impl Levels {+    /// The CM5's numbers scaled to a photo's bright level.+    pub fn scaled(bright: f64) -> Levels {+        Levels { tan_hue: CM5_TAN_HUE, tan_s: CM5_TAN_S, tan_v: BODY_V * bright, dark_v: BODY_V * bright, dark_hue: CM5_DARK_HUE, end_v: END_V * bright, dark_dv: DARK_DV * bright, bodies: 0 }+    }+    pub fn json(&self) -> Value {+        let r = |v: f64| (v * 10.0).round() / 10.0;+        json!({"tanHue": [self.tan_hue.0, self.tan_hue.1], "tanSaturation": r(self.tan_s), "tanValue": r(self.tan_v), "darkValue": r(self.dark_v), "darkHue": self.dark_hue,+               "endValue": r(self.end_v), "endOverDark": r(self.dark_dv), "bodies": self.bodies,+               "from": if self.bodies > 0 { "this photo's own bodies and ends" } else { "too few bodies to measure: the CM5's numbers scaled by this photo's bright level" }})+    }+}++/// How sure a body must be: an end share on both sides, that much more than across, and a size error under `cost`.+#[derive(Clone, Copy, Debug)]+pub struct Gate { pub ends: f64, pub over: f64, pub cost: f64 }+/// What `find` asks of a body.+pub const READ: Gate = Gate { ends: 0.22, over: 0.12, cost: 0.75 };+/// What the calibration pass asks: only a body with clear ends and a size close to a package teaches the photo's levels.+pub const STRICT: Gate = Gate { ends: 0.5, over: 0.4, cost: 0.4 };++/// The photo as the detector reads it: 8-bit HSV per pixel, whether the pixel is board surface, and the photo's bright level.+pub struct Px { pub w: usize, pub h: usize, pub hsv: Vec<[u8; 3]>, pub board: Vec<bool>, pub bright: f64 }  impl Px {-    /// Board is what the flow's own classification calls solder mask in this photo (`prep::material` against the photo's `MaskTone`).+    /// Board is the photo's own dominant hue (`tone::of`).     pub fn of(img: &RgbImage) -> Px {-        let tone = prep::mask_tone(img);-        Px { w: img.width() as usize, h: img.height() as usize, hsv: img.pixels().map(|p| { let (h, s, v) = hsv8(p[0], p[1], p[2]); [h, s, v] }).collect(),-             board: img.pixels().map(|p| prep::material(p, &tone) == Mat::Mask).collect() }+        let hsv: Vec<[u8; 3]> = img.pixels().map(|p| { let (h, s, v) = hsv8(p[0], p[1], p[2]); [h, s, v] }).collect();+        let t = tone::of(&hsv);+        Px { w: img.width() as usize, h: img.height() as usize, hsv, board: t.board, bright: t.bright }     }     /// A tan capacitor body.-    fn tan(&self, i: usize) -> bool { let [h, s, v] = self.hsv[i]; (4..=22).contains(&h) && s >= 78 && v >= 140 && !self.board[i] }+    fn tan(&self, i: usize, lv: &Levels) -> bool { let [h, s, v] = self.hsv[i]; (lv.tan_hue.0..=lv.tan_hue.1).contains(&h) && s as f64 >= lv.tan_s && v as f64 >= lv.tan_v && !self.board[i] }     /// A dark body; the shadow round a part is greener than this.-    fn dark(&self, i: usize) -> bool { let [h, _, v] = self.hsv[i]; v < 140 && h <= 16 && !self.board[i] }+    fn dark(&self, i: usize, lv: &Levels) -> bool { let [h, _, v] = self.hsv[i]; (v as f64) < lv.dark_v && h <= lv.dark_hue && !self.board[i] }     /// The pixels of a 6 x 5 grid of points: from `f0` to `f1` along (ux, uy) from (cx, cy), `half` to either side across. Points off the photo are left out.     fn grid(&self, cx: f64, cy: f64, ux: f64, uy: f64, f0: f64, f1: f64, half: f64) -> Vec<usize> {         let (vx, vy) = (-uy, ux); let mut o = Vec::with_capacity(30);@@ -166,7 +232,11 @@ pub fn bodies(mask: &[bool], w: usize, h: usize, amin: usize, amax: usize, fill:  /// One chip passive. `x`, `y` are in the opened mask's pixels (see `open2`); `rot` is the image angle of its length, clockwise on screen. #[derive(Clone, Debug)]-pub struct Passive { pub kind: &'static str, pub x: f64, pub y: f64, pub rot: f64, pub pk: usize, pub q: f64, pub body_mm: (f64, f64), pub cost: f64 }+pub struct Passive { pub kind: &'static str, pub x: f64, pub y: f64, pub rot: f64, pub pk: usize, pub q: f64, pub body_mm: (f64, f64), pub cost: f64,+                     /// the smaller end share along the part, the larger one across it, and the body's (length, width, angle) in pixels and radians+                     pub ends: f64, pub across: f64, pub axis: (f64, f64, f64),+                     /// read at the photo's own levels (false: at the scaled CM5 numbers)+                     pub own_levels: bool }  impl Passive {     /// The body centre in board-image pixels, a pixel's middle at +0.5: the opening's one pixel taken back out.@@ -174,6 +244,13 @@ impl Passive {     /// The package outline in board-image pixels.     pub fn outline(&self) -> [P; 4] { corners(self.centre(), (PK[self.pk].1 * RES, PK[self.pk].2 * RES), self.rot) }     pub fn area_mm2(&self) -> f64 { PK[self.pk].1 * PK[self.pk].2 }+    /// How sure the reading is, 0.45 to 1: how much clearer the ends are along the part than across it, and how close the body is+    /// to its package. A reading at another board's levels counts a fifth less. Not a measured probability: the scale was laid out+    /// on the left half of board 10 so that 0.5 keeps what a person would look at first.+    pub fn confidence(&self) -> f64 {+        let c = 0.45 + 0.35 * ((self.ends - self.across) / 0.6).min(1.0) + 0.2 * (1.0 - self.cost / READ.cost).clamp(0.0, 1.0);+        if self.own_levels { c } else { 0.8 * c }+    } }  /// A body's read angle within this many degrees of square or diagonal is taken as square or diagonal.@@ -182,27 +259,67 @@ const SQUARE_WITHIN: f64 = 22.5; /// The body gives the place and the width; the ends only say which way the part lies. An end is judged against this part's own /// body: less colour than a tan body, brighter than a dark one. Both ends must show along one axis and not along the other, and the /// body's size must be near a package's; otherwise the body is no part.-pub fn part(b: &Body, px: &Px, kind: &'static str) -> Option<Passive> {+pub fn part(b: &Body, px: &Px, kind: &'static str, lv: &Levels, gate: &Gate) -> Option<Passive> {     let tan = kind == "tan"; let mut ax = vec![];     for (l, w, ang) in [(b.w, b.h, b.ang), (b.h, b.w, b.ang + FRAC_PI_2)] { // l = body size along the tried axis         let (ux, uy) = (ang.cos(), ang.sin()); let half = (w * 0.38).max(2.0);         let own = px.grid(b.cx, b.cy, ux, uy, -l * 0.3, l * 0.3, w * 0.3);         let bs = median(own.iter().map(|i| px.hsv[*i][1] as f64).collect()); let bv = median(own.iter().map(|i| px.hsv[*i][2] as f64).collect());-        let end = |i: usize| { let [_, s, v] = px.hsv[i]; !px.board[i] && v > 120 && if tan { (s as f64) < bs - 15.0 } else { v as f64 > bv + 35.0 } };+        let end = |i: usize| { let [_, s, v] = px.hsv[i]; !px.board[i] && v as f64 > lv.end_v && if tan { (s as f64) < bs - END_DS } else { v as f64 > bv + lv.dark_dv } };         let e = [1.0, -1.0].map(|s: f64| { let g = px.grid(b.cx, b.cy, s * ux, s * uy, l / 2.0 + 1.5, l / 2.0 + 7.0, half); g.iter().filter(|i| end(**i)).count() as f64 / g.len().max(1) as f64 });         ax.push((e[0].min(e[1]), e[0].max(e[1]), l, w, ang));     }     let i = if ax[0].0 >= ax[1].0 { 0 } else { 1 }; let (me, _, l, w, ang) = ax[i]; let other = ax[1 - i].1;-    if me < 0.22 || me - other < 0.12 { return None; }+    if me < gate.ends || me - other < gate.over { return None; }     let (wid, ln) = (w / RES, l / RES);     // the body shows a little narrower than the part     let cost = |q: &(&str, f64, f64, f64)| (wid * 1.15 / q.2).ln().abs() + (ln / (q.3 * q.1)).ln().abs();     let pk = (0..PK.len()).fold(0, |a, k| if cost(&PK[k]) < cost(&PK[a]) { k } else { a });-    if cost(&PK[pk]) > 0.75 { return None; }+    if cost(&PK[pk]) > gate.cost { return None; }     // a body a few pixels across gives a rough angle: parts sit square or on the diagonal, so the angle goes to the nearest 45     let mut rot = ang.to_degrees().rem_euclid(180.0); let near = (rot / 45.0).round() * 45.0;     if (rot - near).abs() <= SQUARE_WITHIN { rot = near.rem_euclid(180.0); }-    Some(Passive { kind, x: b.cx, y: b.cy, rot, pk, q: me - other + if tan { 0.5 } else { 0.0 }, body_mm: (ln, wid), cost: cost(&PK[pk]) })+    Some(Passive { kind, x: b.cx, y: b.cy, rot, pk, q: me - other + if tan { 0.5 } else { 0.0 }, body_mm: (ln, wid), cost: cost(&PK[pk]), ends: me, across: other, axis: (l, w, ang), own_levels: lv.bodies > 0 })+}++/// The value at quantile `q` of a list, between neighbours as numpy's percentile has it.+fn quantile(mut v: Vec<f64>, q: f64) -> f64 {+    if v.is_empty() { return 0.0; }+    v.sort_by(|a, b| a.partial_cmp(b).unwrap()); let at = q * (v.len() - 1) as f64; let (lo, hi) = (at.floor() as usize, at.ceil() as usize);+    v[lo] + (v[hi] - v[lo]) * (at - lo as f64)+}++/// This photo's own levels. Tan bodies are read strictly (`STRICT`) at each saturation floor of the sweep; each one that passes gives+/// the hue, saturation and value of its body and of the solder beyond its two ends. With `MIN_BODIES` or more: the hue window is+/// theirs with `HUE_SPARE` to spare, the saturation floor lies between the ends and the bodies, a body is at least `V_OF_BODY` of the+/// median body's value and an end `V_OF_END` of the median end's. A dark body is then whatever is darker than a tan one, at a hue+/// no further round than the bodies' own. With fewer, the scaled CM5 numbers.+pub fn calibrate(px: &Px) -> Levels {+    let base = Levels::scaled(px.bright);+    let (mut hue, mut sat25, mut val, mut end_s, mut end_v) = (vec![], vec![], vec![], vec![], vec![]);+    let mut seen: Vec<P> = vec![];+    for s in SWEEP_S {+        let lv = Levels { tan_hue: SWEEP_HUE, tan_s: s, tan_v: SWEEP_V * px.bright, ..base.clone() };+        let mask: Vec<bool> = (0..px.w * px.h).map(|i| px.tan(i, &lv)).collect();+        for b in bodies(&mask, px.w, px.h, 14, 9000, 0.55) {+            let Some(p) = part(&b, px, "tan", &lv, &STRICT) else { continue };+            // one sample a body: the same body passes at several floors, and is counted where it first does+            if seen.iter().any(|c: &P| geom::dist(*c, (b.cx, b.cy)) < SAME_BODY) { continue; }+            seen.push((b.cx, b.cy));+            let (l, w, ang) = p.axis; let (ux, uy) = (ang.cos(), ang.sin());+            let own = px.grid(b.cx, b.cy, ux, uy, -l * 0.3, l * 0.3, w * 0.3);+            let ends: Vec<usize> = [1.0, -1.0].iter().flat_map(|s: &f64| px.grid(b.cx, b.cy, s * ux, s * uy, l / 2.0 + 1.5, l / 2.0 + 7.0, (w * 0.38).max(2.0))).filter(|i| !px.board[*i]).collect();+            if own.is_empty() || ends.is_empty() { continue; }+            let ch = |v: &[usize], k: usize| -> Vec<f64> { v.iter().map(|i| px.hsv[*i][k] as f64).collect() };+            hue.push(median(ch(&own, 0))); sat25.push(quantile(ch(&own, 1), 0.25)); val.push(median(ch(&own, 2)));+            end_s.push(median(ch(&ends, 1))); end_v.push(median(ch(&ends, 2)));+        }+    }+    if hue.len() < MIN_BODIES { return base; }+    let (es, bs) = (median(end_s), quantile(sat25, 0.25));+    let tan_v = V_OF_BODY * median(val); let (h5, h95) = (quantile(hue.clone(), 0.05), quantile(hue.clone(), 0.95));+    Levels { tan_hue: ((h5 - HUE_SPARE).max(0.0) as u8, (h95 + HUE_SPARE).min(179.0) as u8), tan_s: (es + S_BETWEEN * (bs - es)).max(es + 10.0), tan_v, dark_v: tan_v,+             dark_hue: h95 as u8, end_v: V_OF_END * median(end_v), dark_dv: base.dark_dv, bodies: hue.len() } }  /// Where a chip passive cannot be: a bigger part or a plated hole's pad, in board-image pixels. `area_mm2` is that part's own size.@@ -248,21 +365,28 @@ pub fn zones(bodies: &[Value], holes: &[Value], rows: &[Value], row_holes: &[Val     z } -/// Every body that passes `part` on one board image, before any is dropped.-pub fn found(px: &Px) -> Vec<Passive> {+/// Every body that passes `part` on one board image at the given levels, before any is dropped.+pub fn found(px: &Px, lv: &Levels) -> Vec<Passive> {     let mut cand = vec![];     for (kind, amin, amax, fill) in [("tan", 14, 9000, 0.55), ("dark", 30, 12000, 0.68)] {-        let mask: Vec<bool> = (0..px.w * px.h).map(|i| if kind == "tan" { px.tan(i) } else { px.dark(i) }).collect();-        cand.extend(bodies(&mask, px.w, px.h, amin, amax, fill).iter().filter_map(|b| part(b, px, kind)));+        let mask: Vec<bool> = (0..px.w * px.h).map(|i| if kind == "tan" { px.tan(i, lv) } else { px.dark(i, lv) }).collect();+        cand.extend(bodies(&mask, px.w, px.h, amin, amax, fill).iter().filter_map(|b| part(b, px, kind, lv, &READ)));     }     cand }  /// Chip passives on one board image, best first. A candidate a zone takes is left out, and of two that overlap the better one stays. /// Returns the passives and, for the record, what the zones took: (passive, zone).-pub fn find(img: &RgbImage, zones: &[Zone]) -> (Vec<Passive>, Vec<(Passive, String)>) {-    let mut cand = found(&Px::of(img));+pub fn find(img: &RgbImage, zones: &[Zone]) -> (Vec<Passive>, Vec<(Passive, String)>) { let (k, t, _) = find_with(img, zones); (k, t) }++/// As `find`, and the levels the photo was read at.+pub fn find_with(img: &RgbImage, zones: &[Zone]) -> (Vec<Passive>, Vec<(Passive, String)>, Levels) {+    let px = Px::of(img); let lv = calibrate(&px);+    let mut cand = found(&px, &lv);     cand.sort_by(|a, b| b.q.partial_cmp(&a.q).unwrap());+    // a second reading at the scaled CM5 numbers, behind the first: it only adds where the photo's own levels found nothing, and+    // what it adds says so (`own_levels` false, a fifth less sure)+    if lv.bodies > 0 { let mut more = found(&px, &Levels::scaled(px.bright)); more.sort_by(|a, b| b.q.partial_cmp(&a.q).unwrap()); cand.extend(more); }     let (mut kept, mut boxes, mut taken): (Vec<Passive>, Vec<[P; 4]>, Vec<(Passive, String)>) = (vec![], vec![], vec![]);     for p in cand {         if let Some(z) = zones.iter().find(|z| z.takes(&p)) { taken.push((p, z.what.clone())); continue; }@@ -270,7 +394,7 @@ pub fn find(img: &RgbImage, zones: &[Zone]) -> (Vec<Passive>, Vec<(Passive, Stri         if kept.iter().zip(&boxes).any(|(k, kr)| shared_area(&r, kr) > OVERLAP * p.area_mm2().min(k.area_mm2()) * RES * RES) { continue; }         kept.push(p); boxes.push(r);     }-    (kept, taken)+    (kept, taken, lv) }  /// Whether a candidate of the older detector (`parts::candidates`) describes the spot of one of these passives: its rectangle shares more@@ -294,7 +418,8 @@ pub fn candidates(found: &[Passive], side: &str, start: usize) -> Vec<Value> {         json!({"id": format!("P{:03}", start + k + 1), "side": side, "pos_mm": [r3(pos.0), r3(pos.1)], "rot": (rot * 10.0).round() / 10.0,                "size_mm": [l, w], "body_mm": [r2(p.body_mm.0), r2(p.body_mm.1)], "colour": p.kind, "kind": p.kind, "fits": true,                "prefix": if p.kind == "tan" { "C" } else { "R" }, "package": name, "footprint": fp,-               "matches": [{"package": name, "footprint": fp, "error": r3(p.cost)}], "score": r2(p.q), "method": "body", "source": "body-first"})+               "matches": [{"package": name, "footprint": fp, "error": r3(p.cost)}], "score": r2(p.q), "method": "body", "source": "body-first",+               "confidence": r2(p.confidence()), "levels": if p.own_levels { "own" } else { "scaled" }})     }).collect() } @@ -390,6 +515,32 @@ mod tests {         assert!(!same_spot(&old(104.0, 103.9, 5.0, 3.0), "top", &found));     } +    #[test]+    fn a_dark_photo_is_read_at_its_own_levels() {+        // the same parts on a photo a third as bright, with a browner tan: under the CM5's numbers nothing here is tan or an end+        let (mask, solder, tan) = (Rgb([14, 60, 44]), Rgb([118, 118, 118]), Rgb([104, 62, 30]));+        let mut img = RgbImage::from_pixel(600, 300, mask);+        // white print sets the bright level well above the solder, as on the Driver photo+        fill(&mut img, 20..60, 250..280, Rgb([230, 230, 230]));+        for k in 0..8u32 { let x0 = 30 + 70 * k; fill(&mut img, x0..x0 + 10, 100..120, solder); fill(&mut img, x0 + 10..x0 + 30, 100..120, tan); fill(&mut img, x0 + 30..x0 + 40, 100..120, solder); }+        let px = Px::of(&img);+        assert!(found(&px, &Levels::scaled(px.bright)).is_empty(), "the scaled CM5 numbers find nothing on this photo");+        let lv = calibrate(&px);+        assert_eq!(lv.bodies, 8, "{lv:?}");+        let (h, s, v) = hsv8(104, 62, 30);+        assert!(lv.tan_hue.0 <= h && h <= lv.tan_hue.1 && lv.tan_s < s as f64 && lv.tan_v < v as f64 && lv.end_v < 118.0, "{lv:?}");+        let (got, _, used) = find_with(&img, &[]);+        assert_eq!((got.len(), used.bodies), (8, 8));+        assert!(got.iter().all(|p| p.kind == "tan" && PK[p.pk].0 == "0402" && p.own_levels && p.confidence() > 0.9), "{got:?}");+        // fewer bodies than MIN_BODIES: the scaled numbers, and candidates that say so+        let mut few = RgbImage::from_pixel(400, 300, MASK);+        chip(&mut few, 100, 100, 10, 20, 20, TAN); chip(&mut few, 250, 100, 10, 20, 20, TAN);+        let (got, _, used) = find_with(&few, &[]);+        assert_eq!((got.len(), used.bodies), (2, 0));+        assert!(got.iter().all(|p| !p.own_levels && p.confidence() < 0.81));+        assert_eq!(candidates(&got, "top", 0)[0]["levels"], json!("scaled"));+    }+     #[test]     fn a_passive_inside_a_bigger_part_is_left_out() {         let mut img = RgbImage::from_pixel(400, 300, MASK);
src/photolayers.rs+3
@@ -494,6 +494,9 @@ pub fn trace(flow: &Path, pads: &[Pad], ev: &Path) -> Result<Value, String> {         let near = grown_rect(p, 1.0);         let shaped = m.and_then(|px| { let kept: Vec<usize> = px.iter().copied().filter(|i| point_in(&near, (fr.ox + ((i % w) as f64 + 0.5) / fr.res, fr.oy + ((i / w) as f64 + 0.5) / fr.res))).collect(); outer_ring_px(&kept, w) });         let (ring, inside) = match shaped { Some((r, i)) => (Some(r), Some(i)), None => (None, None) };+        // A drill stays where the hole is. A through pad whose hole the photo did not find keeps the library's place: its outline from the photo is taken only when it holds that place, and+        // the anchor, which carries the drill, does not move into the outline (test board 1: a mounting hole's drill ended 2.3 mm from its ring's centre, header pins up to 1.5 mm).+        let (ring, inside) = if p.drill > 0.0 && hole_at.is_none() { match &ring { Some(r) if point_in(r, (p.x, p.y)) => (ring, Some((p.x, p.y))), _ => (None, None) } } else { (ring, inside) };         let anchor: Option<P> = hole_at.map(|h| (h.0, h.1)).or(inside);         // a pad where the photo plainly shows mask: the part's footprint does not fit here. The pad stays for its net, but         // it opens no mask the photo does not show. Half or more of it must be in plain view for that.
src/photomat.rs+17−2
@@ -40,6 +40,12 @@ fn median3(img: &RgbImage, c: usize) -> Plane {  fn diff(a: &Plane, b: &Plane) -> Plane { Plane { w: a.w, h: a.h, d: a.d.iter().zip(&b.d).map(|(p, q)| p - q).collect() } } +/// The plane the rules call G - R: how far the mask's colour stands out, for a mask of any colour (`maskcolour::mask_over_red`). On a green mask it is G - R itself.+fn mask_over_red(mc: &crate::maskcolour::MaskColour, r: &Plane, g: &Plane, b: &Plane) -> Plane {+    if mc.green { return diff(g, r); }+    Plane { w: r.w, h: r.h, d: (0..r.d.len()).map(|i| crate::maskcolour::mask_over_red(mc, r.d[i], g.d[i], b.d[i])).collect() }+}+ /// Normalised box mean of `v` over the pixels `m` sets (radius `r`). fn masked_mean(v: &Plane, m: &Mask, r: usize) -> Plane {     let num = raster::blur(&Plane { w: v.w, h: v.h, d: v.d.iter().zip(&m.d).map(|(x, s)| if *s { *x } else { 0.0 }).collect() }, r, 1);@@ -62,14 +68,30 @@ pub fn carries_parts(img: &RgbImage) -> bool {     let (w, h) = (img.width() as usize, img.height() as usize);     let (r, g, b) = (median3(img, 0), median3(img, 1), median3(img, 2));     let y = Plane { w, h, d: (0..w * h).map(|i| 0.299 * r.d[i] + 0.587 * g.d[i] + 0.114 * b.d[i]).collect() };-    let (br, gr) = (raster::blur(&diff(&b, &r), 2, 1), raster::blur(&diff(&g, &r), 2, 1));+    let mc = crate::maskcolour::of(img);+    let (br, gr) = (raster::blur(&diff(&b, &r), 2, 1), raster::blur(&mask_over_red(&mc, &r, &g, &b), 2, 1));     let black = Mask { w, h, d: (0..w * h).map(|i| gr.d[i] < 12.0 && gr.d[i] > -14.0 && br.d[i].abs() < 26.0 && y.d[i] < 62.0).collect() }.open(2.0).close(4.0);-    let tan = Mask { w, h, d: (0..w * h).map(|i| -br.d[i] > 45.0 && y.d[i] > 85.0 && raster::hsv(&image::Rgb([r.d[i] as u8, g.d[i] as u8, b.d[i] as u8])).0 < 33.0).collect() }.close(2.0);+    // (a red mask is warm and under 33 degrees itself: on any mask but green the body must also be off the mask's hue)+    let tan = Mask { w, h, d: (0..w * h).map(|i| -br.d[i] > 45.0 && y.d[i] > 85.0 && raster::hsv(&image::Rgb([r.d[i] as u8, g.d[i] as u8, b.d[i] as u8])).0 < 33.0 && (mc.green || !mc.is_mask(r.d[i], g.d[i], b.d[i]))).collect() }.close(2.0);     let (_, bc) = raster::components(&black); let (_, tc) = raster::components(&tan);     let bodies: f64 = bc.iter().filter(|c| c.area as f64 > 0.5 * MM2).map(|c| c.area as f64).sum::<f64>() + tc.iter().filter(|c| c.area as f64 > 0.12 * MM2).map(|c| c.area as f64).sum::<f64>();+    if std::env::var("TRACE_DEBUG").is_ok() { eprintln!("bodies: black {:.4}, tan {:.4} of the face", bc.iter().filter(|c| c.area as f64 > 0.5 * MM2).map(|c| c.area as f64).sum::<f64>() / (w * h) as f64, tc.iter().filter(|c| c.area as f64 > 0.12 * MM2).map(|c| c.area as f64).sum::<f64>() / (w * h) as f64); }     bodies >= 0.01 * (w * h) as f64 } +/// Whether a face of a run carries parts. Once the run has placed parts (`parts.json`), that list says which faces carry them, and the photo is only asked when there+/// is no list yet. The measured test counts dark and tan blobs, and brass standoffs with their shadows, or a strip of table at the frame's edge, pass it: the USB hub+/// board's bare bottom measured 2.4 percent "black bodies" and was read as a populated face, so the frame of its printed table read as solder and joined its pours.+pub fn face_carries_parts(flow: &std::path::Path, side: &str, img: &RgbImage) -> bool {+    if let Ok(v) = std::env::var("TRACE_BODIES") { return v == "1"; }+    let listed: Option<Vec<String>> = std::fs::read_to_string(flow.join("parts.json")).ok().and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())