app
AI Flow: Routing
Public Made by Adomby adom
The routing stage of AI Flow: routes a placed KiCad board connection by connection and reports what it made, what it could not and why. A guide, never a gate.
← Commit history
Publish 0.2.0
44 files changed
+18430
Cargo.lock+277Cargo.toml+23LICENSE+9README.md+154SKILL.md+101bin/adom-aiflow-routingbuild.sh+11docs/hero.pngflows/flow.json+12install.sh+19package.json+76page.json+83skills/adom-aiflow-routing/SKILL.md+101src/board.rs+723src/bus.rs+410src/check.rs+1182src/classify.rs+752src/clock.rs+66src/compare.rs+368src/conn.rs+315src/current.rs+327src/fixtures.rs+207src/flow.rs+143src/geom.rs+211src/global.rs+1444src/grade.rs+154src/index.rs+286src/main.rs+553src/mask.rs+197src/placement.rs+232src/post.rs+1657src/pour.rs+467src/power.rs+177src/project.rs+274src/quality.rs+716src/render.rs+99src/repair.rs+2142src/router.rs+2147src/rules.rs+589src/search.rs+1317src/spread.rs+189src/sx.rs+110src/write.rs+102uninstall.sh+8Cargo.lockadded+277@@ -0,0 +1,277 @@+# This file is automatically @generated by Cargo.+# It is not intended for manual editing.+version = 4++[[package]]+name = "adom-route"+version = "0.2.0"+dependencies = [+ "clap",+ "serde_json",+]++[[package]]+name = "anstream"+version = "1.0.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"+dependencies = [+ "anstyle",+ "anstyle-parse",+ "anstyle-query",+ "anstyle-wincon",+ "colorchoice",+ "is_terminal_polyfill",+ "utf8parse",+]++[[package]]+name = "anstyle"+version = "1.0.14"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"++[[package]]+name = "anstyle-parse"+version = "1.0.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"+dependencies = [+ "utf8parse",+]++[[package]]+name = "anstyle-query"+version = "1.1.5"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"+dependencies = [+ "windows-sys",+]++[[package]]+name = "anstyle-wincon"+version = "3.0.11"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"+dependencies = [+ "anstyle",+ "once_cell_polyfill",+ "windows-sys",+]++[[package]]+name = "clap"+version = "4.6.7"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946"+dependencies = [+ "clap_builder",+ "clap_derive",+]++[[package]]+name = "clap_builder"+version = "4.6.7"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d"+dependencies = [+ "anstream",+ "anstyle",+ "clap_lex",+ "strsim",+]++[[package]]+name = "clap_derive"+version = "4.6.7"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0"+dependencies = [+ "heck",+ "proc-macro2",+ "quote",+ "syn",+]++[[package]]+name = "clap_lex"+version = "1.1.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486"++[[package]]+name = "colorchoice"+version = "1.0.5"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"++[[package]]+name = "equivalent"+version = "1.0.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"++[[package]]+name = "foldhash"+version = "0.2.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"++[[package]]+name = "hashbrown"+version = "0.17.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"++[[package]]+name = "heck"+version = "0.5.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"++[[package]]+name = "indexmap"+version = "2.14.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"+dependencies = [+ "equivalent",+ "hashbrown",+]++[[package]]+name = "is_terminal_polyfill"+version = "1.70.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"++[[package]]+name = "itoa"+version = "1.0.18"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"++[[package]]+name = "memchr"+version = "2.8.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"++[[package]]+name = "once_cell_polyfill"+version = "1.70.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"++[[package]]+name = "proc-macro2"+version = "1.0.107"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"+dependencies = [+ "unicode-ident",+]++[[package]]+name = "quote"+version = "1.0.47"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"+dependencies = [+ "proc-macro2",+]++[[package]]+name = "serde"+version = "1.0.229"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"+dependencies = [+ "serde_core",+]++[[package]]+name = "serde_core"+version = "1.0.229"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"+dependencies = [+ "serde_derive",+]++[[package]]+name = "serde_derive"+version = "1.0.229"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"+dependencies = [+ "proc-macro2",+ "quote",+ "syn",+]++[[package]]+name = "serde_json"+version = "1.0.154"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"+dependencies = [+ "foldhash",+ "indexmap",+ "itoa",+ "memchr",+ "serde",+ "serde_core",+ "zmij",+]++[[package]]+name = "strsim"+version = "0.11.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"++[[package]]+name = "syn"+version = "3.0.9"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"+dependencies = [+ "proc-macro2",+ "quote",+ "unicode-ident",+]++[[package]]+name = "unicode-ident"+version = "1.0.27"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"++[[package]]+name = "utf8parse"+version = "0.2.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"++[[package]]+name = "windows-link"+version = "0.2.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"++[[package]]+name = "windows-sys"+version = "0.61.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"+dependencies = [+ "windows-link",+]++[[package]]+name = "zmij"+version = "1.0.23"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
Cargo.tomladded+23@@ -0,0 +1,23 @@+[package]+name = "adom-route"+version = "0.2.0"+edition = "2021"+description = "A PCB router built around the designer's routing standards: exact-geometry octilinear search, not a grid."+license = "MIT"++# One program. `adom-aiflow-routing` is its name as an AI Flow stage app; build.sh and install.sh+# also give it the older name `adom-route`.+[[bin]]+name = "adom-aiflow-routing"+path = "src/main.rs"++[dependencies]+clap = { version = "4", features = ["derive"] }+serde_json = { version = "1", features = ["preserve_order"] }++[profile.release]+opt-level = 3+lto = true+codegen-units = 1+strip = true+debug = false
LICENSEadded+9@@ -0,0 +1,9 @@+MIT License++Copyright (c) 2026 Adom Industries Inc.++Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:++The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.++THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
README.mdadded+154@@ -0,0 +1,154 @@+# AI Flow: Routing++The routing stage of AI Flow: `adom-aiflow-routing` takes a placed KiCad board, routes every+connection in the designer's hierarchy, and reports what it made, what it could not and why. It is+a whole-board router written in Rust with an exact 0/45/90 degree search, not a grid. `adom-route`+is the same program under its older name.++## AI Flow: Routing is a dynamic skill++A **dynamic skill** is a small Rust binary that holds the AI's hand through a long job. It says+which step comes next, does the routine parts, checks the work with deterministic linters and+answers every command with hints. This app follows the same rules as AI Flow and every+`adom-aiflow-<stage>` app:++- **The AI does about 97 % of the work. The binary is about 3 %.** The binary is the guide and the+ checker. Reading the plan as a designer, finding the currents, deciding what to do about a+ connection that would not route and looking at the board belong to the AI.+- **A guide, never a gate.** No step is a hard gate. `next` suggests what to do; no command checks+ that an earlier step ran or decides whether the AI may continue.+- **Linters give feedback, gently.** `check` and `route` report what they found and suggest a next+ action. It is a suggestion, not a verdict: the AI and the human decide what to do with it.+- **It never tries to do the AI's job.** It encodes no rule about what may happen next.+- **The only error is "I could not run"** (a board that will not parse, nowhere to write), and+ that answer says what to try next. A board with open connections, a board with findings and a+ failed test all answer `OK:` with the facts and `Hint:` lines.++Anyone changing this app should hold it to that: a new requirement, a mandatory field or a step+that refuses is a sign the binary has started doing the AI's work.++## Use++```bash+adom-wiki pkg install adom/adom-aiflow-routing+```++```bash+adom-aiflow-routing init --board placed.kicad_pcb --spec spec.json --ai-thread "<conversation>"+adom-aiflow-routing next # the suggested step and what to do in it+adom-aiflow-routing plan # how every net was sorted, and why+adom-aiflow-routing route # writes routing-flow/routed.kicad_pcb; the input is never changed+adom-aiflow-routing check # grades the routed board against the routing standards+adom-aiflow-routing steps # every step with its state+adom-aiflow-routing ledger # one line per command: time, AI thread, what happened+```++Every command answers `OK:` or `ERROR:` and then `Hint:` lines that depend on this board and this+run. A project keeps its state in `<project>/routing-flow/`: `state.json`, `run.jsonl` and the file+each step leaves. The verbs also work with no project: `plan <board>`, `route <board> -o <out>`,+`check <board>`, `draw`, `compare`, `strip`.++| Step | Its file | What the AI does |+|---|---|---|+| intake | `state.json` | `init` on the placed board, with the spec if the design has one |+| plan | `plan.txt` | reads how the nets were sorted; puts currents, pairs and priorities in the spec |+| route | `routed.kicad_pcb` | `route`; reads why each open connection failed |+| check | `check.json` | `check`, then KiCad's DRC with zones refilled |+| review | `review.md` | looks at the board; decides and writes down what was done about each open item |+| handoff | `handoff.json` | what the next stage needs: the board, what is open, what a person must decide |++The steps live in `flows/flow.json`; the skill is `SKILL.md` (also `skills/adom-aiflow-routing/`).++## Results++22 boards from public wiki pages were stripped of their tracks and vias and routed again. A+board counts as clean when every connection is made, KiCad reports 0 open, and KiCad finds no copper+error that the original does not also have. KiCad's DRC ran with zones refilled, under each board's+own rule values with clearance and open connections forced on.++| | Boards clean in KiCad |+|---|---|+| First run | 9 of 22 |+| Current revision | 19 of 22 |++++Three benchmark boards are routed from bare before every change is kept:++| Board | Parts | Connections | KiCad errors beyond the original's | KiCad open |+|---|---|---|---|---|+| PiProbe v1.3 | 60 | 96 of 96 | 0 | 0 |+| Benchmark B | 113 | 107 of 107 | 0 | 0 |+| Benchmark C | 160 | 300 of 302 | 0 | 2 |++Not good yet: a two-layer power board with many small pours stops at 207 of 239 connections; a dense+microcontroller board and a large receiver board each have 4 or 5 connections open; ground pads on+some boards keep a single thermal spoke where KiCad wants two; at least one board does not come out+identical on every run (see Build and test).++## All-board run: every board beside its original++<table>+<tr>+<td width="104"><a href="docs/results/README.md"><img src="docs/results/results-icon.png" width="112" alt="All-board run"></a><br><sub> All-board run </sub></td>+<td>Each board was routed from bare and is pictured beside its original, with the connections KiCad reports open ringed in red. <strong><a href="docs/results/README.md">The all-board run</a></strong> shows where the router stands board by board, including the ones it does not finish. It also holds the run ledger and the lessons, one record per finding with the fix and how it was verified.<br><br><a href="docs/results/README.md">Pictures, table and data</a></td>+</tr>+</table>++## How the router is built++It handles one connection at a time, in this order:++1. **Read and bind**: `board.rs`, `rules.rs`, `project.rs`+2. **Classify** nets into the routing hierarchy (pairs, data, power, the rest): `classify.rs`+3. **Plan** the board coarsely, group buses and plan rated supply rails: `global.rs`, `bus.rs`, `power.rs`+4. **Route** each connection with an exact 0/45/90 degree search: `router.rs`, `search.rs`+5. **Repair** connections that failed, by moving lower-priority routes: `repair.rs`+6. **Post passes** on finished copper: `post.rs`, `spread.rs`, `pour.rs`+7. **Check** the result against the rules: `check.rs`, `current.rs`, `mask.rs`+8. **Guide**: the steps, the project folder and the ledger: `flow.rs`, `flows/flow.json`++Rules come from the board's `.kicad_pro` (net classes, clearances, minimums). A board with no+project file is routed to KiCad's own defaults. A spec (JSON) adds what the board file cannot say:+each supply rail's source, voltage and loads, and routing priorities.++## Build and test++```bash+./build.sh # tests, release build, bin/adom-aiflow-routing+cargo test --release # unit tests only+adom-aiflow-routing fixtures <fixtures.json> # fast tests on your own reference boards+```++A fixture routes a few nets of one reference board alone and judges one behaviour: joined, clean of+named rules, a width near a part, the voltage drop to a load, a via count, a pair's coupling.+Changes to the router are kept only when the fixtures, three whole benchmark boards with KiCad's+DRC, and the all-board run agree. The reference boards and their fixture files are not on this+page; some unit tests read them and are skipped in spirit without them (they fail to find the file).++Every budget runs on a work clock (`src/clock.rs`): a "second" is a fixed amount of search work, not+wall time, so a result does not depend on how fast the machine is. Two runs of the same board are+meant to give the same file byte for byte. That does not hold on every board yet: on one public board+with ground pours, a run gives one of two slightly different results (one via and a few tracks apart).++## Supply rails++`src/power.rs` plans each rail that has a current in the spec as a whole before any copper is+laid: a tree from the source with loads joined heaviest first, each branch's current, and each+branch's width as the larger of the current chart and its share of a 0.25 % voltage-drop limit. A+rail gives way to signals by changing layer, never by thinning. A rail with no current in the spec+is drawn at a 0.40 mm floor and `plan` says so.++## What is on this page++| Path | What it is |+|---|---|+| `src/`, `Cargo.toml`, `Cargo.lock` | the source |+| `flows/flow.json` | the stage's steps |+| `SKILL.md`, `skills/adom-aiflow-routing/SKILL.md` | the skill |+| `LICENSE` | MIT |+| `bin/adom-aiflow-routing` | a Linux build of the current revision |+| `build.sh`, `install.sh`, `uninstall.sh` | build from source; install and uninstall hooks |+| `docs/results/` | pictures and data from the run on public boards |++Board files are not stored here; the pictures in `docs/results/` are renders of public boards.
SKILL.mdadded+101@@ -0,0 +1,101 @@+---+name: adom-aiflow-routing+description: >-+ The routing stage of AI Flow: takes a placed KiCad board (.kicad_pcb with its .kicad_pro) and routes+ every connection in the designer's hierarchy (pairs, data, power, the rest), then reports what it+ made, what it could not and why, graded against the routing standards and ready for KiCad's DRC.+ A dynamic skill: it suggests the next step and never blocks. Use when asked to route a board,+ autoroute a PCB, finish the routing, route the remaining nets, check a routed board against the+ routing standards, compare a routed board with a reference, or why a connection would not route.+ Trigger words: route the board, autoroute, adom-route, adom-aiflow-routing, routing stage, unrouted+ nets, ratsnest, differential pair routing, power rail width, routing check, strip and reroute.+---++# adom-aiflow-routing++The routing stage of AI Flow. `adom-route` is the same program under its older name.++## What a dynamic skill is (read this first)++adom-aiflow-routing is a **dynamic skill**: a small Rust binary that holds your hand through the+job. It tells you which step comes next, does the routine parts (the search, the bookkeeping, the+checks) and answers every command with hints.++- **You are about 97 % of the flow. The binary is about 3 %.** It is the guide and the checker; the+ thinking and the work are yours: reading the plan as a designer, finding the currents, deciding+ what to do about a connection that would not route, looking at the board.+- **It is never a hard gate, at any step.** `next` names a suggested step. No command checks that+ an earlier one ran. Take another route when you have a better one, and say what you did and why.+- **Findings are gentle suggestions.** `check` and `route` report what they saw and offer a next+ action. Weigh it, fix what is real, carry on. You and the human decide.+- **It does not do your job.** It encodes no rule about what you may do next, so a better model+ loses nothing by using it.+- **Its only error is "I could not run"** (a file that is missing or will not parse, nowhere to+ write), and that answer says what to try next. A board with open connections, a board with+ findings and a failed fixture all answer `OK:` with the facts and hints.++When you change this app, keep it this way: add hints and checks that report, never requirements,+mandatory fields or steps that refuse.++## Use++Run `adom-aiflow-routing next` whenever you are unsure what to do. Every command answers `OK:` or+`ERROR:` and then `Hint:` lines that depend on this board and this run. In a project, every command+adds a line to `<project>/routing-flow/run.jsonl`.++```+adom-aiflow-routing init --board <placed.kicad_pcb> [--spec <spec.json>] --ai-thread <you>+adom-aiflow-routing next # the suggested step and what to do in it+adom-aiflow-routing plan # how every net was sorted, and why+adom-aiflow-routing route # writes routing-flow/routed.kicad_pcb; the input is never changed+adom-aiflow-routing check # grades the routed board against the routing standards+adom-aiflow-routing steps # every step with its state+adom-aiflow-routing ledger # what was run, by whom, when+```++The verbs also work with no project: `plan <board>`, `route <board> -o <out>`, `check <board>`,+`draw <board> -o <svg> [--window x0,y0,x1,y1] [--highlight net,net]`,+`compare <board> <reference>`, `strip <board> -o <out>`.++## The steps++| Step | Its file | What you do |+|---|---|---|+| intake | `state.json` | `init` on the placed board, with the spec if the design has one |+| plan | `plan.txt` | read how the nets were sorted; put currents, pairs and priorities in the spec |+| route | `routed.kicad_pcb` | `route`; read why each open connection failed |+| check | `check.json` | `check`, then KiCad's DRC with zones refilled |+| review | `review.md` | look at the board; decide and write down what you did about each open item |+| handoff | `handoff.json` | what the next stage needs: the board, what is open, what a person must decide |++## What matters++- **KiCad is the judge.** Run its DRC with zones refilled (`kicad-cli pcb drc --refill-zones`)+ against the board's own project on the routed file. A project can turn clearance or open+ connections down to a warning or carry exclusions; judge with those checks on, and report the+ designer's own exceptions rather than hiding them.+- **Look at the board.** `draw` writes a picture; put it beside the reference or the earlier+ revision and show the human, with the open places marked. A count is a claim, a picture is evidence.+- **Supply rails need a current.** With no spec a rail is drawn at the 0.40 mm floor. A spec entry+ per rail (`rails`, by net name: `sources`, `volts`, `amps` and `loads` by part) lets the power+ stage plan its tree and size each branch by current and voltage drop. A rail the board makes+ itself is rated by its regulator. A rail the board only receives is sized by the draw of the parts+ that take it: look in the prompt and on those parts' wiki pages and datasheets. When nothing says,+ ask the human for the expected draw; do not guess it from a name or a pad.+- **A fixture mounted on the board keeps its side clear.** Where screw holes take a fixture, the+ copper layer that touches the fixture carries no tracks under it other than ground, wherever the+ board allows. The router does not know a hole is for a fixture: find that in the prompt or the+ design notes, check the routed board for it, and say what you found in the review.+- **An open connection has a reason.** `route` prints it: walled in by pads or fills, out of search+ budget, held by a protected route. Moving a part or giving a rail its current often does more+ than more time.+- **Local pad rules are proposals.** Where no track fits a pad under its class clearance, the+ router writes a rule beside the output (`.kicad_dru`) and marks it REVIEW. It is the human's to+ accept.+- **The input board is never changed.** Every result is a new file.++## Debug switches++`ADOM_ROUTE_TRACE=1` prints every search. `ADOM_ROUTE_SNAP_FAIL=<file>` with+`ADOM_ROUTE_SNAP_PAD=<U2.19>` writes the board as it stood when that pad's connection first failed.+`ADOM_ROUTE_DEBUG_VIA=x,y` prints what the checker sees round one via.
bin/adom-aiflow-routingadded⋯ 1 unchanged line ⋯
build.shadded+11@@ -0,0 +1,11 @@+#!/bin/bash+# Build the Linux binary from source and put it where the page ships it (bin/). Anyone can run this.+set -e+HERE="$(cd "$(dirname "$0")" && pwd)"; cd "$HERE"+NAME=adom-aiflow-routing+cargo test --release+# The shipped binary carries no paths from the machine that built it.+RUSTFLAGS="--remap-path-prefix=$HOME/.cargo/registry/src=crates --remap-path-prefix=$HOME/.rustup=rustup --remap-path-prefix=$HERE=." cargo build --release+mkdir -p bin && cp "target/release/$NAME" "bin/$NAME"+ln -sf "$NAME" target/release/adom-route # the older name, for scripts that still use it+echo "OK: bin/$NAME ($(bin/$NAME --version))"
docs/hero.pngadded⋯ 1 unchanged line ⋯
flows/flow.jsonadded+12@@ -0,0 +1,12 @@+{+ "name": "routing",+ "description": "From a placed KiCad board to a routed one with its report: every connection the router could make, what it could not, and why.",+ "steps": [+ {"name": "intake", "done": ["state.json"], "do": "Run `init --board <placed board.kicad_pcb>` (add `--spec <spec.json>` when the design has one). The board's own .kicad_pro beside it carries the rules; without one the tool works to KiCad's defaults and says so. The input board is never changed."},+ {"name": "plan", "done": ["plan.txt"], "do": "Run `plan`. It lists every net with the class it was sorted into (pairs, data, power, plane, the rest) and the reason. Read it as a designer would: a supply rail with no current, a pair that was not found, an RF or sense net treated as plain data are yours to correct in the spec (rails with volts and amps, pairs, priority). A rail the board does not generate itself is sized by the draw of the parts that receive it: look for that in the prompt and on the parts' wiki pages and datasheets, and when nothing says, ask the human for the expected draw. Run `plan` again after a change."},+ {"name": "route", "done": ["routed.kicad_pcb"], "do": "Run `route`. It writes the routed board, lists every connection it could not make with the reason, and any local pad rule it proposes for the human's review. A board with open connections is still a result: read why each one failed before deciding what to do."},+ {"name": "check", "done": ["check.json"], "do": "Run `check`. Then run KiCad's own DRC with zones refilled against the board's project; KiCad is the judge of clearance and of what is really joined. Both are reports, not verdicts: a finding on copper the designer drew on purpose stays the designer's call."},+ {"name": "review", "done": ["review.md"], "do": "Look at the board, do not only read the numbers: `draw` writes a picture, `compare` sets it beside a reference board. For each open connection and each finding decide and write down in review.md what you did: moved a part, changed the spec and routed again, routed it by hand, or left it with the reason. Show the human the picture with the open places marked."},+ {"name": "handoff", "done": ["handoff.json"], "do": "Write handoff.json for whatever comes next: the routed board, connections made and open, the check and KiCad results, the local rules proposed, and what is left for a person to decide."}+ ]+}
install.shadded+19@@ -0,0 +1,19 @@+#!/bin/bash+# Package install hook: the binary to ~/.local/bin (also under its older name adom-route), the skill to+# ~/.claude/skills and ~/.codex/skills. Builds from source when the shipped binary does not run here.+set -e+HERE="$(cd "$(dirname "$0")" && pwd)"+NAME=adom-aiflow-routing+if ! "$HERE/bin/$NAME" --version >/dev/null 2>&1; then+ command -v cargo >/dev/null || { echo "ERROR: no binary for this machine and no cargo to build one"; echo "Hint: install Rust from https://rustup.rs, then run ./install.sh again"; exit 1; }+ (cd "$HERE" && cargo build --release && mkdir -p bin && cp "target/release/$NAME" "bin/$NAME")+fi+mkdir -p "$HOME/.local/bin"+install -m 755 "$HERE/bin/$NAME" "$HOME/.local/bin/.$NAME-new" && mv -f "$HOME/.local/bin/.$NAME-new" "$HOME/.local/bin/$NAME"+ln -sf "$NAME" "$HOME/.local/bin/adom-route"+for skill in "$HERE"/skills/*; do+ [ -f "$skill/SKILL.md" ] || continue+ for consumer in .claude .codex; do mkdir -p "$HOME/$consumer/skills/$(basename "$skill")"; cp "$skill/SKILL.md" "$HOME/$consumer/skills/$(basename "$skill")/SKILL.md"; done+done+echo "OK: $NAME installed (also as adom-route). Run '$NAME --help'."+echo "Hint: KiCad's own DRC is the judge of a routed board; have kicad-cli (KiCad 9 or 10) reachable for the check step."
package.jsonadded+76@@ -0,0 +1,76 @@+{+ "slug": "adom-aiflow-routing",+ "type": "app",+ "version": "0.2.0",+ "title": "AI Flow: Routing",+ "description": "The routing stage of AI Flow: adom-aiflow-routing takes a placed KiCad board, routes every connection in the designer's hierarchy (pairs, data, power, the rest) with an exact 0/45/90 degree search, and reports what it made, what it could not and why. A dynamic skill: it suggests the next step and never blocks. The full Rust source ships on this page; build it with ./build.sh.",+ "category": "Developer Tools",+ "bin": {+ "adom-aiflow-routing": "bin/adom-aiflow-routing"+ },+ "dependencies": {},+ "scripts": {+ "install": "./install.sh",+ "uninstall": "./uninstall.sh"+ },+ "bundled": true,+ "brief": "The routing stage of AI Flow: routes a placed KiCad board connection by connection and reports what it made, what it could not and why. A guide, never a gate.",+ "discovery_pitch": "Reach for this when a placed KiCad board needs its tracks: it sorts the nets as a designer would, routes them, grades the result against the routing standards and tells you what to look at next.",+ "discovery_triggers": [+ "route the board",+ "autoroute my pcb",+ "finish the routing",+ "route the remaining nets",+ "adom-aiflow-routing",+ "adom-route",+ "why will this net not route",+ "check the routing against the standards",+ "strip the board and route it again",+ "ai flow routing"+ ],+ "sample_prompts": [+ {+ "label": "Route a placed board",+ "prompt": "Route this placed KiCad board with adom-aiflow-routing, then show me the result beside the original with anything still open marked."+ },+ {+ "label": "Explain an open connection",+ "prompt": "Three connections did not route on this board. Find out why each one failed and what would fix it."+ },+ {+ "label": "Grade a routed board",+ "prompt": "Check this routed board against the routing standards and KiCad's DRC and tell me which findings are real."+ }+ ],+ "files": [+ "README.md",+ "SKILL.md",+ "LICENSE",+ "package.json",+ "Cargo.toml",+ "Cargo.lock",+ "build.sh",+ "install.sh",+ "uninstall.sh",+ "src/**",+ "flows/**",+ "skills/**",+ "bin/**",+ "docs/hero.png",+ "screenshots/showcase.png"+ ],+ "license": "MIT",+ "hero": {+ "path": "docs/hero.png"+ },+ "tags": [+ "pcb",+ "routing",+ "autorouter",+ "kicad",+ "ai-flow",+ "dynamic-skill",+ "rust",+ "cli"+ ]+}
page.jsonadded+83@@ -0,0 +1,83 @@+{+ "slug": "adom-aiflow-routing",+ "type": "app",+ "version": "0.2.0",+ "title": "AI Flow: Routing",+ "description": "The routing stage of AI Flow: adom-aiflow-routing takes a placed KiCad board, routes every connection in the designer's hierarchy (pairs, data, power, the rest) with an exact 0/45/90 degree search, and reports what it made, what it could not and why. A dynamic skill: it suggests the next step and never blocks. The full Rust source ships on this page; build it with ./build.sh.",+ "category": "Developer Tools",+ "bin": {+ "adom-aiflow-routing": "bin/adom-aiflow-routing"+ },+ "dependencies": {},+ "scripts": {+ "install": "./install.sh",+ "uninstall": "./uninstall.sh"+ },+ "bundled": true,+ "brief": "The routing stage of AI Flow: routes a placed KiCad board connection by connection and reports what it made, what it could not and why. A guide, never a gate.",+ "discovery_pitch": "Reach for this when a placed KiCad board needs its tracks: it sorts the nets as a designer would, routes them, grades the result against the routing standards and tells you what to look at next.",+ "discovery_triggers": [+ "route the board",+ "autoroute my pcb",+ "finish the routing",+ "route the remaining nets",+ "adom-aiflow-routing",+ "adom-route",+ "why will this net not route",+ "check the routing against the standards",+ "strip the board and route it again",+ "ai flow routing"+ ],+ "sample_prompts": [+ {+ "label": "Route a placed board",+ "prompt": "Route this placed KiCad board with adom-aiflow-routing, then show me the result beside the original with anything still open marked."+ },+ {+ "label": "Explain an open connection",+ "prompt": "Three connections did not route on this board. Find out why each one failed and what would fix it."+ },+ {+ "label": "Grade a routed board",+ "prompt": "Check this routed board against the routing standards and KiCad's DRC and tell me which findings are real."+ }+ ],+ "files": [+ "README.md",+ "SKILL.md",+ "LICENSE",+ "package.json",+ "Cargo.toml",+ "Cargo.lock",+ "build.sh",+ "install.sh",+ "uninstall.sh",+ "src/**",+ "flows/**",+ "skills/**",+ "bin/**",+ "docs/hero.png",+ "screenshots/showcase.png"+ ],+ "license": "MIT",+ "hero": {+ "type": "image",+ "path": "docs/hero.png"+ },+ "tags": [+ "pcb",+ "routing",+ "autorouter",+ "kicad",+ "ai-flow",+ "dynamic-skill",+ "rust",+ "cli"+ ],+ "visibility": "public",+ "org": "adom",+ "author": {+ "name": "Caleb",+ "email": "[email protected]"+ }+}
skills/adom-aiflow-routing/SKILL.mdadded+101@@ -0,0 +1,101 @@+---+name: adom-aiflow-routing+description: >-+ The routing stage of AI Flow: takes a placed KiCad board (.kicad_pcb with its .kicad_pro) and routes+ every connection in the designer's hierarchy (pairs, data, power, the rest), then reports what it+ made, what it could not and why, graded against the routing standards and ready for KiCad's DRC.+ A dynamic skill: it suggests the next step and never blocks. Use when asked to route a board,+ autoroute a PCB, finish the routing, route the remaining nets, check a routed board against the+ routing standards, compare a routed board with a reference, or why a connection would not route.+ Trigger words: route the board, autoroute, adom-route, adom-aiflow-routing, routing stage, unrouted+ nets, ratsnest, differential pair routing, power rail width, routing check, strip and reroute.+---++# adom-aiflow-routing++The routing stage of AI Flow. `adom-route` is the same program under its older name.++## What a dynamic skill is (read this first)++adom-aiflow-routing is a **dynamic skill**: a small Rust binary that holds your hand through the+job. It tells you which step comes next, does the routine parts (the search, the bookkeeping, the+checks) and answers every command with hints.++- **You are about 97 % of the flow. The binary is about 3 %.** It is the guide and the checker; the+ thinking and the work are yours: reading the plan as a designer, finding the currents, deciding+ what to do about a connection that would not route, looking at the board.+- **It is never a hard gate, at any step.** `next` names a suggested step. No command checks that+ an earlier one ran. Take another route when you have a better one, and say what you did and why.+- **Findings are gentle suggestions.** `check` and `route` report what they saw and offer a next+ action. Weigh it, fix what is real, carry on. You and the human decide.+- **It does not do your job.** It encodes no rule about what you may do next, so a better model+ loses nothing by using it.+- **Its only error is "I could not run"** (a file that is missing or will not parse, nowhere to+ write), and that answer says what to try next. A board with open connections, a board with+ findings and a failed fixture all answer `OK:` with the facts and hints.++When you change this app, keep it this way: add hints and checks that report, never requirements,+mandatory fields or steps that refuse.++## Use++Run `adom-aiflow-routing next` whenever you are unsure what to do. Every command answers `OK:` or+`ERROR:` and then `Hint:` lines that depend on this board and this run. In a project, every command+adds a line to `<project>/routing-flow/run.jsonl`.++```+adom-aiflow-routing init --board <placed.kicad_pcb> [--spec <spec.json>] --ai-thread <you>+adom-aiflow-routing next # the suggested step and what to do in it+adom-aiflow-routing plan # how every net was sorted, and why+adom-aiflow-routing route # writes routing-flow/routed.kicad_pcb; the input is never changed+adom-aiflow-routing check # grades the routed board against the routing standards+adom-aiflow-routing steps # every step with its state+adom-aiflow-routing ledger # what was run, by whom, when+```++The verbs also work with no project: `plan <board>`, `route <board> -o <out>`, `check <board>`,+`draw <board> -o <svg> [--window x0,y0,x1,y1] [--highlight net,net]`,+`compare <board> <reference>`, `strip <board> -o <out>`.++## The steps++| Step | Its file | What you do |+|---|---|---|+| intake | `state.json` | `init` on the placed board, with the spec if the design has one |+| plan | `plan.txt` | read how the nets were sorted; put currents, pairs and priorities in the spec |+| route | `routed.kicad_pcb` | `route`; read why each open connection failed |+| check | `check.json` | `check`, then KiCad's DRC with zones refilled |+| review | `review.md` | look at the board; decide and write down what you did about each open item |+| handoff | `handoff.json` | what the next stage needs: the board, what is open, what a person must decide |++## What matters++- **KiCad is the judge.** Run its DRC with zones refilled (`kicad-cli pcb drc --refill-zones`)+ against the board's own project on the routed file. A project can turn clearance or open+ connections down to a warning or carry exclusions; judge with those checks on, and report the+ designer's own exceptions rather than hiding them.+- **Look at the board.** `draw` writes a picture; put it beside the reference or the earlier+ revision and show the human, with the open places marked. A count is a claim, a picture is evidence.+- **Supply rails need a current.** With no spec a rail is drawn at the 0.40 mm floor. A spec entry+ per rail (`rails`, by net name: `sources`, `volts`, `amps` and `loads` by part) lets the power+ stage plan its tree and size each branch by current and voltage drop. A rail the board makes+ itself is rated by its regulator. A rail the board only receives is sized by the draw of the parts+ that take it: look in the prompt and on those parts' wiki pages and datasheets. When nothing says,+ ask the human for the expected draw; do not guess it from a name or a pad.+- **A fixture mounted on the board keeps its side clear.** Where screw holes take a fixture, the+ copper layer that touches the fixture carries no tracks under it other than ground, wherever the+ board allows. The router does not know a hole is for a fixture: find that in the prompt or the+ design notes, check the routed board for it, and say what you found in the review.+- **An open connection has a reason.** `route` prints it: walled in by pads or fills, out of search+ budget, held by a protected route. Moving a part or giving a rail its current often does more+ than more time.+- **Local pad rules are proposals.** Where no track fits a pad under its class clearance, the+ router writes a rule beside the output (`.kicad_dru`) and marks it REVIEW. It is the human's to+ accept.+- **The input board is never changed.** Every result is a new file.++## Debug switches++`ADOM_ROUTE_TRACE=1` prints every search. `ADOM_ROUTE_SNAP_FAIL=<file>` with+`ADOM_ROUTE_SNAP_PAD=<U2.19>` writes the board as it stood when that pad's connection first failed.+`ADOM_ROUTE_DEBUG_VIA=x,y` prints what the checker sees round one via.
src/board.rsadded+723@@ -0,0 +1,723 @@+//! The board as this router sees it: copper layers, nets, footprints and pads with their exact+//! outlines, tracks, vias, zones, the edge and every hole. Read straight from a `.kicad_pcb`.++use crate::geom::*;+use crate::sx::{self, Node};+use std::collections::{BTreeMap, HashMap};++pub type NetId = u32;+pub const NO_NET: NetId = 0;++#[derive(Clone, Debug)]+pub struct Layer {+ pub name: String,+ pub signal: bool,+}++#[derive(Clone, Debug)]+pub struct Pad {+ pub fp: usize,+ pub number: String,+ pub net: NetId,+ pub center: P,+ pub shape: Shape,+ /// Copper layers the pad exists on (indices into `Board::layers`).+ pub layers: Vec<usize>,+ pub drill: Option<f64>,+ pub npth: bool,+ /// The pad's own clearance override, else its footprint's, in nm (0 when it has none). KiCad's+ /// DRC holds other copper to it even where the net class allows less.+ pub local_clearance: f64,+ /// The pad's own way of joining a pour (`zone_connect`: 0 none, 1 thermal relief, 2 solid, 3+ /// thermal for through-hole only), its footprint's if it has none, else the zone decides.+ pub zone_connect: Option<u8>,+ pub size: (f64, f64),+ pub angle: f64,+ /// Mask opening beyond the copper, resolved the way KiCad does: the pad's own margin, else the+ /// footprint's, else the board's pad_to_mask_clearance.+ pub mask_margin: f64,+ /// Opening in F.Mask and in B.Mask. A pad listing no mask layer has no opening at all.+ pub mask: (bool, bool),+ pub paste: bool,+ /// The schematic's electrical type (power_in, input, ...), empty when the file has none.+ pub pintype: String,+ /// The schematic's pin name (VCC_5, IOVDD_1, ...), empty when the file has none.+ pub pinfunction: String,+}++#[derive(Clone, Debug)]+pub struct Footprint {+ pub reference: String,+ pub value: String,+ pub lib: String,+ pub at: P,+ pub bottom: bool,+ pub pads: Vec<usize>,+ /// Smallest centre-to-centre distance between two of its copper pads, nm.+ pub min_pitch: f64,+ pub dnp: bool,+}++#[derive(Clone, Debug)]+pub struct Track {+ pub net: NetId,+ pub layer: usize,+ pub a: P,+ pub b: P,+ pub width: i64,+ pub span: (usize, usize),+}++#[derive(Clone, Debug)]+pub struct Via {+ pub net: NetId,+ pub at: P,+ pub size: i64,+ pub drill: i64,+ pub span: (usize, usize),+ /// Mask over the via on the front and back (4.4: every via tented on both sides).+ pub tent: (bool, bool),+}++/// A teardrop's filled copper, as KiCad wrote it. It is not an obstacle to routing but it does+/// count for solder mask, so the mask check reads it.+#[derive(Clone, Debug)]+pub struct Teardrop {+ pub net: NetId,+ pub layer: usize,+ pub pts: Vec<V>,+}++/// Board-wide mask settings from `(setup ...)`.+#[derive(Clone, Debug)]+pub struct MaskSetup {+ pub pad_to_mask: f64,+ /// Smallest web of mask between two openings.+ pub web: f64,+ pub allow_bridges_in_footprints: bool,+ pub tent: (bool, bool),+}++#[derive(Clone, Debug)]+pub struct Zone {+ pub net: NetId,+ pub layers: Vec<usize>,+ pub outline: Vec<V>,+ /// Filled copper per layer, as read from the file (empty when the zone is not filled).+ pub fills: Vec<(usize, Vec<V>)>,+ pub keepout_tracks: bool,+ pub keepout_vias: bool,+ pub priority: i64,+ pub name: String,+ /// `(keepout (copperpour not_allowed))`: no zone fills here.+ pub keepout_pour: bool,+ /// The zone's own clearance, nm (`connect_pads (clearance x)`), 0 when it sets none.+ pub clearance: f64,+ /// `min_thickness`, nm: copper narrower than this does not survive a refill.+ pub min_thickness: f64,+ /// `connect_pads no`: the fill keeps clear of pads of its own net.+ pub joins_pads: bool,+ /// Thermal reliefs (the default `connect_pads`): the gap round a pad and the width of the spokes+ /// across it, nm. None: pads join the fill solid (`connect_pads yes`) or not at all.+ pub thermal: Option<(f64, f64)>,+ /// `connect_pads thru_hole_only`: reliefs for through-hole pads, solid for the rest.+ pub thermal_tht_only: bool,+}++#[derive(Clone, Debug)]+pub struct Board {+ pub text: String,+ pub layers: Vec<Layer>,+ pub nets: Vec<String>,+ pub net_ids: HashMap<String, NetId>,+ pub footprints: Vec<Footprint>,+ pub pads: Vec<Pad>,+ pub tracks: Vec<Track>,+ pub vias: Vec<Via>,+ pub zones: Vec<Zone>,+ pub edges: Vec<(V, V)>,+ pub bbox: (f64, f64, f64, f64),+ /// Teardrops are stored as small zones. They are copper KiCad regenerates from the routing,+ /// so they are not obstacles, and they go when the routing goes.+ pub teardrop_spans: Vec<(usize, usize)>,+ pub teardrops: Vec<Teardrop>,+ pub mask: MaskSetup,+}++fn xy(n: &Node) -> Option<V> {+ Some(V(n.num(1)? * NM, n.num(2)? * NM))+}++impl Board {+ pub fn read(path: &str) -> Result<Board, String> {+ let text = std::fs::read_to_string(path).map_err(|e| format!("{path}: {e}"))?;+ Board::parse(text)+ }++ pub fn parse(text: String) -> Result<Board, String> {+ let root = sx::parse(&text)?;+ if root.head() != "kicad_pcb" {+ return Err("not a kicad_pcb file".into());+ }+ let mut b = Board {+ text: String::new(),+ layers: Vec::new(),+ nets: vec![String::new()],+ net_ids: HashMap::new(),+ footprints: Vec::new(),+ pads: Vec::new(),+ tracks: Vec::new(),+ vias: Vec::new(),+ zones: Vec::new(),+ edges: Vec::new(),+ bbox: (f64::MAX, f64::MAX, f64::MIN, f64::MIN),+ teardrop_spans: Vec::new(),+ teardrops: Vec::new(),+ mask: MaskSetup { pad_to_mask: 0.0, web: 0.0, allow_bridges_in_footprints: false, tent: (true, true) },+ };+ if let Some(s) = root.child("setup") {+ let num = |k: &str| s.child(k).and_then(|x| x.num(1)).unwrap_or(0.0) * NM;+ b.mask.pad_to_mask = num("pad_to_mask_clearance");+ b.mask.web = num("solder_mask_min_width");+ b.mask.allow_bridges_in_footprints = s.child("allow_soldermask_bridges_in_footprints").and_then(|x| x.atom(1)) == Some("yes");+ b.mask.tent = tent_of(s, (true, true));+ }+ b.net_ids.insert(String::new(), NO_NET);++ // Copper layers in stack order: F.Cu, inner layers by number, B.Cu.+ let mut inner: BTreeMap<u32, Layer> = BTreeMap::new();+ let (mut top, mut bot) = (None, None);+ if let Some(ls) = root.child("layers") {+ for l in ls.items() {+ let (Some(name), Some(kind)) = (l.atom(1), l.atom(2)) else { continue };+ if !name.ends_with(".Cu") {+ continue;+ }+ let layer = Layer { name: name.to_string(), signal: kind == "signal" || kind == "mixed" };+ if name == "F.Cu" {+ top = Some(layer);+ } else if name == "B.Cu" {+ bot = Some(layer);+ } else if let Some(n) = name.strip_prefix("In").and_then(|s| s.strip_suffix(".Cu")).and_then(|s| s.parse().ok()) {+ inner.insert(n, layer);+ }+ }+ }+ b.layers.extend(top);+ b.layers.extend(inner.into_values());+ b.layers.extend(bot);+ if b.layers.len() < 2 {+ return Err("board has fewer than two copper layers".into());+ }++ // Older files carry a net table; KiCad 10 writes the net by name on each item.+ let mut table: HashMap<i64, String> = HashMap::new();+ for n in root.children("net") {+ if let (Some(id), Some(name)) = (n.num(1), n.atom(2)) {+ table.insert(id as i64, name.to_string());+ }+ }+ let net_of = |b: &mut Board, node: &Node| -> NetId {+ let Some(n) = node.child("net") else { return NO_NET };+ let name = match (n.atom(1), n.atom(2)) {+ (_, Some(name)) => name.to_string(),+ (Some(a), None) => {+ if let Some(name) = a.parse::<i64>().ok().and_then(|id| table.get(&id)) {+ name.clone()+ } else {+ a.to_string()+ }+ }+ _ => String::new(),+ };+ b.intern(&name)+ };++ for n in root.items() {+ match n.head() {+ "footprint" | "module" => b.read_footprint(n, &net_of)?,+ "segment" => {+ let (Some(s), Some(e)) = (n.child("start").and_then(xy), n.child("end").and_then(xy)) else { continue };+ let Some(layer) = n.child("layer").and_then(|l| l.atom(1)).and_then(|l| b.layer_index(l)) else { continue };+ let width = mm(n.child("width").and_then(|w| w.num(1)).unwrap_or(0.0));+ let net = net_of(&mut b, n);+ b.tracks.push(Track { net, layer, a: to_p(s), b: to_p(e), width, span: n.span() });+ }+ "arc" => {+ let (Some(s), Some(m), Some(e)) = (n.child("start").and_then(xy), n.child("mid").and_then(xy), n.child("end").and_then(xy)) else { continue };+ let Some(layer) = n.child("layer").and_then(|l| l.atom(1)).and_then(|l| b.layer_index(l)) else { continue };+ let width = mm(n.child("width").and_then(|w| w.num(1)).unwrap_or(0.0));+ let net = net_of(&mut b, n);+ let pts = arc_points(s, m, e, 50_000.0);+ for w in pts.windows(2) {+ b.tracks.push(Track { net, layer, a: to_p(w[0]), b: to_p(w[1]), width, span: n.span() });+ }+ }+ "via" => {+ let Some(at) = n.child("at").and_then(xy) else { continue };+ let size = mm(n.child("size").and_then(|w| w.num(1)).unwrap_or(0.0));+ let drill = mm(n.child("drill").and_then(|w| w.num(1)).unwrap_or(0.0));+ let net = net_of(&mut b, n);+ let tent = tent_of(n, b.mask.tent);+ b.vias.push(Via { net, at: to_p(at), size, drill, span: n.span(), tent });+ }+ "zone" => b.read_zone(n, &net_of),+ "gr_line" | "gr_arc" | "gr_rect" | "gr_circle" | "gr_poly" => b.read_edge(n, &|v| v),+ _ => {}+ }+ }+ for e in b.edges.clone() {+ b.bbox = (b.bbox.0.min(e.0 .0).min(e.1 .0), b.bbox.1.min(e.0 .1).min(e.1 .1), b.bbox.2.max(e.0 .0).max(e.1 .0), b.bbox.3.max(e.0 .1).max(e.1 .1));+ }+ b.text = text;+ Ok(b)+ }++ pub fn intern(&mut self, name: &str) -> NetId {+ if let Some(&i) = self.net_ids.get(name) {+ return i;+ }+ let i = self.nets.len() as NetId;+ self.nets.push(name.to_string());+ self.net_ids.insert(name.to_string(), i);+ i+ }++ pub fn layer_index(&self, name: &str) -> Option<usize> {+ self.layers.iter().position(|l| l.name == name)+ }++ pub fn net_name(&self, n: NetId) -> &str {+ &self.nets[n as usize]+ }++ pub fn pad_name(&self, i: usize) -> String {+ let p = &self.pads[i];+ format!("{}.{}", self.footprints[p.fp].reference, p.number)+ }++ /// Layer indices named by a `(layers ...)` list, wildcards expanded.+ fn expand_layers(&self, names: &[&str]) -> Vec<usize> {+ let mut out = Vec::new();+ for n in names {+ match *n {+ "*.Cu" => out.extend(0..self.layers.len()),+ "F&B.Cu" => {+ out.push(0);+ out.push(self.layers.len() - 1);+ }+ other => out.extend(self.layer_index(other)),+ }+ }+ out.sort_unstable();+ out.dedup();+ out+ }++ fn read_footprint(&mut self, n: &Node, net_of: &dyn Fn(&mut Board, &Node) -> NetId) -> Result<(), String> {+ let at = n.child("at").ok_or("footprint without (at)")?;+ let (fx, fy) = (at.num(1).unwrap_or(0.0) * NM, at.num(2).unwrap_or(0.0) * NM);+ let frot = at.num(3).unwrap_or(0.0);+ let bottom = n.child("layer").and_then(|l| l.atom(1)) == Some("B.Cu");+ let mut reference = String::new();+ let mut value = String::new();+ for p in n.children("property") {+ match p.atom(1) {+ Some("Reference") => reference = p.atom(2).unwrap_or("").to_string(),+ Some("Value") => value = p.atom(2).unwrap_or("").to_string(),+ _ => {}+ }+ }+ for t in n.children("fp_text") {+ match t.atom(1) {+ Some("reference") if reference.is_empty() => reference = t.atom(2).unwrap_or("").to_string(),+ Some("value") if value.is_empty() => value = t.atom(2).unwrap_or("").to_string(),+ _ => {}+ }+ }+ let dnp = n.child("attr").map(|a| a.atoms().contains(&"dnp")).unwrap_or(false);+ let fp_idx = self.footprints.len();+ let lib = n.atom(1).unwrap_or("").to_string();+ let fp_margin = n.child("solder_mask_margin").and_then(|m| m.num(1));+ let mut fp = Footprint { reference, value, lib, at: (fx as i64, fy as i64), bottom, pads: Vec::new(), min_pitch: f64::INFINITY, dnp };+ for p in n.children("pad") {+ let kind = p.atom(2).unwrap_or("smd");+ let shape_name = p.atom(3).unwrap_or("rect");+ let pat = p.child("at").ok_or("pad without (at)")?;+ let (lx, ly) = (pat.num(1).unwrap_or(0.0) * NM, pat.num(2).unwrap_or(0.0) * NM);+ let pang = pat.num(3).unwrap_or(0.0);+ let (rx, ry) = rot(lx, ly, frot);+ let (cx, cy) = (fx + rx, fy + ry);+ let size = p.child("size").map(|s| (s.num(1).unwrap_or(0.0) * NM, s.num(2).unwrap_or(0.0) * NM)).unwrap_or((0.0, 0.0));+ let names = p.child("layers").map(|l| l.atoms().into_iter().skip(1).collect::<Vec<_>>()).unwrap_or_default();+ let layers = self.expand_layers(&names);+ let drill_node = p.child("drill");+ let drill = drill_node.and_then(|d| d.atoms().iter().skip(1).find_map(|a| a.parse::<f64>().ok())).map(|d| d * NM);+ let off = drill_node.and_then(|d| d.child("offset")).map(|o| (o.num(1).unwrap_or(0.0) * NM, o.num(2).unwrap_or(0.0) * NM)).unwrap_or((0.0, 0.0));+ let (ox, oy) = rot(off.0, off.1, pang);+ let center = V(cx + ox, cy + oy);+ let rr = p.child("roundrect_rratio").and_then(|r| r.num(1)).unwrap_or(0.0);+ let delta = p.child("rect_delta").map(|d| (d.num(1).unwrap_or(0.0) * NM, d.num(2).unwrap_or(0.0) * NM));+ let shape = pad_shape(shape_name, center, size, pang, rr, delta, p, kind);+ let net = net_of(self, p);+ let npth = kind == "np_thru_hole";+ let pi = self.pads.len();+ let mask_margin = p.child("solder_mask_margin").and_then(|m| m.num(1)).or(fp_margin).map(|m| m * NM).unwrap_or(self.mask.pad_to_mask);+ let has = |l: &str| names.iter().any(|n| *n == l || *n == "*.Mask" && l.ends_with(".Mask") || *n == "F&B.Mask" && l.ends_with(".Mask"));+ let mask = (has("F.Mask"), has("B.Mask"));+ let paste = names.iter().any(|n| n.ends_with(".Paste"));+ self.pads.push(Pad { fp: fp_idx, number: p.atom(1).unwrap_or("").to_string(), net, center: to_p(V(cx, cy)), shape, layers: if npth { Vec::new() } else { layers }, drill, npth, local_clearance: p.child("clearance").or(n.child("clearance")).and_then(|c| c.num(1)).unwrap_or(0.0) * NM, zone_connect: p.child("zone_connect").or(n.child("zone_connect")).and_then(|c| c.num(1)).map(|v| v as u8), size, angle: pang, mask_margin, mask, paste, pintype: p.child("pintype").and_then(|t| t.atom(1)).unwrap_or("").to_string(), pinfunction: p.child("pinfunction").and_then(|t| t.atom(1)).unwrap_or("").to_string() });+ fp.pads.push(pi);+ }+ for i in 0..fp.pads.len() {+ for j in i + 1..fp.pads.len() {+ let (a, b) = (&self.pads[fp.pads[i]], &self.pads[fp.pads[j]]);+ if a.layers.is_empty() || b.layers.is_empty() {+ continue;+ }+ let d = hypot(sub(a.center.into(), b.center.into()));+ if d > 1.0 {+ fp.min_pitch = fp.min_pitch.min(d);+ }+ }+ }+ self.footprints.push(fp);+ for z in n.children("zone") {+ self.read_zone(z, net_of);+ }+ let xf = |v: V| {+ let (x, y) = rot(v.0, v.1, frot);+ V(fx + x, fy + y)+ };+ for k in ["fp_line", "fp_arc", "fp_rect", "fp_circle", "fp_poly"] {+ for g in n.children(k) {+ self.read_edge(g, &xf);+ }+ }+ Ok(())+ }++ fn read_edge(&mut self, n: &Node, xf: &dyn Fn(V) -> V) {+ if n.child("layer").and_then(|l| l.atom(1)) != Some("Edge.Cuts") {+ return;+ }+ let head = n.head();+ if head.ends_with("_line") {+ if let (Some(a), Some(b)) = (n.child("start").and_then(xy), n.child("end").and_then(xy)) {+ self.edges.push((xf(a), xf(b)));+ }+ } else if head.ends_with("_arc") {+ if let (Some(a), Some(m), Some(b)) = (n.child("start").and_then(xy), n.child("mid").and_then(xy), n.child("end").and_then(xy)) {+ let pts = arc_points(xf(a), xf(m), xf(b), 100_000.0);+ for w in pts.windows(2) {+ self.edges.push((w[0], w[1]));+ }+ }+ } else if head.ends_with("_rect") {+ if let (Some(a), Some(b)) = (n.child("start").and_then(xy), n.child("end").and_then(xy)) {+ let c = [a, V(b.0, a.1), b, V(a.0, b.1)];+ for i in 0..4 {+ self.edges.push((xf(c[i]), xf(c[(i + 1) % 4])));+ }+ }+ } else if head.ends_with("_circle") {+ if let (Some(c), Some(e)) = (n.child("center").and_then(xy), n.child("end").and_then(xy)) {+ let r = hypot(sub(e, c));+ let pts: Vec<V> = (0..=48).map(|i| {+ let a = i as f64 / 48.0 * std::f64::consts::TAU;+ xf(V(c.0 + r * a.cos(), c.1 + r * a.sin()))+ }).collect();+ for w in pts.windows(2) {+ self.edges.push((w[0], w[1]));+ }+ }+ } else if head.ends_with("_poly") {+ if let Some(pts) = n.child("pts") {+ let v: Vec<V> = pts.children("xy").filter_map(xy).map(xf).collect();+ for i in 0..v.len() {+ self.edges.push((v[i], v[(i + 1) % v.len()]));+ }+ }+ }+ }++ fn read_zone(&mut self, n: &Node, net_of: &dyn Fn(&mut Board, &Node) -> NetId) {+ if n.child("attr").and_then(|a| a.child("teardrop")).is_some() {+ self.teardrop_spans.push(n.span());+ let net = net_of(self, n);+ for f in n.children("filled_polygon") {+ let Some(layer) = f.child("layer").and_then(|l| l.atom(1)).and_then(|l| self.layer_index(l)) else { continue };+ if let Some(p) = f.child("pts") {+ self.teardrops.push(Teardrop { net, layer, pts: pts_of(p) });+ }+ }+ return;+ }+ let net = net_of(self, n);+ let mut names: Vec<&str> = Vec::new();+ if let Some(l) = n.child("layer") {+ names.extend(l.atoms().into_iter().skip(1));+ }+ if let Some(l) = n.child("layers") {+ names.extend(l.atoms().into_iter().skip(1));+ }+ let layers = self.expand_layers(&names);+ let outline: Vec<V> = n.child("polygon").and_then(|p| p.child("pts")).map(pts_of).unwrap_or_default();+ let mut fills = Vec::new();+ for f in n.children("filled_polygon") {+ let Some(l) = f.child("layer").and_then(|l| l.atom(1)).and_then(|l| self.layer_index(l)) else { continue };+ if let Some(p) = f.child("pts") {+ fills.push((l, pts_of(p)));+ }+ }+ let ko = n.child("keepout");+ let na = |k: &str| ko.and_then(|o| o.child(k)).and_then(|c| c.atom(1)) == Some("not_allowed");+ let priority = n.child("priority").and_then(|p| p.num(1)).unwrap_or(0.0) as i64;+ let name = n.child("name").and_then(|p| p.atom(1)).unwrap_or("").to_string();+ let pads = n.child("connect_pads");+ let clearance = pads.and_then(|c| c.child("clearance")).and_then(|c| c.num(1)).unwrap_or(0.0) * NM;+ let min_thickness = n.child("min_thickness").and_then(|c| c.num(1)).unwrap_or(0.25) * NM;+ let joins_pads = pads.and_then(|c| c.atom(1)) != Some("no");+ let mode = pads.and_then(|c| c.atom(1));+ let fill = n.child("fill");+ let relief = (fill.and_then(|f| f.child("thermal_gap")).and_then(|c| c.num(1)).unwrap_or(0.5) * NM, fill.and_then(|f| f.child("thermal_bridge_width")).and_then(|c| c.num(1)).unwrap_or(0.5) * NM);+ let thermal = if mode.is_none() || mode == Some("thru_hole_only") { Some(relief) } else { None };+ let thermal_tht_only = mode == Some("thru_hole_only");+ self.zones.push(Zone { net, layers, outline, fills, keepout_tracks: na("tracks"), keepout_vias: na("vias"), priority, name, keepout_pour: na("copperpour"), clearance, min_thickness, joins_pads, thermal, thermal_tht_only });+ }++ /// Whether a point lies inside the board outline (holes in the outline count as outside).+ pub fn inside(&self, p: V) -> bool {+ let mut inside = false;+ for (a, b) in &self.edges {+ if (a.1 > p.1) != (b.1 > p.1) && p.0 < (b.0 - a.0) * (p.1 - a.1) / (b.1 - a.1) + a.0 {+ inside = !inside;+ }+ }+ inside+ }++ /// Inner layers given over to a reference plane: a zone of one net covers most of the board.+ /// KiCad's layer type doesn't tell: his benchmark C and benchmark B mark GND planes "signal". Routing+ /// through a plane cuts the return path (2.2, 4.2), so these never take a track.+ pub fn plane_layers(&self) -> Vec<usize> {+ let last = self.layers.len() - 1;+ let board_area = (self.bbox.2 - self.bbox.0).max(0.0) * (self.bbox.3 - self.bbox.1).max(0.0);+ let mut out = Vec::new();+ for l in 1..last {+ // Added up per net: benchmark B's GND plane is two zones, 48% and 13% of the board.+ let mut per_net: HashMap<NetId, f64> = HashMap::new();+ for z in self.zones.iter().filter(|z| z.net != NO_NET && !z.keepout_tracks && z.layers.contains(&l)) {+ let n = z.outline.len();+ let mut a = 0.0;+ for i in 0..n {+ let (p, q) = (z.outline[i], z.outline[(i + 1) % n]);+ a += p.0 * q.1 - q.0 * p.1;+ }+ *per_net.entry(z.net).or_insert(0.0) += (a / 2.0).abs();+ }+ let covered = per_net.values().copied().fold(0.0, f64::max);+ if board_area > 0.0 && covered >= 0.5 * board_area {+ out.push(l);+ }+ }+ out+ }++ /// Nets with a copper plane on an inner layer: zone net to its plane layers.+ pub fn plane_nets(&self) -> HashMap<NetId, Vec<usize>> {+ let mut out: HashMap<NetId, Vec<usize>> = HashMap::new();+ let last = self.layers.len() - 1;+ for z in &self.zones {+ if z.net == NO_NET || z.keepout_tracks {+ continue;+ }+ for &l in &z.layers {+ if l != 0 && l != last {+ out.entry(z.net).or_default().push(l);+ }+ }+ }+ out+ }+}++fn pts_of(p: &Node) -> Vec<V> {+ let mut out = Vec::new();+ for c in p.items() {+ match c.head() {+ "xy" => out.extend(xy(c)),+ "arc" => {+ if let (Some(a), Some(m), Some(b)) = (c.child("start").and_then(xy), c.child("mid").and_then(xy), c.child("end").and_then(xy)) {+ out.extend(arc_points(a, m, b, 100_000.0));+ }+ }+ _ => {}+ }+ }+ out+}++/// A `(tenting ...)` block on a via or in the setup, or the default when it has none.+fn tent_of(n: &Node, default: (bool, bool)) -> (bool, bool) {+ let Some(t) = n.child("tenting") else { return default };+ let side = |k: &str, d: bool| match t.child(k).and_then(|x| x.atom(1)) {+ Some("yes") => true,+ Some("no") => false,+ _ => d,+ };+ (side("front", default.0), side("back", default.1))+}++pub fn to_p(v: V) -> P {+ (v.0.round() as i64, v.1.round() as i64)+}++fn rect_pts(center: V, w: f64, h: f64, ang: f64, rr: f64) -> Vec<V> {+ let (hw, hh) = (w / 2.0, h / 2.0);+ let mut local: Vec<(f64, f64)> = Vec::new();+ if rr <= 0.0 {+ local.extend([(-hw, -hh), (hw, -hh), (hw, hh), (-hw, hh)]);+ } else {+ // Corner arcs with enough facets that the polygon stays within a micron of the true outline.+ let n = 12;+ for (cx, cy, a0) in [(hw - rr, -hh + rr, -90.0f64), (hw - rr, hh - rr, 0.0), (-hw + rr, hh - rr, 90.0), (-hw + rr, -hh + rr, 180.0)] {+ for i in 0..=n {+ let a = (a0 + 90.0 * i as f64 / n as f64).to_radians();+ local.push((cx + rr * a.cos(), cy + rr * a.sin()));+ }+ }+ }+ local+ .into_iter()+ .map(|(x, y)| {+ let (rx, ry) = rot(x, y, ang);+ V(center.0 + rx, center.1 + ry)+ })+ .collect()+}++#[allow(clippy::too_many_arguments)]+fn pad_shape(name: &str, center: V, size: (f64, f64), ang: f64, rr: f64, delta: Option<(f64, f64)>, p: &Node, kind: &str) -> Shape {+ let (w, h) = size;+ match name {+ "circle" => Shape::Circle { c: center, r: w / 2.0 },+ "oval" => {+ if (w - h).abs() < 1.0 {+ Shape::Circle { c: center, r: w / 2.0 }+ } else if w > h {+ let d = (w - h) / 2.0;+ let (x, y) = rot(d, 0.0, ang);+ Shape::Capsule { a: V(center.0 - x, center.1 - y), b: V(center.0 + x, center.1 + y), r: h / 2.0 }+ } else {+ let d = (h - w) / 2.0;+ let (x, y) = rot(0.0, d, ang);+ Shape::Capsule { a: V(center.0 - x, center.1 - y), b: V(center.0 + x, center.1 + y), r: w / 2.0 }+ }+ }+ "roundrect" => Shape::Poly { pts: rect_pts(center, w, h, ang, rr * w.min(h)), r: 0.0 },+ "trapezoid" => {+ let (dx, dy) = delta.unwrap_or((0.0, 0.0));+ let (hw, hh) = (w / 2.0, h / 2.0);+ let local = [(-hw - dy / 2.0, -hh + dx / 2.0), (hw + dy / 2.0, -hh - dx / 2.0), (hw - dy / 2.0, hh + dx / 2.0), (-hw + dy / 2.0, hh - dx / 2.0)];+ let pts = local.iter().map(|&(x, y)| {+ let (rx, ry) = rot(x, y, ang);+ V(center.0 + rx, center.1 + ry)+ }).collect();+ Shape::Poly { pts, r: 0.0 }+ }+ "custom" => {+ // A custom pad is its anchor plus every primitive, all one piece of copper. KiCad's DRC+ // and connectivity see the union, so all of it is kept, not only the largest piece.+ let anchor_circle = p.child("options").and_then(|o| o.child("anchor")).and_then(|a| a.atom(1)) == Some("circle");+ let mut parts = vec![if anchor_circle { Shape::Circle { c: center, r: w / 2.0 } } else { Shape::Poly { pts: rect_pts(center, w, h, ang, 0.0), r: 0.0 } }];+ let xf = |v: V| {+ let (rx, ry) = rot(v.0, v.1, ang);+ V(center.0 + rx, center.1 + ry)+ };+ if let Some(prims) = p.child("primitives") {+ for g in prims.items() {+ let half = g.child("width").and_then(|w| w.num(1)).unwrap_or(0.0) * NM / 2.0;+ let filled = g.child("fill").and_then(|f| f.atom(1)).map(|f| f != "no" && f != "none").unwrap_or(true);+ match g.head() {+ "gr_poly" => {+ if let Some(pp) = g.child("pts") {+ parts.push(Shape::Poly { pts: pts_of(pp).into_iter().map(xf).collect(), r: half });+ }+ }+ "gr_line" => {+ if let (Some(a), Some(e)) = (g.child("start").and_then(xy), g.child("end").and_then(xy)) {+ parts.push(Shape::Capsule { a: xf(a), b: xf(e), r: half });+ }+ }+ "gr_arc" => {+ if let (Some(a), Some(m), Some(e)) = (g.child("start").and_then(xy), g.child("mid").and_then(xy), g.child("end").and_then(xy)) {+ for s in arc_points(a, m, e, 50_000.0).windows(2) {+ parts.push(Shape::Capsule { a: xf(s[0]), b: xf(s[1]), r: half });+ }+ }+ }+ "gr_rect" => {+ if let (Some(a), Some(e)) = (g.child("start").and_then(xy), g.child("end").and_then(xy)) {+ let pts: Vec<V> = [a, V(e.0, a.1), e, V(a.0, e.1)].into_iter().map(xf).collect();+ parts.push(Shape::Poly { pts, r: half });+ }+ }+ "gr_circle" => {+ if let (Some(c), Some(e)) = (g.child("center").and_then(xy), g.child("end").and_then(xy)) {+ let r = hypot(sub(e, c));+ if filled {+ parts.push(Shape::Circle { c: xf(c), r: r + half });+ } else {+ let ring: Vec<V> = (0..=48).map(|i| {+ let a = i as f64 / 48.0 * std::f64::consts::TAU;+ V(c.0 + r * a.cos(), c.1 + r * a.sin())+ }).collect();+ for s in ring.windows(2) {+ parts.push(Shape::Capsule { a: xf(s[0]), b: xf(s[1]), r: half });+ }+ }+ }+ }+ _ => {}+ }+ }+ }+ if parts.len() == 1 { parts.pop().unwrap() } else { Shape::Multi(parts) }+ }+ _ => {+ let _ = kind;+ Shape::Poly { pts: rect_pts(center, w, h, ang, 0.0), r: 0.0 }+ }+ }+}++#[cfg(test)]+mod tests {+ use super::*;++ /// A 100 x 100 board with rectangles on In1.Cu: (net, width, height) of each zone.+ fn with_zones(zones: &[(&str, f64, f64)]) -> Board {+ let mut t = String::from(r#"(kicad_pcb (layers (0 "F.Cu" signal) (4 "In1.Cu" signal) (6 "In2.Cu" signal) (2 "B.Cu" signal)) (gr_rect (start 0 0) (end 100 100) (layer "Edge.Cuts"))"#);+ let mut y0 = 0.0;+ for (net, w, h) in zones {+ t.push_str(&format!(r#"(zone (net "{net}") (layer "In1.Cu") (polygon (pts (xy 0 {y0}) (xy {w} {y0}) (xy {w} {}) (xy 0 {}))))"#, y0 + h, y0 + h));+ y0 += h;+ }+ t.push(')');+ Board::parse(t).unwrap()+ }++ #[test]+ fn a_plane_split_into_two_zones_counts() {+ // Benchmark B's shape: neither zone is half the board, together they are 61%.+ assert_eq!(with_zones(&[("GND", 100.0, 48.0), ("GND", 100.0, 13.0)]).plane_layers(), vec![1]);+ assert_eq!(with_zones(&[("GND", 100.0, 60.0)]).plane_layers(), vec![1]);+ }++ #[test]+ fn zones_of_two_nets_do_not_add_up_to_a_plane() {+ assert!(with_zones(&[("GND", 100.0, 30.0), ("+3V3", 100.0, 30.0)]).plane_layers().is_empty());+ assert!(with_zones(&[("GND", 100.0, 40.0)]).plane_layers().is_empty());+ }+}
src/bus.rsadded+410@@ -0,0 +1,410 @@+//! Buses: what makes hand routing look hand-routed, and what road navigation has no reason to do.+//! Connections between the same two parts are planned as a group: the ones that don't cross share a+//! layer and run side by side in pin order, and the ones that would cross them take the other layer+//! on purpose instead of finding that out one at a time.+//!+//! Adapted from KiCadRoutingTools (MIT, Copyright (c) 2026 drandyhaas), docs/bus-routing.md and+//! docs/net-ordering.md: its maximum-planar-subset greedy (fewest active conflicts first, then shorter),+//! routing a bus's members middle-out, and each member following its routed neighbour. Two things+//! differ. Its crossings come from unrolling each chip's boundary, which assumes copper can't pass+//! through a part; benchmark B's runs eighteen lines through the pins of a module, so chords+//! are compared directly. And its attraction is a discount around the neighbour's path, where here a+//! member is drawn to the neighbour's shape carried over to its own pins: "each line leaves its+//! pin straight out, parallel to its neighbours at the pin pitch" (4.7, QSPI on PiProbe).+//!+//! The hierarchy isn't touched: this only orders connections inside one level (4.2), and the layer+//! and the neighbour's shape are preferences the search can overrule.++use crate::board::*;+use crate::classify::*;+use crate::geom::*;+use crate::global::chords_cross;+use crate::router::{dir_of, Plan, Router};+use crate::rules::Rules;+use crate::search::{Profile, Query, World};+use std::collections::{BTreeMap, HashMap};++/// Contacts and two-pad parts this close count as one row, the way a connector's pins do. KRT's+/// default detection radius.+const ROW_REACH: f64 = 5.0 * NM;+const ROW_BAND: f64 = 0.5 * NM;+/// Fewest connections that make a bus (KRT's strict filter).+const MIN_MEMBERS: usize = 3;++#[derive(Clone, Debug)]+pub struct Member {+ /// Which bus, and this member's turn inside it; buses go first within their level.+ pub group: usize,+ pub seq: usize,+ /// Preferred layer (index into Board::layers), by crossing count.+ pub layer: usize,+ /// Both pads reach that layer, so it costs no via to use.+ pub free: bool,+ /// The routed neighbour to follow, and whether its ends are swapped relative to this one's.+ pub follow: Option<((usize, usize), bool)>,+}++#[derive(Default)]+pub struct BusPlan {+ pub members: HashMap<(usize, usize), Member>,+ /// Each bus's lines that don't cross, in their order across the bus, and their layer: what can+ /// be laid as one band.+ pub bands: Vec<(usize, Vec<(usize, usize)>)>,+ pub notes: Vec<String>,+}++impl BusPlan {+ /// Sort key inside a level: buses first, biggest first, each member in its turn.+ pub fn order_of(&self, t: &crate::classify::Task) -> (usize, usize) {+ self.members.get(&(t.a, t.b)).map(|m| (m.group, m.seq)).unwrap_or((usize::MAX, 0))+ }+}++/// One end of a bus: a part with a row of pins, or a row of separate contacts or passives.+fn clusters(board: &Board) -> Vec<usize> {+ let n = board.footprints.len();+ let mut uf = crate::conn::Uf::new(n);+ let small: Vec<bool> = board.footprints.iter().map(|f| f.pads.iter().filter(|&&p| !board.pads[p].layers.is_empty()).count() <= 2).collect();+ for i in 0..n {+ if !small[i] {+ continue;+ }+ for j in i + 1..n {+ if small[j] && ref_prefix(&board.footprints[i].reference) == ref_prefix(&board.footprints[j].reference) {+ let (pi, pj): (V, V) = (board.footprints[i].at.into(), board.footprints[j].at.into());+ // In line with each other: a row. Parts merely near each other aren't one end of a bus.+ let aligned = (pi.0 - pj.0).abs() < ROW_BAND || (pi.1 - pj.1).abs() < ROW_BAND;+ if aligned && hypot(sub(pi, pj)) <= ROW_REACH {+ uf.union(i as u32, j as u32);+ }+ }+ }+ }+ (0..n).map(|i| uf.find(i as u32) as usize).collect()+}++/// Find the buses among this pass's connections, and give each member its layer, turn and neighbour.+/// A row this long or longer goes down as one band (lines back to back, each beside the last, kept+/// out of optimise). Benchmark B's 15- and 11-line rows look like the designer's that way; on benchmark C the+/// same treatment of its 4-line rows cost 11 joins (290 against 301 of 302, four runs each way,+/// 2026-11-09), so short rows route as before.+const BAND_MIN: usize = 6;++pub fn plan(board: &Board, world: &World, _rules: &Rules, plan: &Plan) -> BusPlan {+ let mut out = BusPlan::default();+ let cluster = clusters(board);+ let mut by: BTreeMap<(Level, usize, usize), Vec<usize>> = BTreeMap::new();+ let tasks = &plan.tasks;+ for (i, t) in tasks.iter().enumerate() {+ // Data only: non-priority lines may via often without a noticeable effect (4.2 item 4), and+ // holding them to a plan shut GPIO2's pin on piprobe_v13 for no gain in the board's looks.+ if t.a == t.b || t.zone.is_some() || t.level != Level::Data {+ continue;+ }+ let (x, y) = (cluster[board.pads[t.a].fp], cluster[board.pads[t.b].fp]);+ if x == y {+ continue;+ }+ by.entry((t.level, x.min(y), x.max(y))).or_default().push(i);+ }+ let mut groups: Vec<((Level, usize, usize), Vec<usize>)> = by.into_iter().filter(|(_, v)| v.len() >= MIN_MEMBERS).collect();+ // Biggest first: within a level the largest bus has the least room to move. Stable, so the+ // order is the same on every run.+ groups.sort_by_key(|(k, v)| (k.0, std::cmp::Reverse(v.len())));+ let chord = |i: usize| -> (V, V) { (board.pads[tasks[i].a].center.into(), board.pads[tasks[i].b].center.into()) };+ let free_on = |i: usize, l: usize| board.pads[tasks[i].a].layers.contains(&l) && board.pads[tasks[i].b].layers.contains(&l);+ let layers = &world.route_layers;+ // What each layer already holds, for counting crossings between buses.+ let mut placed: HashMap<usize, Vec<(V, V)>> = HashMap::new();+ for (gi, (key, members)) in groups.iter().enumerate() {+ let m = members.len();+ let conflict: Vec<Vec<bool>> = (0..m).map(|a| (0..m).map(|b| a != b && { let (p, q) = (chord(members[a]), chord(members[b])); chords_cross(p.0, p.1, q.0, q.1) }).collect()).collect();+ // The largest set that doesn't cross: fewest active conflicts first, shorter on a tie (KRT).+ let mut left: Vec<usize> = (0..m).collect();+ let mut planar: Vec<usize> = Vec::new();+ let len = |a: usize| { let c = chord(members[a]); hypot(sub(c.0, c.1)) };+ while !left.is_empty() {+ let &pick = left.iter().min_by(|&&a, &&b| {+ let count = |x: usize| left.iter().filter(|&&o| conflict[x][o]).count();+ count(a).cmp(&count(b)).then(len(a).total_cmp(&len(b)))+ }).unwrap();+ planar.push(pick);+ left.retain(|&o| o != pick && !conflict[pick][o]);+ }+ let others: Vec<usize> = (0..m).filter(|a| !planar.contains(a)).collect();+ // Which layer for the planar set? A via is dearest, then crossing other buses, then the+ // bottom: "don't hop to the bottom layer where the top works" (4.2).+ let score = |a_layer: usize, b_layer: usize| -> f64 {+ let mut s = 0.0;+ for (set, l) in [(&planar, a_layer), (&others, b_layer)] {+ for &a in set.iter() {+ let c = chord(members[a]);+ if !free_on(members[a], l) {+ s += 100.0;+ }+ if let Some(v) = placed.get(&l) {+ s += v.iter().filter(|q| chords_cross(c.0, c.1, q.0, q.1)).count() as f64;+ }+ if l != 0 {+ s += 0.01 * (1.0 + hypot(sub(c.0, c.1)) / NM);+ }+ }+ }+ s+ };+ let mut best: Option<(f64, usize, usize)> = None;+ for &la in layers {+ for &lb in layers {+ if layers.len() > 1 && la == lb {+ continue;+ }+ let s = score(la, lb);+ if best.map(|b| s < b.0).unwrap_or(true) {+ best = Some((s, la, lb));+ }+ }+ }+ let Some((_, la, lb)) = best else { continue };+ // Physical order across the bus, so each member's neighbour really is beside it.+ let (dx, dy) = planar.iter().fold((0.0, 0.0), |acc, &a| {+ let c = chord(members[a]);+ let (mut x, mut y) = (c.1 .0 - c.0 .0, c.1 .1 - c.0 .1);+ // Chords can be listed either way round; add them all facing the same way.+ if x * (acc.0 as f64) + y * (acc.1 as f64) < 0.0 {+ x = -x;+ y = -y;+ }+ (acc.0 + x, acc.1 + y)+ });+ let norm = (dx * dx + dy * dy).sqrt().max(1.0);+ let across = |a: usize| {+ let c = chord(members[a]);+ ((c.0 .0 + c.1 .0) / 2.0 * -dy + (c.0 .1 + c.1 .1) / 2.0 * dx) / norm+ };+ let mut row = planar.clone();+ row.sort_by(|&a, &b| across(a).total_cmp(&across(b)));+ if row.len() >= BAND_MIN {+ out.bands.push((la, row.iter().map(|&a| (tasks[members[a]].a, tasks[members[a]].b)).collect()));+ }+ // Middle out: each member's neighbour is the one before it, toward the middle, and no+ // member is ever walled off from its pins by the order.+ let mid = row.len() / 2;+ let mut turns: Vec<(usize, Option<usize>)> = vec![(row[mid], None)];+ for k in 1..=row.len() {+ if mid + k < row.len() {+ turns.push((row[mid + k], Some(row[mid + k - 1])));+ }+ if k <= mid {+ turns.push((row[mid - k], Some(row[mid - k + 1])));+ }+ }+ // The rest go shortest first: a line that must wrap round another's pin goes after it, so+ // they nest, as the bottom row of benchmark B's bus does.+ let mut rest = others.clone();+ rest.sort_by(|&a, &b| len(a).total_cmp(&len(b)));+ // They nest, so they run beside each other too: the next one out follows the one inside it.+ if rest.len() >= BAND_MIN {+ out.bands.push((lb, rest.iter().map(|&a| (tasks[members[a]].a, tasks[members[a]].b)).collect()));+ }+ for a in rest {+ turns.push((a, None));+ }+ let same_way = |a: usize, b: usize| {+ let (ta, tb) = (&tasks[members[a]], &tasks[members[b]]);+ cluster[board.pads[ta.a].fp] == cluster[board.pads[tb.a].fp]+ };+ for (seq, (a, near)) in turns.iter().enumerate() {+ let t = &tasks[members[*a]];+ let layer = if planar.contains(a) { la } else { lb };+ let follow = near.map(|n| {+ let o = &tasks[members[n]];+ ((o.a, o.b), !same_way(*a, n))+ });+ out.members.insert((t.a, t.b), Member { group: gi, seq, layer, free: free_on(members[*a], layer), follow });+ placed.entry(layer).or_default().push(chord(members[*a]));+ }+ let name = |c: usize| {+ let f = board.footprints.iter().enumerate().find(|(i, _)| cluster[*i] == c).map(|(_, f)| f.reference.clone()).unwrap_or_default();+ if board.footprints.iter().enumerate().filter(|(i, _)| cluster[*i] == c).count() > 1 { format!("{}..", f) } else { f }+ };+ out.notes.push(format!("bus {} to {}: {} lines, {} level, {} side by side on {}, {} on {}", name(key.1), name(key.2), m, key.0.name(), planar.len(), board.layers[la].name, others.len(), board.layers[lb].name));+ }+ out+}++/// How much of a bus's line length runs beside its neighbour in the bus: same layer, parallel, within+/// `BESIDE` of another line of the same bus. His buses move as one band of lines at 3W; lines routed one at a time fan out and cross,+/// which bends and vias don't show (benchmark B: ours had fewer bends than his and looked worse).+/// Returns (lines measured, share beside, mm measured).+pub fn coherence(board: &Board, rules: &Rules) -> (usize, f64, f64) {+ const BESIDE: f64 = 1.5 * NM;+ const STEP: f64 = 0.2 * NM;+ let mut bare = board.clone();+ bare.tracks.clear();+ bare.vias.clear();+ let mut r = rules.clone();+ r.bind(&bare);+ let p = crate::router::make_plan(&bare, &r);+ let w = crate::router::build_world(&bare, &r);+ let bp = plan(&bare, &w, &r, &p);+ let net_of = |a: usize| board.pads[a].net;+ let (mut lines, mut beside, mut total) = (0usize, 0.0, 0.0);+ for ((a, _), m) in &bp.members {+ let mine = net_of(*a);+ // Any other line of the same bus counts: the order he routes them in is his, not the plan's.+ let others: Vec<NetId> = bp.members.iter().filter(|((x, _), o)| o.group == m.group && net_of(*x) != mine).map(|((x, _), _)| net_of(*x)).collect();+ let segs_of = |n: NetId| board.tracks.iter().filter(move |t| t.net == n);+ let theirs: Vec<&Track> = board.tracks.iter().filter(|t| others.contains(&t.net)).collect();+ lines += 1;+ for s in segs_of(mine) {+ let (sa, sb): (V, V) = (s.a.into(), s.b.into());+ let len = hypot(sub(sa, sb));+ if len < 1.0 {+ continue;+ }+ let d = dir_of(s.a, s.b);+ let k = (len / STEP).ceil().max(1.0) as usize;+ for i in 0..k {+ let t = (i as f64 + 0.5) / k as f64;+ let pt = V(sa.0 + (sb.0 - sa.0) * t, sa.1 + (sb.1 - sa.1) * t);+ total += len / k as f64;+ let near = theirs.iter().any(|o| {+ o.layer == s.layer && pt_seg(pt, o.a.into(), o.b.into()) <= BESIDE && match (d, dir_of(o.a, o.b)) {+ (Some(x), Some(y)) => x % 4 == y % 4,+ _ => false,+ }+ });+ if near {+ beside += len / k as f64;+ }+ }+ }+ }+ (lines, if total > 0.0 { beside / total } else { 0.0 }, total / NM)+}++/// How close a GND via counts as close by. His benchmark C has one within this of 48 of its 61 signal vias.+const RETURN_REACH: f64 = 1.5 * NM;++impl<'a> Router<'a> {+ /// "Any signal via benefits from a GND via close by, when possible" (4.4): once every route is+ /// done, so nothing moved for it (lowest priority, like stitching), a GND via next to each signal+ /// via that lacks one, wherever one fits. It reaches the plane by itself.+ pub(crate) fn return_vias(&mut self) {+ let planes = self.board.plane_nets();+ let Some(&gnd) = planes.keys().find(|&&n| looks_ground(self.board.net_name(n))) else { return };+ let Some(pad) = self.board.pads.iter().position(|p| p.net == gnd && !p.layers.is_empty()) else { return };+ let plane_at = |p: V, world: &World| world.zones.iter().any(|(n, _, poly)| *n == gnd && in_poly(p, poly));+ let task = Task { net: gnd, level: Level::PlaneFanout, a: pad, b: pad, main: false, zone: None };+ // A through pin on the plane is a return path as good as a via.+ let mut returns: Vec<V> = self.board.pads.iter().filter(|p| p.net == gnd && p.drill.is_some() && !p.npth).map(|p| p.center.into()).collect();+ let mut gnd_vias: Vec<V> = self.board.vias.iter().filter(|v| v.net == gnd).map(|v| v.at.into()).collect();+ let mut signal: Vec<(u32, P)> = Vec::new();+ for r in self.routes.values() {+ if r.task.net == gnd {+ gnd_vias.extend(r.vias.iter().map(|&v| V::from(v)));+ } else if !matches!(r.task.level, Level::Power) {+ signal.extend(r.vias.iter().map(|&v| (r.id, v)));+ }+ }+ signal.sort();+ returns.extend(gnd_vias.iter().copied());+ let close = |set: &[V], v: P| set.iter().any(|g| hypot(sub(*g, v.into())) <= RETURN_REACH);+ let total = signal.len();+ let before = signal.iter().filter(|(_, v)| close(&gnd_vias, *v)).count();+ let vr = self.rules.via as f64 / 2.0;+ let mut added = 0;+ let mut boxed = 0;+ for (_, v) in &signal {+ if close(&returns, *v) {+ continue;+ }+ let c: V = (*v).into();+ let profile = Profile { width: 0, neck: 0, clearance: self.rules.clearance_for(gnd, gnd), target_gap: 0, max_vias: 0, margin: 0.0, layer_bias: None };+ let q = Query { net: gnd, also: None, sources: &[], targets: &[], profile, rules: &self.rules, soft: None, via_in: None, history: None, free: &[], arrive: None };+ let mut site = None;+ let mut radius = 2.0 * vr + self.rules.hole_hole as f64;+ 'look: while radius <= RETURN_REACH {+ for k in 0..16 {+ let a = k as f64 / 16.0 * std::f64::consts::TAU;+ let at = crate::search::snap(to_p(V(c.0 + radius * a.cos(), c.1 + radius * a.sin())));+ let (mut b, mut sc) = (Vec::new(), 0.0);+ if plane_at(at.into(), &self.world) && self.world.via_ok(at, &q, None, &mut b, &mut sc) {+ site = Some(at);+ break 'look;+ }+ }+ radius += 0.05 * NM;+ }+ let Some(at) = site else {+ boxed += 1;+ continue;+ };+ let id = self.fresh_id();+ self.place(id, task.clone(), Vec::new(), vec![at]);+ returns.push(at.into());+ gnd_vias.push(at.into());+ added += 1;+ }+ let after = signal.iter().filter(|(_, v)| close(&gnd_vias, *v)).count();+ self.log.push(format!("return vias: {after} of {total} signal vias have a GND via within {:.1} mm ({before} before; {added} added, {boxed} with no room)", RETURN_REACH / NM));+ }+}++#[cfg(test)]+mod tests {+ use super::*;++ /// His benchmark B: 26 lines from the module to the contact row. The lines that keep their order run+ /// together; the bottom row, which is reversed against the contacts, goes on the other layer, as+ /// he routed it.+ #[test]+ fn benchmark_b_splits_its_bus_the_way_the_designer_did() {+ let path = format!("{}/bench/boards/benchb_v210.ref.kicad_pcb", env!("CARGO_MANIFEST_DIR"));+ if !std::path::Path::new(&path).exists() {+ eprintln!("skipped: {path} is not here (the reference boards do not ship with the source)");+ return;+ }+ let mut b = Board::read(&path).unwrap();+ b.tracks.clear();+ b.vias.clear();+ let mut r = Rules::default();+ crate::project::apply(&path, &mut r);+ let p = crate::router::make_plan(&b, &r);+ let w = crate::router::build_world(&b, &r);+ let bus = plan(&b, &w, &r, &p);+ let layer_of = |net: &str| {+ let id = b.net_ids[net];+ let t = p.tasks.iter().find(|t| t.net == id).unwrap();+ bus.members[&(t.a, t.b)].layer+ };+ for n in ["/USER_IO.13", "/USER_IO.16", "/USER_IO.22", "/USER_IO.27"] {+ assert_eq!(layer_of(n), 0, "{n} should be with the bus on F.Cu");+ }+ // Everything with its pins in the reversed order is not on the bus's layer, bar the one it+ // can keep. Pairwise reversal means at most one stays.+ let top = ["/USER_IO.0", "/USER_IO.1", "/USER_IO.2", "/USER_IO.3", "/USER_IO.4", "/USER_IO.5"].iter().filter(|n| layer_of(n) == 0).count();+ assert!(top <= 1, "{top} of the reversed lines stayed on the top");+ }++ #[test]+ fn piprobe_finds_the_qspi_bus() {+ let path = format!("{}/bench/boards/piprobe_v13.ref.kicad_pcb", env!("CARGO_MANIFEST_DIR"));+ if !std::path::Path::new(&path).exists() {+ eprintln!("skipped: {path} is not here (the reference boards do not ship with the source)");+ return;+ }+ let mut b = Board::read(&path).unwrap();+ b.tracks.clear();+ b.vias.clear();+ let mut r = Rules::default();+ crate::project::apply(&path, &mut r);+ let p = crate::router::make_plan(&b, &r);+ let w = crate::router::build_world(&b, &r);+ let bus = plan(&b, &w, &r, &p);+ let qspi: Vec<_> = p.tasks.iter().filter(|t| b.net_name(t.net).starts_with("QSPI_") && bus.members.contains_key(&(t.a, t.b))).collect();+ assert!(qspi.len() >= 6, "{} QSPI lines in a bus", qspi.len());+ // SMD on one side: no line may be pushed to the other layer without a via, so none is held.+ assert!(qspi.iter().all(|t| bus.members[&(t.a, t.b)].layer == 0 || !bus.members[&(t.a, t.b)].free));+ }+}
src/check.rsadded+1182@@ -0,0 +1,1182 @@+//! The standards checker. Reads any .kicad_pcb and reports what breaks the designer's routing rules, with+//! the place. It trusts nothing from the router: it reads the written file from scratch.++use crate::board::*;+use crate::classify::*;+use crate::conn;+use crate::geom::*;+use crate::index::*;+use crate::mask::MaskMap;+use crate::rules::Rules;+use crate::router::build_world;+use serde_json::{json, Value};+use std::collections::{BTreeMap, HashMap};++#[derive(Clone, Debug)]+pub struct Finding {+ pub rule: &'static str,+ /// error: breaks a hard rule. warn: a preference or something to read by eye.+ pub severity: &'static str,+ pub at: (f64, f64),+ pub text: String,+}++#[derive(Default, Debug)]+pub struct Score {+ pub nets_open: usize,+ pub pads_open: usize,+ pub length_mm: f64,+ pub length_top_mm: f64,+ pub length_bottom_mm: f64,+ pub length_inner_mm: f64,+ pub vias: usize,+ pub segments: usize,+ pub bends45: usize,+ pub bends90: usize,+ pub widths: BTreeMap<String, usize>,+ pub min_gap_mm: f64,+ pub tight_mm: f64,+ pub spacing: Spacing,+ pub pairs: Vec<PairStat>,+ /// Distance from each signal via to its nearest GND via, mm (4.4), sorted.+ pub signal_via_gnd_mm: Vec<f64>,+ /// Per net: what its route costs against how far apart its pads are.+ pub nets: Vec<NetStat>,+ pub width_steps: usize,+ pub rails: Vec<RailStat>,+ pub cap_first: CapFirst,+ pub entries: Entries,+ /// Each supply pin's own cap, pad edge to pad edge, farthest first.+ pub own_cap_mm: Vec<(f64, String)>,+}++/// Every track end on its own pad (4.2): how it comes in.+#[derive(Default, Debug)]+pub struct Entries {+ pub total: usize,+ pub centre: usize,+ pub corner: usize,+ pub side: usize,+ /// Not square to the edge it crosses.+ pub oblique: usize,+}++/// How each supply pin of an IC reaches its net (4.2), walked on the copper.+#[derive(Default, Debug)]+pub struct CapFirst {+ pub pins: usize,+ /// Its own pad shares copper with the cap's.+ pub direct: usize,+ /// A track or via path with nothing on the way.+ pub cap: usize,+ /// It reaches a fill first, which is the rail: v1.3's rings.+ pub on_fill: usize,+ pub junction: usize,+}++#[derive(Default, Debug)]+pub struct NetStat {+ pub name: String,+ pub level: &'static str,+ pub pads: usize,+ pub len_mm: f64,+ /// Straight-line length of the shortest tree over its pads.+ pub tree_mm: f64,+ pub vias: usize,+ pub width_steps: usize,+ pub fill: bool,+}++/// One power net's record (4.2: main run and branches, found from the copper actually connected).+#[derive(Default, Debug)]+pub struct RailStat {+ pub name: String,+ pub main_width_mm: f64,+ pub main_len_mm: f64,+ pub fed_by_fill: bool,+ /// Narrower widths and how much of each, the branch analysis he asked for.+ pub branches: Vec<(f64, f64)>,+}++/// Track length by spacing to the nearest other-net copper (4.2). 3W is centre to centre, a gap of 2W.+/// Length inside a fine-pitch escape is counted apart, since spacing may tighten there.+#[derive(Default, Debug)]+pub struct Spacing {+ pub at_3w_mm: f64,+ pub at_2w_mm: f64,+ pub under_2w_mm: f64,+ pub escape_mm: f64,+}++#[derive(Default, Debug)]+pub struct PairStat {+ pub p: String,+ pub n: String,+ pub len_p_mm: f64,+ pub len_n_mm: f64,+ /// Length of P with N running parallel on the same layer at the gap, mm.+ pub coupled_mm: f64,+ pub gap_mm: f64,+ pub vias: usize,+}++/// A clearance is broken when it is short by more than this. KiCad flagged 0.1991 against 0.2 mm+/// on the benchmark C, so the tolerance has to sit below 0.9 microns.+const TOL: f64 = 500.0;++/// Segments this short (nm) are a width step or a stray join, not a direction change: the benchmark C has+/// 4 micron jogs where one width meets another. Same length the off-45 check ignores.+const JOG: f64 = 20_000.0;++/// Rules a finished board must pass on geometry alone: `grade` holds the designer's boards to these.+pub const GEOMETRY_RULES: &[&str] = &[+ "clearance", "hole-clearance", "hole-to-hole", "keepout", "edge", "width", "width-setup", "via-min", "off-45", "corner-90", "corner-acute", "square-t", "crystal-chain", "via-in-pad", "mask-bridge", "mask-web", "xtal-series-line", "xtal-load-cap-line", "decoupling-pin", "inner-tie-no-fill", "inner-route", "fold-back", "same-net-parallel", "cap-first", "fill-unfed", "rail-floor", "kelvin",+];++fn key(p: P) -> P {+ // Endpoints that meet to within 2 microns are one node.+ ((p.0 + 1000).div_euclid(2000), (p.1 + 1000).div_euclid(2000))+}++/// Angle in degrees of a segment's direction out of a node.+fn out_angle(from: P, to: P) -> f64 {+ ((to.1 - from.1) as f64).atan2((to.0 - from.0) as f64).to_degrees()+}++fn diff(a: f64, b: f64) -> f64 {+ let d = (a - b).rem_euclid(360.0);+ d.min(360.0 - d)+}++fn pad_at(board: &Board, p: P, layer: usize, net: NetId) -> Option<usize> {+ let v: V = p.into();+ board.pads.iter().position(|q| q.net == net && q.layers.contains(&layer) && q.shape.dist_pt(v) <= 1.0)+}++/// A package's heat pad: thermal vias belong in it (4.4). Measured on the copper, since a custom+/// pad's anchor can be tiny (U5.10 on the benchmark C: anchor 0.38 mm, pad 2.4 mm).+fn is_exposed(board: &Board, i: usize) -> bool {+ let p = &board.pads[i];+ let b = p.shape.bbox();+ board.footprints[p.fp].pads.len() > 4 && b.2 - b.0 >= 1.0 * NM && b.3 - b.1 >= 1.0 * NM+}++/// Pads against pads: KiCad's DRC holds them to the clearance too, inside a footprint as well, and+/// a pad's copper and hole to the board edge and to other holes.+fn pad_pad_rules(board: &Board, rules: &Rules, world: &crate::search::World, f: &mut Vec<Finding>) {+ let reach = rules.max_clearance() as f64;+ let mm_at = |p: P| (to_mm(p.0), to_mm(p.1));+ for (i, p) in board.pads.iter().enumerate() {+ let b = p.shape.bbox();+ let bb = (b.0 - reach, b.1 - reach, b.2 + reach, b.3 + reach);+ let mut worst: Option<(f64, f64, usize)> = None;+ for &l in &p.layers {+ world.layers[l].visit(bb, |o| {+ let Owner::Pad(j) = o.owner else { return true };+ let j = j as usize;+ if j <= i || (o.net == p.net && p.net != NO_NET) {+ return true;+ }+ let clr = rules.clearance_for(p.net, o.net) as f64;+ let d = p.shape.dist_shape(&o.shape);+ if d < clr - TOL && worst.map(|w| d - clr < w.0 - w.1).unwrap_or(true) {+ worst = Some((d, clr, j));+ }+ true+ });+ }+ if let Some((d, clr, j)) = worst {+ f.push(Finding { rule: "clearance", severity: "error", at: mm_at(p.center), text: format!("pad {} is {:.3} mm from pad {} (minimum {:.3})", board.pad_name(i), d.max(0.0) / NM, board.pad_name(j), clr / NM) });+ }+ // Holes of two footprints; holes inside one footprint are its drawing, not the layout's.+ if let Some(dr) = p.drill {+ let (c, r): (V, f64) = (p.center.into(), dr / 2.0);+ let hh = rules.hole_to_hole() as f64;+ let span = r + hh + 1.0 * NM;+ world.holes.visit((c.0 - span, c.1 - span, c.0 + span, c.1 + span), |o| {+ let Owner::Pad(j) = o.owner else { return true };+ let j = j as usize;+ if j <= i || board.pads[j].fp == p.fp {+ return true;+ }+ let d = o.shape.dist_pt(c) - r;+ if d < hh - TOL {+ f.push(Finding { rule: "hole-to-hole", severity: rules.severity("hole_to_hole"), at: mm_at(p.center), text: format!("hole of pad {} is {:.3} mm from the hole of pad {} (minimum {:.3})", board.pad_name(i), d.max(0.0) / NM, board.pad_name(j), hh / NM) });+ }+ true+ });+ }+ }+}++/// An entry shallower than this to the pad's edge is the acute one 4.2 warns about. An octilinear+/// track reaches a square-on pad at 90 or 45, so this needs a rotated pad or an off-45 track.+const ACUTE_ENTRY: f64 = 44.0;++/// The angle in degrees between a track and the pad edge it crosses coming in from `o`, or None for+/// a round pad, whose edge has no direction to be acute to.+fn entry_angle(p: &Pad, o: V, e: V) -> Option<f64> {+ if matches!(p.shape, Shape::Circle { .. } | Shape::Multi(_)) {+ return None;+ }+ let c = p.shape.center();+ let (ox, oy) = rot(o.0 - c.0, o.1 - c.1, -p.angle);+ let (ex, ey) = rot(e.0 - c.0, e.1 - c.1, -p.angle);+ let (hw, hh) = (p.size.0 / 2.0, p.size.1 / 2.0);+ let (dx, dy) = (ex - ox, ey - oy);+ let l = (dx * dx + dy * dy).sqrt().max(1.0);+ // The track comes in across whichever side it is latest to pass, slab by slab.+ let tx = if dx.abs() > 1e-9 { ((if dx > 0.0 { -hw } else { hw }) - ox) / dx } else { f64::NEG_INFINITY };+ let ty = if dy.abs() > 1e-9 { ((if dy > 0.0 { -hh } else { hh }) - oy) / dy } else { f64::NEG_INFINITY };+ let normal = if tx >= ty { dx } else { dy };+ Some((normal.abs() / l).asin().to_degrees())+}++/// Pad entry (4.2): at the pad's centre or a corner, with no acute angle. Pairs may enter at the+/// side to keep their spacing, and a large pad or a rail width has no entry to speak of.+fn pad_entry_rules(board: &Board, rules: &Rules, f: &mut Vec<Finding>, s: &mut Score) {+ let pair_nets: std::collections::HashSet<&str> = rules.pairs.iter().flat_map(|p| [p.p.as_str(), p.n.as_str()]).collect();+ for t in &board.tracks {+ if pair_nets.contains(board.net_name(t.net)) || hypot(sub(t.a.into(), t.b.into())) <= JOG {+ continue;+ }+ for (e, o) in [(t.a, t.b), (t.b, t.a)] {+ let (ev, ov): (V, V) = (e.into(), o.into());+ let Some(i) = board.pads.iter().position(|q| q.net == t.net && q.layers.contains(&t.layer) && q.shape.dist_pt(ev) <= 1.0) else { continue };+ let p = &board.pads[i];+ if p.shape.dist_pt(ov) <= 1.0 {+ continue;+ }+ let half = t.width as f64 / 2.0;+ // Wider than the pad's short side: the track covers the whole entry.+ let (hw, hh) = (p.size.0 / 2.0, p.size.1 / 2.0);+ if t.width as f64 >= 2.0 * hw.min(hh) || matches!(p.shape, Shape::Multi(_)) {+ continue;+ }+ let c = p.shape.center();+ let dir = sub(ev, ov);+ let len = hypot(dir).max(1.0);+ let off = (dir.0 * (c.1 - ov.1) - dir.1 * (c.0 - ov.0)).abs() / len;+ let tol = half.max(0.02 * NM);+ s.entries.total += 1;+ let incidence = entry_angle(p, ov, ev);+ if let Some(a) = incidence {+ if a < 88.0 {+ s.entries.oblique += 1;+ }+ if a < ACUTE_ENTRY {+ f.push(Finding { rule: "pad-entry-acute", severity: "warn", at: (ev.0 / NM, ev.1 / NM), text: format!("{}: enters pad {} at {:.0} degrees to its edge (4.2: no acute angles)", board.net_name(t.net), board.pad_name(i), a) });+ }+ }+ if off <= tol {+ s.entries.centre += 1;+ continue;+ }+ let corners = [(-hw, -hh), (hw, -hh), (hw, hh), (-hw, hh)];+ let at_corner = corners.iter().any(|&(x, y)| {+ let (rx, ry) = rot(x, y, p.angle);+ pt_seg(V(c.0 + rx, c.1 + ry), ov, V(ev.0 + dir.0 / len * 0.3 * NM, ev.1 + dir.1 / len * 0.3 * NM)) <= tol+ });+ if at_corner {+ s.entries.corner += 1;+ continue;+ }+ s.entries.side += 1;+ f.push(Finding { rule: "pad-entry", severity: "warn", at: (ev.0 / NM, ev.1 / NM), text: format!("{}: enters pad {} {:.3} mm off its centre and away from its corners (4.2)", board.net_name(t.net), board.pad_name(i), off / NM) });+ }+ }+}++/// More of a board's pad entries oblique to the pad's edge than this: the reference boards' worst is 17 of 110+/// (v1.3). Our routes on the same boards ran 30%, 21% and 10% where he has 15%, 6% and 6%.+const OBLIQUE_RATE: f64 = 0.16;++/// Vias (4.4). A pad's own via sits just clear of the pad, about 0.1 mm. A signal via benefits from+/// a GND via close by: his document gives no number, so the distances are recorded, not gated.+fn via_rules(board: &Board, rules: &Rules, infos: &[NetInfo], f: &mut Vec<Finding>, s: &mut Score) {+ let gnd: Vec<V> = board.vias.iter().filter(|v| looks_ground(board.net_name(v.net))).map(|v| v.at.into()).collect();+ for v in &board.vias {+ let c: V = v.at.into();+ let r = v.size as f64 / 2.0;+ let name = board.net_name(v.net);+ for (i, p) in board.pads.iter().enumerate() {+ if p.net != v.net || p.layers.is_empty() || p.drill.is_some() {+ continue;+ }+ let d = p.shape.dist_pt(c);+ // The land overlaps the pad's edge while its centre stays out: neither in the pad nor+ // clear of it, and the hole can sit in the pad's mask opening.+ if d > 0.0 && d - r < rules.pad_via_gap as f64 / 2.0 && !is_exposed(board, i) {+ f.push(Finding { rule: "via-stand-off", severity: "warn", at: (c.0 / NM, c.1 / NM), text: format!("via on {name} is {:.3} mm from its pad {}; 4.4 puts it about {:.1} mm clear", (d - r) / NM, board.pad_name(i), rules.pad_via_gap as f64 / NM) });+ }+ }+ let level = infos.iter().find(|i| i.net == v.net).map(|i| i.level);+ if matches!(level, Some(Level::Pair) | Some(Level::Data) | Some(Level::Crystal) | Some(Level::Kelvin)) {+ let near = gnd.iter().map(|g| hypot(sub(*g, c))).fold(f64::INFINITY, f64::min);+ if near.is_finite() {+ s.signal_via_gnd_mm.push(near / NM);+ }+ }+ }+ s.signal_via_gnd_mm.sort_by(|a, b| a.total_cmp(b));+ let _ = f;+}++/// Hops (4.2: don't hop to the bottom where the top works): a run off the component layer that+/// starts and ends at vias of one net and comes straight back, under 3 mm in all. A hop that long+/// or shorter crosses at most a track or two; a longer one is routing. Warning only.+fn hop_rule(board: &Board, f: &mut Vec<Finding>) {+ let top = 0usize;+ let mut by: HashMap<(NetId, usize), Vec<&Track>> = HashMap::new();+ for t in &board.tracks {+ if t.layer != top {+ by.entry((t.net, t.layer)).or_default().push(t);+ }+ }+ for ((net, layer), tracks) in by {+ // Group into connected runs.+ let n = tracks.len();+ let mut uf = conn::Uf::new(n);+ for i in 0..n {+ for j in i + 1..n {+ if key(tracks[i].a) == key(tracks[j].a) || key(tracks[i].a) == key(tracks[j].b) || key(tracks[i].b) == key(tracks[j].a) || key(tracks[i].b) == key(tracks[j].b) {+ uf.union(i as u32, j as u32);+ }+ }+ }+ let mut runs: HashMap<u32, Vec<&Track>> = HashMap::new();+ for (i, t) in tracks.iter().enumerate() {+ runs.entry(uf.find(i as u32)).or_default().push(*t);+ }+ for run in runs.values() {+ let len: f64 = run.iter().map(|t| hypot(sub(t.a.into(), t.b.into()))).sum();+ let ends: Vec<P> = board.vias.iter().filter(|v| v.net == net && run.iter().any(|t| key(t.a) == key(v.at) || key(t.b) == key(v.at))).map(|v| v.at).collect();+ let touches_pad = run.iter().any(|t| board.pads.iter().any(|p| p.net == net && p.layers.contains(&layer) && p.shape.dist_seg(t.a.into(), t.b.into()) <= t.width as f64 / 2.0));+ if ends.len() == 2 && !touches_pad && len < 3.0 * NM {+ f.push(Finding { rule: "layer-hop", severity: "warn", at: (to_mm(ends[0].0), to_mm(ends[0].1)), text: format!("{}: hops to {} for {:.2} mm between two vias (4.2)", board.net_name(net), board.layers[layer].name, len / NM) });+ }+ }+ }+}++/// Kelvin nets a spec lists (4.2): one dedicated thin trace from the sense pad to its shunt pad, no+/// branch, about 0.25 mm wide, and no via on the ground-side tap. DRC can't see these.+fn kelvin_rules(board: &Board, rules: &Rules, f: &mut Vec<Finding>) {+ for name in &rules.kelvin {+ let Some(&net) = board.net_ids.get(name) else { continue };+ let pads: Vec<usize> = (0..board.pads.len()).filter(|&i| board.pads[i].net == net).collect();+ let tracks: Vec<&Track> = board.tracks.iter().filter(|t| t.net == net).collect();+ if tracks.is_empty() {+ continue;+ }+ // Each sense pad runs its own trace to the shunt pad (benchmark C +3V3_EXT_SENS: Vbus and Vin- each+ // have one, side by side), so several pads are fine; a junction out in the open is not.+ let mut count: HashMap<P, (usize, P)> = HashMap::new();+ for t in &tracks {+ for e in [t.a, t.b] {+ let c = count.entry(key(e)).or_insert((0, e));+ c.0 += 1;+ }+ }+ for (c, e) in count.values() {+ let v: V = (*e).into();+ let in_copper = board.pads.iter().any(|p| p.net == net && p.shape.dist_pt(v) <= 0.25 * NM);+ if *c >= 3 && !in_copper {+ f.push(Finding { rule: "kelvin", severity: "error", at: (to_mm(e.0), to_mm(e.1)), text: format!("{name}: a Kelvin tap has no branches (4.2)") });+ }+ }+ for t in &tracks {+ if !(mm(0.15)..=mm(0.3)).contains(&t.width) {+ f.push(Finding { rule: "kelvin", severity: "warn", at: (to_mm(t.a.0), to_mm(t.a.1)), text: format!("{name}: {:.3} mm wide; a Kelvin tap is a dedicated thin trace, about 0.25 mm", to_mm(t.width)) });+ }+ }+ // The ground-side tap: its shunt's other pad is a ground, or the name says return or GND.+ let grounded = looks_ground(name) || pads.iter().any(|&p| {+ let fp = &board.footprints[board.pads[p].fp];+ fp.pads.len() == 2 && fp.pads.iter().any(|&q| q != p && looks_ground(board.net_name(board.pads[q].net)))+ });+ if grounded {+ if let Some(v) = board.vias.iter().find(|v| v.net == net) {+ f.push(Finding { rule: "kelvin", severity: "error", at: (to_mm(v.at.0), to_mm(v.at.1)), text: format!("{name}: a via on the ground-side tap (4.2 forbids it)") });+ }+ }+ }+}++/// Differential pairs (4.2): how much of the length actually runs coupled, at what gap, with what+/// skew and how many vias. Recorded for every pair; a pair with almost none coupled gets a warning.+fn pair_stats(board: &Board, rules: &Rules, f: &mut Vec<Finding>, s: &mut Score) {+ for pr in &rules.pairs {+ let (Some(&p), Some(&n)) = (board.net_ids.get(&pr.p), board.net_ids.get(&pr.n)) else { continue };+ let (tp, tn): (Vec<&Track>, Vec<&Track>) = (board.tracks.iter().filter(|t| t.net == p).collect(), board.tracks.iter().filter(|t| t.net == n).collect());+ if tp.is_empty() || tn.is_empty() {+ continue;+ }+ let length = |v: &[&Track]| v.iter().map(|t| hypot(sub(t.a.into(), t.b.into()))).sum::<f64>();+ let mut st = PairStat { p: pr.p.clone(), n: pr.n.clone(), len_p_mm: length(&tp) / NM, len_n_mm: length(&tn) / NM, vias: board.vias.iter().filter(|v| v.net == p || v.net == n).count(), ..Default::default() };+ // Sample P every 50 microns: coupled where a parallel N segment on the layer lies at a gap+ // no more than twice the width of the pair's own, the far limit of edge coupling.+ let mut gaps: Vec<f64> = Vec::new();+ for t in &tp {+ let (a, b): (V, V) = (t.a.into(), t.b.into());+ let d = sub(b, a);+ let l = hypot(d);+ if l <= JOG {+ continue;+ }+ let steps = (l / 50_000.0).ceil() as usize;+ for i in 0..steps {+ let u = (i as f64 + 0.5) / steps as f64;+ let q = V(a.0 + d.0 * u, a.1 + d.1 * u);+ let mut best = f64::INFINITY;+ for o in &tn {+ if o.layer != t.layer {+ continue;+ }+ let e = sub(o.b.into(), o.a.into());+ let cross = (e.0 * d.1 - e.1 * d.0).abs() / (hypot(e) * l).max(1.0);+ if cross > 0.02 {+ continue;+ }+ let g = pt_seg(q, o.a.into(), o.b.into()) - (t.width + o.width) as f64 / 2.0;+ best = best.min(g);+ }+ if best < 2.0 * t.width as f64 {+ st.coupled_mm += l / steps as f64 / NM;+ gaps.push(best);+ }+ }+ }+ if !gaps.is_empty() {+ gaps.sort_by(|a, b| a.total_cmp(b));+ st.gap_mm = gaps[gaps.len() / 2] / NM;+ }+ let at = (to_mm(tp[0].a.0), to_mm(tp[0].a.1));+ if st.len_p_mm >= 1.0 && st.coupled_mm < 0.25 * st.len_p_mm {+ f.push(Finding { rule: "pair-coupling", severity: "warn", at, text: format!("{} / {}: {:.1} mm of {:.1} mm runs coupled (4.2)", pr.p, pr.n, st.coupled_mm, st.len_p_mm) });+ }+ s.pairs.push(st);+ }+}++/// 3W and 2W spacing (4.2), as length: a statistic, since the rule is a target with exceptions.+fn spacing_stats(board: &Board, rules: &Rules, world: &crate::search::World, s: &mut Score) {+ let step = 250_000.0;+ for t in &board.tracks {+ let (a, b): (V, V) = (t.a.into(), t.b.into());+ let d = sub(b, a);+ let l = hypot(d);+ if l <= JOG {+ continue;+ }+ let w = t.width as f64;+ let steps = (l / step).ceil() as usize;+ let piece = l / steps as f64 / NM;+ for i in 0..steps {+ let u = (i as f64 + 0.5) / steps as f64;+ let q = V(a.0 + d.0 * u, a.1 + d.1 * u);+ if world.escapes.iter().any(|e| q.0 >= e.0 && q.0 <= e.2 && q.1 >= e.1 && q.1 <= e.3) {+ s.spacing.escape_mm += piece;+ continue;+ }+ let reach = w / 2.0 + 2.0 * w;+ let mut near = f64::INFINITY;+ world.layers[t.layer].visit((q.0 - reach, q.1 - reach, q.0 + reach, q.1 + reach), |o| {+ if o.net != t.net && !matches!(o.owner, Owner::Zone(_)) {+ near = near.min(o.shape.dist_pt(q) - w / 2.0);+ }+ true+ });+ // 3W and 2W are centre to centre: 3W leaves a gap of 2W, 2W a gap of 1W (4.2).+ if near >= 2.0 * w - 1000.0 {+ s.spacing.at_3w_mm += piece;+ } else if near >= w - 1000.0 {+ s.spacing.at_2w_mm += piece;+ } else {+ s.spacing.under_2w_mm += piece;+ }+ }+ }+}++/// Solder mask bridges and webs (see mask.rs for what was measured).+fn mask_rules(board: &Board, rules: &Rules, f: &mut Vec<Finding>) {+ let map = MaskMap::new(board, rules);+ let sev = rules.severity("solder_mask_bridge");+ let mm_at = |p: P| (to_mm(p.0), to_mm(p.1));+ let side = |l: usize| &board.layers[l].name;+ for t in &board.tracks {+ if let Some((i, d)) = map.seg_hit(t.net, t.layer, t.a.into(), t.b.into(), t.width as f64 / 2.0) {+ f.push(Finding { rule: "mask-bridge", severity: sev, at: mm_at(t.a), text: format!("{} on {} is {:.3} mm from pad {} ({}): it bridges that pad's mask opening", board.net_name(t.net), side(t.layer), d / NM, board.pad_name(i), board.net_name(board.pads[i].net)) });+ }+ }+ for v in &board.vias {+ if let Some((i, d)) = map.via_hit(v.net, v.at.into(), v.size as f64 / 2.0) {+ f.push(Finding { rule: "mask-bridge", severity: sev, at: mm_at(v.at), text: format!("via on {} is {:.3} mm from pad {} ({}): it bridges that pad's mask opening", board.net_name(v.net), d / NM, board.pad_name(i), board.net_name(board.pads[i].net)) });+ }+ }+ for td in &board.teardrops {+ if let Some((i, d)) = map.poly_hit(td.net, td.layer, &td.pts) {+ let at = td.pts.first().map(|p| (p.0 / NM, p.1 / NM)).unwrap_or((0.0, 0.0));+ f.push(Finding { rule: "mask-bridge", severity: sev, at, text: format!("teardrop on {} is {:.3} mm from pad {} ({}): it bridges that pad's mask opening", board.net_name(td.net), d / NM, board.pad_name(i), board.net_name(board.pads[i].net)) });+ }+ }+ // Pads of two footprints: copper in an opening, and the web between two openings.+ let web = board.mask.web;+ let extra = rules.min.mask_to_copper as f64;+ for (i, p) in board.pads.iter().enumerate() {+ for (j, q) in board.pads.iter().enumerate().skip(i + 1) {+ if p.net == q.net && p.net != NO_NET {+ continue;+ }+ if p.fp == q.fp && board.mask.allow_bridges_in_footprints {+ continue;+ }+ let front = p.mask.0 && q.mask.0;+ let back = p.mask.1 && q.mask.1;+ if !(front || back) {+ continue;+ }+ let (pb, qb) = (p.shape.bbox(), q.shape.bbox());+ let m = p.mask_margin.max(0.0) + q.mask_margin.max(0.0) + web.max(extra);+ if pb.0 > qb.2 + m || qb.0 > pb.2 + m || pb.1 > qb.3 + m || qb.1 > pb.3 + m {+ continue;+ }+ let d = p.shape.dist_shape(&q.shape);+ let limit = p.mask_margin + q.mask_margin + web;+ if d < limit - TOL {+ f.push(Finding { rule: "mask-web", severity: rules.severity("solder_mask_bridge"), at: mm_at(p.center), text: format!("mask openings of pads {} and {} leave a web of {:.3} mm (minimum {:.3})", board.pad_name(i), board.pad_name(j), (d - p.mask_margin - q.mask_margin) / NM, web / NM) });+ }+ }+ }+}++pub fn check(board: &Board, rules: &Rules) -> (Vec<Finding>, Score) {+ let mut f: Vec<Finding> = Vec::new();+ let mut s = Score { min_gap_mm: f64::INFINITY, ..Default::default() };+ let mm_at = |p: P| (to_mm(p.0), to_mm(p.1));+ // Net ids belong to the parsed file, and a written board is parsed afresh.+ let mut bound = rules.clone();+ bound.bind(board);+ let rules = &bound;+ let world = build_world(board, rules);++ // Connections.+ let infos = classify(board, rules);+ let groups = conn::pad_groups(board, &[]);+ let planes = board.plane_nets();+ for info in &infos {+ let Some(g) = groups.get(&info.net) else { continue };+ let live: Vec<&Vec<usize>> = g.iter().filter(|grp| grp.iter().any(|p| info.pads.contains(p))).collect();+ if live.len() > 1 {+ s.nets_open += 1;+ s.pads_open += live.len() - 1;+ let names: Vec<String> = live.iter().map(|grp| board.pad_name(grp[0])).collect();+ let at = board.pads[live[1][0]].center;+ f.push(Finding { rule: "unconnected", severity: "error", at: mm_at(at), text: format!("{}: {} separate islands, e.g. {}", board.net_name(info.net), live.len(), names.join(", ")) });+ }+ }++ // 4.2: every track against the current through it, found from the connected copper.+ for (name, rail) in &rules.rails {+ let Some(net) = board.net_ids.iter().find(|(k, _)| k.eq_ignore_ascii_case(name)).map(|(_, v)| *v) else { continue };+ if std::env::var_os("ADOM_ROUTE_DROPS").is_some() {+ for d in crate::current::rail_drops(board, rules, net, rail) {+ eprintln!(" drop {} to {}: {:.3} A, {:.1} mV, {:.2}% of {:.1} V (limit {:.1} mV)", board.net_name(net), d.load, d.amps, d.volts * 1000.0, d.volts / (d.limit / crate::current::DROP_LIMIT) * 100.0, d.limit / crate::current::DROP_LIMIT, d.limit * 1000.0);+ }+ }+ let mut short: std::collections::BTreeMap<(i64, bool), (f64, f64, i64, P)> = Default::default();+ for x in crate::current::rate_rail(board, rules, net, rail) {+ let t = &board.tracks[x.track];+ if t.width + 1000 < x.need {+ let e = short.entry((t.width, x.by_drop)).or_insert((0.0, 0.0, 0, t.a));+ e.0 += hypot(sub(t.a.into(), t.b.into())) / NM;+ if x.amps > e.1 || x.need > e.2 {+ e.1 = x.amps.max(e.1);+ e.2 = x.need.max(e.2);+ e.3 = t.a;+ }+ }+ }+ for ((width, by_drop), (len, amps, need, at)) in short {+ let why = if by_drop { format!("which needs {:.2} mm to keep the copper's drop to its load under {:.2}% of the rail (the designer's limit)", to_mm(need), crate::current::DROP_LIMIT * 100.0) } else { format!("which needs {:.2} mm (4.2)", to_mm(need)) };+ f.push(Finding { rule: "rail-current", severity: "error", at: mm_at(at), text: format!("{}: {:.1} mm at {:.2} mm carries up to {:.3} A, {why}", board.net_name(net), len, to_mm(width), amps) });+ }+ }++ // An exception stays in front of whoever reads the report until they have decided on it.+ for ((net, pad), min) in &rules.pad_exit {+ let p = &board.pads[*pad as usize];+ f.push(Finding { rule: "pad-rule", severity: "warn", at: mm_at(p.center), text: format!("{} may pass pad {} at {:.3} mm under a local .kicad_dru rule (its class asks {:.3}): review it", board.net_name(*net), board.pad_name(*pad as usize), to_mm(*min), to_mm(rules.clearance_for(*net, p.net))) });+ }+ // A pour is only as connected as its refill leaves it, whatever shape the file saved.+ for sp in crate::pour::splits(board, rules, &world) {+ let by = if sp.cutters.is_empty() { String::new() } else { format!(", cut by {}", sp.cutters.iter().map(|&n| board.net_name(n)).collect::<Vec<_>>().join(", ")) };+ s.nets_open += 1;+ s.pads_open += sp.extra;+ f.push(Finding { rule: "pour-split", severity: "error", at: (sp.at.0 / NM, sp.at.1 / NM), text: format!("{}: refilling its pour on {} leaves {} extra separate pieces{by}", board.net_name(sp.net), board.layers[sp.layer].name, sp.extra) });+ }++ // Copper clearance: tracks and vias against everything of another net, on each layer, by the+ // two nets' classes. Zones refill around tracks, so they are the DRC's job after refill, not+ // this check's. Holes and keep-outs have their own rules below, as KiCad has.+ let reach_clr = rules.max_clearance() as f64;+ let mut gap_note = |gap: f64, s: &mut Score| {+ if gap < s.min_gap_mm {+ s.min_gap_mm = gap;+ }+ };+ let hole_clr = rules.hole_clearance() as f64;+ let sev_hole = rules.severity("hole_clearance");+ let plane_layers = board.plane_layers();+ for t in board.tracks.iter() {+ let (a, b): (V, V) = (t.a.into(), t.b.into());+ let half = t.width as f64 / 2.0;+ let reach = half + reach_clr * 2.0;+ let bb = (a.0.min(b.0) - reach, a.1.min(b.1) - reach, a.0.max(b.0) + reach, a.1.max(b.1) + reach);+ let mut worst: Option<(f64, f64, String)> = None;+ world.layers[t.layer].visit(bb, |o| {+ if o.net == t.net && t.net != NO_NET {+ return true;+ }+ match o.owner {+ Owner::Zone(_) => return true,+ Owner::Keepout => {+ // A keep-out area has no clearance of its own. A hole is the hole rule's job.+ if matches!(o.shape, Shape::Poly { .. }) && o.shape.dist_seg(a, b) <= 0.0 {+ f.push(Finding { rule: "keepout", severity: rules.severity("items_not_allowed"), at: mm_at(t.a), text: format!("{} on {} enters a keep-out area", board.net_name(t.net), board.layers[t.layer].name) });+ }+ return true;+ }+ _ => {}+ }+ let local = if let Owner::Pad(i) = o.owner { world.pad_clearance[i as usize] } else { 0.0 };+ let pad = if let Owner::Pad(i) = o.owner { Some(i) } else { None };+ let clr = (rules.clearance_at(t.net, o.net, pad) as f64).max(local);+ let d = o.shape.dist_seg(a, b) - half;+ if d < clr * 2.0 {+ gap_note(d.max(0.0) / NM, &mut s);+ }+ if d < clr - TOL && worst.as_ref().map(|w| d - clr < w.0 - w.1).unwrap_or(true) {+ let who = match o.owner {+ Owner::Pad(i) => format!("pad {}", board.pad_name(i as usize)),+ _ => board.net_name(o.net).to_string(),+ };+ worst = Some((d, clr, who));+ }+ true+ });+ if let Some((d, clr, who)) = worst {+ f.push(Finding { rule: "clearance", severity: "error", at: mm_at(t.a), text: format!("{} on {} is {:.3} mm from {} (minimum {:.3})", board.net_name(t.net), board.layers[t.layer].name, d.max(0.0) / NM, who, clr / NM) });+ }+ // Copper to a hole of another net: every layer sees every through hole.+ let hr = half + hole_clr + world.max_local + 1.0 * NM;+ world.holes.visit((a.0.min(b.0) - hr, a.1.min(b.1) - hr, a.0.max(b.0) + hr, a.1.max(b.1) + hr), |o| {+ if (o.net == t.net && t.net != NO_NET) || matches!(o.shape, Shape::Poly { .. }) {+ return true;+ }+ let d = o.shape.dist_seg(a, b) - half;+ let hole_local = if let Owner::Pad(i) = o.owner { world.pad_clearance[i as usize] } else { 0.0 };+ if d < hole_clr.max(hole_local) - TOL {+ f.push(Finding { rule: "hole-clearance", severity: sev_hole, at: mm_at(t.a), text: format!("{} on {} is {:.3} mm from a hole (minimum {:.3})", board.net_name(t.net), board.layers[t.layer].name, d.max(0.0) / NM, hole_clr / NM) });+ return false;+ }+ true+ });+ let e = half + rules.edge as f64;+ let mut near = f64::INFINITY;+ world.edge.visit((a.0.min(b.0) - e, a.1.min(b.1) - e, a.0.max(b.0) + e, a.1.max(b.1) + e), |o| {+ near = near.min(o.shape.dist_seg(a, b));+ true+ });+ if near < e - TOL {+ f.push(Finding { rule: "edge", severity: rules.severity("copper_edge_clearance"), at: mm_at(t.a), text: format!("{}: track {:.3} mm from the board edge (minimum {:.3})", board.net_name(t.net), (near - half).max(0.0) / NM, rules.edge as f64 / NM) });+ }+ // Narrow copper: his 0.15 mm (2.2), and the board setup's own minimum, which KiCad enforces.+ if t.width < rules.neck - 1000 {+ f.push(Finding { rule: "width", severity: "error", at: mm_at(t.a), text: format!("{}: track {:.3} mm is under the {:.3} mm minimum", board.net_name(t.net), t.width as f64 / NM, rules.neck as f64 / NM) });+ }+ if rules.min.track > 0 && t.width < rules.min.track - 1000 {+ f.push(Finding { rule: "width-setup", severity: rules.severity("track_width"), at: mm_at(t.a), text: format!("{}: track {:.3} mm is under the board setup's {:.3} mm minimum (2.2: the setup should allow the 0.15 mm necks)", board.net_name(t.net), t.width as f64 / NM, rules.min.track as f64 / NM) });+ }+ *s.widths.entry(format!("{:.3}", t.width as f64 / NM)).or_insert(0) += 1;+ let len = hypot(sub(a, b));+ s.length_mm += len / NM;+ if t.layer == 0 {+ s.length_top_mm += len / NM;+ } else if t.layer == board.layers.len() - 1 {+ s.length_bottom_mm += len / NM;+ } else {+ s.length_inner_mm += len / NM;+ if plane_layers.contains(&t.layer) && t.width > 0 {+ f.push(Finding { rule: "inner-route", severity: "error", at: mm_at(t.a), text: format!("{}: track inside the plane layer {}; it cuts the return path (2.2, 4.2)", board.net_name(t.net), board.layers[t.layer].name) });+ }+ }+ s.segments += 1;+ // Direction: 0, 45 or 90 degrees only; anything else is an error (4.2).+ if len > JOG {+ let ang = out_angle(t.a, t.b).rem_euclid(45.0);+ let off = ang.min(45.0 - ang);+ if off > 0.5 {+ f.push(Finding { rule: "off-45", severity: "error", at: mm_at(t.a), text: format!("{}: segment {:.3} mm long is {:.1} degrees off the 45-degree directions", board.net_name(t.net), len / NM, off) });+ }+ }+ }+ let hole_hole = rules.hole_to_hole() as f64;+ let sev_hh = rules.severity("hole_to_hole");+ for (vi, v) in board.vias.iter().enumerate() {+ let c: V = v.at.into();+ let r = v.size as f64 / 2.0;+ let reach = r + reach_clr;+ let mut worst: Option<(f64, f64, String)> = None;+ let dbg = std::env::var("ADOM_ROUTE_DEBUG_VIA").ok().and_then(|s| { let mut it = s.split(',').filter_map(|x| x.parse::<f64>().ok()); Some((it.next()?, it.next()?)) }).map_or(false, |(x, y)| (to_mm(v.at.0) - x).abs() < 0.02 && (to_mm(v.at.1) - y).abs() < 0.02);+ for l in 0..board.layers.len() {+ world.layers[l].visit((c.0 - reach, c.1 - reach, c.0 + reach, c.1 + reach), |o| {+ if dbg {+ eprintln!("debug via {} layer {}: obstacle net {} owner {:?} dist {:.4} mm", board.net_name(v.net), board.layers[l].name, board.net_name(o.net), o.owner, (o.shape.dist_pt(c) - r) / NM);+ }+ if (o.net == v.net && v.net != NO_NET) || matches!(o.owner, Owner::Zone(_) | Owner::Keepout) {+ return true;+ }+ let local = if let Owner::Pad(i) = o.owner { world.pad_clearance[i as usize] } else { 0.0 };+ let pad = if let Owner::Pad(i) = o.owner { Some(i) } else { None };+ let clr = (rules.clearance_at(v.net, o.net, pad) as f64).max(local);+ let d = o.shape.dist_pt(c) - r;+ if d < clr - TOL && worst.as_ref().map(|w| d - clr < w.0 - w.1).unwrap_or(true) {+ worst = Some((d, clr, format!("{} on {}", board.net_name(o.net), board.layers[l].name)));+ }+ true+ });+ }+ if let Some((d, clr, who)) = worst {+ f.push(Finding { rule: "clearance", severity: "error", at: mm_at(v.at), text: format!("via on {} is {:.3} mm from {} (minimum {:.3})", board.net_name(v.net), d.max(0.0) / NM, who, clr / NM) });+ }+ // The via's copper and its hole against every other hole. The hole-to-hole rule covers+ // any net (2.2); a same-net via pair is judged on holes only, as KiCad does (R11).+ let hd = v.drill as f64 / 2.0;+ let span = r + hole_clr.max(hole_hole) + 1.0 * NM;+ world.holes.visit((c.0 - span, c.1 - span, c.0 + span, c.1 + span), |o| {+ if let Shape::Poly { .. } = o.shape {+ if o.shape.dist_pt(c) <= 0.0 {+ f.push(Finding { rule: "keepout", severity: rules.severity("items_not_allowed"), at: mm_at(v.at), text: format!("via on {} in a keep-out area", board.net_name(v.net)) });+ }+ return true;+ }+ let dc = o.shape.dist_pt(c);+ if dc <= 0.0 && matches!(o.owner, Owner::Fixed) && hypot(sub(o.shape.center(), c)) < 1.0 {+ return true; // its own hole+ }+ if o.net != v.net || v.net == NO_NET {+ if dc - r < hole_clr - TOL {+ f.push(Finding { rule: "hole-clearance", severity: sev_hole, at: mm_at(v.at), text: format!("via on {} is {:.3} mm from a hole (minimum {:.3})", board.net_name(v.net), (dc - r).max(0.0) / NM, hole_clr / NM) });+ }+ }+ if dc - hd < hole_hole - TOL {+ f.push(Finding { rule: "hole-to-hole", severity: sev_hh, at: mm_at(v.at), text: format!("via hole on {} is {:.3} mm from another hole (minimum {:.3})", board.net_name(v.net), (dc - hd).max(0.0) / NM, hole_hole / NM) });+ }+ true+ });+ let e = r + rules.edge as f64;+ let mut near = f64::INFINITY;+ world.edge.visit((c.0 - e, c.1 - e, c.0 + e, c.1 + e), |o| {+ near = near.min(o.shape.dist_pt(c));+ true+ });+ if near < e - TOL {+ f.push(Finding { rule: "edge", severity: rules.severity("copper_edge_clearance"), at: mm_at(v.at), text: format!("{}: via {:.3} mm from the board edge (minimum {:.3})", board.net_name(v.net), (near - r).max(0.0) / NM, rules.edge as f64 / NM) });+ }+ // KiCad's own limits on a via, from the board setup.+ let annular = (v.size - v.drill) / 2;+ if v.size < rules.min.via_dia - 1000 || v.drill < rules.min.through_drill - 1000 || annular < rules.min.annular - 1000 {+ f.push(Finding { rule: "via-min", severity: "error", at: mm_at(v.at), text: format!("via {:.2}/{:.2} on {} is under the board setup's minimum", v.drill as f64 / NM, v.size as f64 / NM, board.net_name(v.net)) });+ }+ // 4.4: 0.3/0.6 is the only size; larger on a high-current net "when the current calculation+ // calls for it", so a current on record for that net in the spec, not just any 0.4/0.8.+ let standard = v.size == rules.via && v.drill == rules.drill;+ let earned = v.size > rules.via && rules.has_load(board.net_name(v.net));+ if !standard && !earned {+ f.push(Finding { rule: "via-size", severity: "warn", at: mm_at(v.at), text: format!("via {:.2}/{:.2} (drill/diameter) on {}: 0.3/0.6 is the only size unless one is specified, or a current on record for the net calls for more (4.4)", v.drill as f64 / NM, v.size as f64 / NM, board.net_name(v.net)) });+ }+ if !(v.tent.0 && v.tent.1) {+ f.push(Finding { rule: "via-tent", severity: "warn", at: mm_at(v.at), text: format!("via on {} is not tented on both sides (4.4)", board.net_name(v.net)) });+ }+ for (i, p) in board.pads.iter().enumerate() {+ if p.layers.is_empty() || p.drill.is_some() {+ continue;+ }+ // The rule is about solder paste: a pad with no paste layer (benchmark C U5.10, a ground bar+ // meant to carry vias) is free of it, and a heat pad takes thermal vias on purpose.+ if p.paste && p.shape.dist_pt(c) <= 0.0 && !is_exposed(board, i) {+ f.push(Finding { rule: "via-in-pad", severity: "error", at: mm_at(v.at), text: format!("via in pad {}: plan for capped vias or keep it off the paste (4.4)", board.pad_name(i)) });+ }+ }+ let _ = vi;+ }+ crate::placement::crystal_rules(board, &mut f);+ crate::placement::decoupling_rules(board, rules, &mut f, &mut s.own_cap_mm);+ s.own_cap_mm.sort_by(|a, b| b.0.total_cmp(&a.0));+ crate::placement::inner_fill_rule(board, rules, &mut f);+ pad_pad_rules(board, rules, &world, &mut f);+ mask_rules(board, rules, &mut f);+ pad_entry_rules(board, rules, &mut f, &mut s);+ if s.entries.total >= 20 && s.entries.oblique as f64 / s.entries.total as f64 > OBLIQUE_RATE {+ f.push(Finding { rule: "pad-entry-oblique", severity: "warn", at: (0.0, 0.0), text: format!("{} of {} pad entries come in at an angle to the pad's edge; the reference boards' worst is 15% (4.2: no acute angles, when possible)", s.entries.oblique, s.entries.total) });+ }+ via_rules(board, rules, &infos, &mut f, &mut s);+ hop_rule(board, &mut f);+ kelvin_rules(board, rules, &mut f);+ pair_stats(board, rules, &mut f, &mut s);+ spacing_stats(board, rules, &world, &mut s);+ let copper_nodes = crate::quality::Nodes::new(board);+ crate::quality::shape_rules(board, &copper_nodes, &mut f);+ crate::quality::net_rules(board, rules, &infos, &copper_nodes, &mut f, &mut s);+ crate::quality::island_rules(board, &mut f);+ s.vias = board.vias.len();++ let rail = rules.power_floor - 1000;+ // 4.2 allows one 90-degree shape: a stub off a large power rail. Width alone doesn't make a rail+ // (never judge current from a width), and two equal sides are a bend in the rail, not a stub.+ let powered = |net: NetId| looks_power(board.net_name(net)) || rules.wide.contains_key(board.net_name(net));+ // Shape: corners and junctions, per net and layer. A jog of a few microns where two widths meet+ // is one node, not two bends, so its ends are merged first.+ let mut link: HashMap<(NetId, usize, P), P> = HashMap::new();+ fn root(link: &HashMap<(NetId, usize, P), P>, net: NetId, layer: usize, mut k: P) -> P {+ while let Some(&n) = link.get(&(net, layer, k)) {+ k = n;+ }+ k+ }+ for t in &board.tracks {+ if hypot(sub(t.a.into(), t.b.into())) <= JOG {+ let (x, y) = (root(&link, t.net, t.layer, key(t.a)), root(&link, t.net, t.layer, key(t.b)));+ if x != y {+ link.insert((t.net, t.layer, x), y);+ }+ }+ }+ let rep = |net: NetId, layer: usize, p: P| root(&link, net, layer, key(p));+ let mut nodes: HashMap<(NetId, usize, P), Vec<usize>> = HashMap::new();+ for (i, t) in board.tracks.iter().enumerate() {+ if hypot(sub(t.a.into(), t.b.into())) <= JOG {+ continue;+ }+ nodes.entry((t.net, t.layer, rep(t.net, t.layer, t.a))).or_default().push(i);+ nodes.entry((t.net, t.layer, rep(t.net, t.layer, t.b))).or_default().push(i);+ }+ let far = |t: &Track, n: P| if rep(t.net, t.layer, t.a) == n { (t.a, t.b) } else { (t.b, t.a) };+ // Copper of the net's own that hides a corner: a pad (or the quarter millimetre the teardrop+ // covers), a via's land, or a same-net fill. No bend exists there.+ let hidden = |net: NetId, layer: usize, v: V| {+ board.pads.iter().any(|q| q.net == net && q.layers.contains(&layer) && q.shape.dist_pt(v) <= 0.25 * NM)+ || board.vias.iter().any(|w| w.net == net && hypot(sub(w.at.into(), v)) <= w.size as f64 / 2.0)+ || world.zones.iter().any(|(zn, zl, poly)| *zn == net && *zl == layer && in_poly(v, poly))+ // Inside a same-net rail wide enough to swallow the corner (benchmark C +3V3_EXT 218.00, 93.26:+ // a 0.2 mm stub landing square on the 0.5 mm run, the rail exception in 4.2).+ || powered(net) && board.tracks.iter().any(|o| o.net == net && o.layer == layer && o.width >= rail && key(o.a) != key(to_p(v)) && key(o.b) != key(to_p(v)) && pt_seg(v, o.a.into(), o.b.into()) <= o.width as f64 / 2.0)+ };+ for ((net, layer, n), list) in &nodes {+ let mut dirs: Vec<(f64, usize, i64)> = list+ .iter()+ .map(|&i| {+ let t = &board.tracks[i];+ let (a, b) = far(t, *n);+ (out_angle(a, b), i, t.width)+ })+ .collect();+ dirs.sort_by(|a, b| a.0.total_cmp(&b.0));+ let at_p = far(&board.tracks[list[0]], *n).0;+ let v: V = at_p.into();+ let in_pad = hidden(*net, *layer, v);+ if dirs.len() == 2 {+ let between = diff(dirs[0].0, dirs[1].0);+ let turn = 180.0 - between;+ if turn < 1.0 {+ continue;+ }+ if !in_pad {+ if (turn - 45.0).abs() < 3.0 {+ s.bends45 += 1;+ } else if (turn - 90.0).abs() < 3.0 {+ s.bends90 += 1;+ let is_rail_stub = powered(*net) && dirs[0].2.max(dirs[1].2) >= rail && dirs[0].2 != dirs[1].2;+ if !is_rail_stub {+ f.push(Finding { rule: "corner-90", severity: "error", at: mm_at(at_p), text: format!("{}: 90-degree bend on {} (only a stub off a large power rail may)", board.net_name(*net), board.layers[*layer].name) });+ }+ } else if between < 88.0 && between > 3.0 {+ f.push(Finding { rule: "corner-acute", severity: "error", at: mm_at(at_p), text: format!("{}: acute corner of {:.0} degrees on {}", board.net_name(*net), between, board.layers[*layer].name) });+ }+ }+ } else if dirs.len() >= 3 && !in_pad {+ // A branch joins as a Y (45/135), never a square T (4.2).+ for a in 0..dirs.len() {+ for b in a + 1..dirs.len() {+ if (diff(dirs[a].0, dirs[b].0) - 180.0).abs() < 2.0 {+ for c in 0..dirs.len() {+ if c != a && c != b && (diff(dirs[c].0, dirs[a].0) - 90.0).abs() < 3.0 && !(powered(*net) && dirs[a].2 >= rail && dirs[c].2 <= dirs[a].2) {+ f.push(Finding { rule: "square-t", severity: "error", at: mm_at(at_p), text: format!("{}: square T on {}; signal branches join as a Y", board.net_name(*net), board.layers[*layer].name) });+ }+ }+ }+ }+ }+ if infos.iter().any(|i| i.net == *net && i.level == Level::Crystal) {+ f.push(Finding { rule: "crystal-chain", severity: "error", at: mm_at(at_p), text: format!("{}: junction on a crystal line; XIN/XOUT run as a daisy chain (4.2)", board.net_name(*net)) });+ }+ }+ }++ // T junctions onto the middle of a track.+ for (i, t) in board.tracks.iter().enumerate() {+ for end in [t.a, t.b] {+ let e: V = end.into();+ if hypot(sub(t.a.into(), t.b.into())) <= JOG || hidden(t.net, t.layer, e) {+ continue;+ }+ for (j, o) in board.tracks.iter().enumerate() {+ if i == j || o.net != t.net || o.layer != t.layer {+ continue;+ }+ let n = rep(t.net, t.layer, end);+ if rep(o.net, o.layer, o.a) == n || rep(o.net, o.layer, o.b) == n {+ continue;+ }+ if pt_seg(e, o.a.into(), o.b.into()) > 2000.0 {+ continue;+ }+ let (a1, a2) = (out_angle(end, if key(t.a) == key(end) { t.b } else { t.a }), out_angle(o.a, o.b));+ let d = diff(a1, a2);+ if (d - 90.0).abs() < 3.0 && !(powered(t.net) && o.width >= rail && t.width <= o.width) {+ f.push(Finding { rule: "square-t", severity: "error", at: mm_at(end), text: format!("{}: square T onto a track on {}; signal branches join as a Y", board.net_name(t.net), board.layers[t.layer].name) });+ }+ }+ }+ }++ // A via inside a straight run (4.4: no via inside a trace).+ for v in &board.vias {+ for l in 0..board.layers.len() {+ let n = rep(v.net, l, v.at);+ if let Some(list) = nodes.get(&(v.net, l, n)) {+ if list.len() == 2 {+ let (t1, t2) = (&board.tracks[list[0]], &board.tracks[list[1]]);+ let (a1, b1) = far(t1, n);+ let (a2, b2) = far(t2, n);+ if (diff(out_angle(a1, b1), out_angle(a2, b2)) - 180.0).abs() < 2.0 {+ f.push(Finding { rule: "via-in-trace", severity: "warn", at: mm_at(v.at), text: format!("{}: via inside a straight run on {}", board.net_name(v.net), board.layers[l].name) });+ }+ }+ }+ }+ }++ // Plane nets: pads the plane doesn't reach get their own via (4.2). Sharing is reported.+ let _ = &planes;+ if !s.min_gap_mm.is_finite() {+ s.min_gap_mm = 0.0;+ }+ f.sort_by(|a, b| (a.severity != "error", a.rule).cmp(&(b.severity != "error", b.rule)));+ (f, s)+}++fn r2(x: f64) -> f64 {+ (x * 100.0).round() / 100.0+}++pub fn to_json(f: &[Finding], s: &Score) -> Value {+ let mut per: BTreeMap<&str, usize> = BTreeMap::new();+ for x in f {+ *per.entry(x.rule).or_insert(0) += 1;+ }+ json!({+ "errors": f.iter().filter(|x| x.severity == "error").count(),+ "warnings": f.iter().filter(|x| x.severity != "error").count(),+ "perRule": per,+ "score": {+ "netsOpen": s.nets_open, "padsOpen": s.pads_open,+ "lengthMm": (s.length_mm * 100.0).round() / 100.0,+ "topMm": (s.length_top_mm * 100.0).round() / 100.0,+ "bottomMm": (s.length_bottom_mm * 100.0).round() / 100.0,+ "innerMm": (s.length_inner_mm * 100.0).round() / 100.0,+ "vias": s.vias, "segments": s.segments, "bends45": s.bends45, "bends90": s.bends90,+ "widths": s.widths, "minGapMm": (s.min_gap_mm * 1000.0).round() / 1000.0,+ "spacingMm": {+ "at3W": r2(s.spacing.at_3w_mm), "at2W": r2(s.spacing.at_2w_mm), "tighter": r2(s.spacing.under_2w_mm), "inEscape": r2(s.spacing.escape_mm)+ },+ "pairs": s.pairs.iter().map(|p| json!({"p": p.p, "n": p.n, "lenP": r2(p.len_p_mm), "lenN": r2(p.len_n_mm), "coupled": r2(p.coupled_mm), "gapMm": (p.gap_mm * 1000.0).round() / 1000.0, "vias": p.vias})).collect::<Vec<_>>(),+ "signalViaGndMm": s.signal_via_gnd_mm.iter().map(|x| r2(*x)).collect::<Vec<_>>(),+ "widthSteps": s.width_steps,+ "ownCapMm": s.own_cap_mm.iter().take(5).map(|x| json!([r2(x.0), x.1])).collect::<Vec<_>>(),+ "padEntries": {"total": s.entries.total, "centre": s.entries.centre, "corner": s.entries.corner, "side": s.entries.side, "oblique": s.entries.oblique},+ "capFirst": {"pins": s.cap_first.pins, "direct": s.cap_first.direct, "cap": s.cap_first.cap, "onFill": s.cap_first.on_fill, "junction": s.cap_first.junction},+ "nets": s.nets.iter().map(|n| json!({"net": n.name, "level": n.level, "pads": n.pads, "lenMm": r2(n.len_mm), "treeMm": r2(n.tree_mm), "vias": n.vias, "widthSteps": n.width_steps, "fill": n.fill})).collect::<Vec<_>>(),+ "rails": s.rails.iter().map(|n| json!({"net": n.name, "mainWidthMm": n.main_width_mm, "mainLenMm": r2(n.main_len_mm), "fedByFill": n.fed_by_fill, "branches": n.branches})).collect::<Vec<_>>()+ },+ "findings": f.iter().map(|x| json!({"rule": x.rule, "severity": x.severity, "atMm": [x.at.0, x.at.1], "text": x.text})).collect::<Vec<_>>()+ })+}++#[cfg(test)]+mod tests {+ use super::*;+ use crate::project;++ fn board(extra: &str) -> Board {+ let head = r#"(kicad_pcb (layers (0 "F.Cu" signal) (2 "B.Cu" signal)) (setup (pad_to_mask_clearance 0.05))+ (gr_rect (start 0 0) (end 30 30) (layer "Edge.Cuts"))+ (footprint "x" (layer "F.Cu") (at 10 10) (property "Reference" "R1")+ (pad "1" smd rect (at 0 0) (size 1 1) (layers "F.Cu" "F.Mask") (net "A") (solder_mask_margin 0.102))+ (pad "2" smd rect (at 5 0) (size 1 1) (layers "F.Cu" "F.Mask") (net "B")))"#;+ Board::parse(format!("{head}{extra})")).unwrap()+ }++ fn track(y: f64, net: &str) -> String {+ format!(r#"(segment (start 8 {y}) (end 12 {y}) (width 0.2) (layer "F.Cu") (net "{net}"))"#)+ }++ fn rules(mask_to_copper: f64) -> Rules {+ let mut r = Rules::default();+ r.min.mask_to_copper = mm(mask_to_copper);+ r+ }++ fn count(f: &[Finding], rule: &str) -> usize {+ f.iter().filter(|x| x.rule == rule).count()+ }++ #[test]+ fn mask_bridge_is_margin_plus_mask_to_copper() {+ // Pad edge at y 10.5, so the track edge is y - 0.1 - 10.5 from the pad.+ let near = board(&track(10.771, "C")); // 0.171: under 0.102 + 0.1, as on the routed PiProbe+ let (f, _) = check(&near, &rules(0.1));+ assert_eq!(count(&f, "mask-bridge"), 1);+ let far = board(&track(10.82, "C")); // 0.22: clear of 0.202+ let (f, _) = check(&far, &rules(0.1));+ assert_eq!(count(&f, "mask-bridge"), 0);+ // With no mask-to-copper setting, as on the benchmark C, only the margin counts.+ let (f, _) = check(&near, &rules(0.0));+ assert_eq!(count(&f, "mask-bridge"), 0);+ // Same net never bridges.+ let own = board(&track(10.771, "A"));+ let (f, _) = check(&own, &rules(0.1));+ assert_eq!(count(&f, "mask-bridge"), 0);+ }++ #[test]+ fn pad_margin_falls_back_to_the_board() {+ let b = board("");+ assert_eq!(b.pads[0].mask_margin, 0.102 * NM);+ assert_eq!(b.pads[1].mask_margin, 0.05 * NM);+ }++ #[test]+ fn a_micro_jog_is_not_a_bend_and_a_real_one_is() {+ let jog = r#"(segment (start 2 5) (end 6 5) (width 0.2) (layer "F.Cu") (net "C"))+ (segment (start 6 5) (end 6 5.004) (width 0.2) (layer "F.Cu") (net "C"))+ (segment (start 6 5.004) (end 10 5.004) (width 0.2) (layer "F.Cu") (net "C"))"#;+ let (f, s) = check(&board(jog), &rules(0.0));+ assert_eq!(count(&f, "corner-90"), 0);+ assert_eq!(s.bends90, 0);+ let bend = r#"(segment (start 2 5) (end 6 5) (width 0.2) (layer "F.Cu") (net "C"))+ (segment (start 6 5) (end 6 8) (width 0.2) (layer "F.Cu") (net "C"))"#;+ let (f, _) = check(&board(bend), &rules(0.0));+ assert_eq!(count(&f, "corner-90"), 1);+ }++ #[test]+ fn overlapping_fills_join_a_net() {+ // Two pads each reached by their own zone; the zones overlap but neither's first vertex is+ // inside the other, which the old one-vertex test missed.+ let z = |x0: f64, x1: f64| format!(r#"(zone (net "A") (layer "F.Cu") (polygon (pts (xy {x0} 8) (xy {x1} 8) (xy {x1} 12) (xy {x0} 12))) (filled_polygon (layer "F.Cu") (pts (xy {x0} 8) (xy {x1} 8) (xy {x1} 12) (xy {x0} 12))))"#);+ let text = format!(r#"(footprint "y" (layer "F.Cu") (at 20 10) (property "Reference" "R2") (pad "1" smd rect (at 0 0) (size 1 1) (layers "F.Cu") (net "A"))){}{}"#, z(9.0, 15.0), z(14.0, 21.0));+ let b = board(&text);+ let groups = conn::pad_groups(&b, &[]);+ assert_eq!(groups[&b.net_ids["A"]].len(), 1);+ }++ #[test]+ fn hole_rules_fire() {+ let hole = r#"(footprint "h" (layer "F.Cu") (at 20 20) (property "Reference" "H1") (pad "" np_thru_hole circle (at 0 0) (size 1 1) (drill 1) (layers "*.Cu" "*.Mask")))"#;+ // Track edge 0.2 mm from a 0.5 mm radius hole, against 0.25 mm hole clearance.+ let text = format!(r#"{hole}(segment (start 18 20.8) (end 22 20.8) (width 0.2) (layer "F.Cu") (net "C"))"#);+ let (f, _) = check(&board(&text), &rules(0.0));+ assert_eq!(count(&f, "hole-clearance"), 1);+ // Two vias with 0.2 mm between their holes, against 0.25 mm hole to hole, same net.+ let vias = r#"(via (at 5 5) (size 0.6) (drill 0.3) (layers "F.Cu" "B.Cu") (net "C")) (via (at 5.5 5) (size 0.6) (drill 0.3) (layers "F.Cu" "B.Cu") (net "C"))"#;+ let (f, _) = check(&board(vias), &rules(0.0));+ assert!(count(&f, "hole-to-hole") >= 1);+ // A via 0.1 mm from the edge against 0.3 mm.+ let edge = r#"(via (at 0.4 5) (size 0.6) (drill 0.3) (layers "F.Cu" "B.Cu") (net "C"))"#;+ let (f, _) = check(&board(edge), &rules(0.0));+ assert_eq!(count(&f, "edge"), 1);+ }++ #[test]+ fn entry_angle_reads_the_edge_crossed() {+ let pad = |angle: f64| Pad { fp: 0, number: String::new(), net: 1, center: (0, 0), shape: Shape::Poly { pts: vec![V(-NM, -NM / 2.0), V(NM, -NM / 2.0), V(NM, NM / 2.0), V(-NM, NM / 2.0)], r: 0.0 }, layers: vec![0], drill: None, npth: false, size: (2.0 * NM, NM), angle, mask_margin: 0.0, mask: (true, false), paste: true, pintype: String::new(), pinfunction: String::new(), zone_connect: None, local_clearance: 0.0 };+ // Straight down onto a 2 x 1 pad: square to its long edge.+ let a = entry_angle(&pad(0.0), V(0.0, -3.0 * NM), V(0.0, 0.0)).unwrap();+ assert!((a - 90.0).abs() < 0.01);+ // In at 45 degrees.+ let a = entry_angle(&pad(0.0), V(3.0 * NM, -3.0 * NM), V(0.0, 0.0)).unwrap();+ assert!((a - 45.0).abs() < 0.01);+ }++ #[test]+ fn a_kelvin_branch_fails_and_a_clean_tap_does_not() {+ let mut r = rules(0.0);+ r.kelvin = vec!["K".to_string()];+ let clean = r#"(segment (start 2 5) (end 6 5) (width 0.25) (layer "F.Cu") (net "K"))"#;+ let (f, _) = check(&board(clean), &r);+ assert_eq!(count(&f, "kelvin"), 0);+ let branch = r#"(segment (start 2 5) (end 6 5) (width 0.25) (layer "F.Cu") (net "K"))+ (segment (start 6 5) (end 9 5) (width 0.25) (layer "F.Cu") (net "K"))+ (segment (start 6 5) (end 8 7) (width 0.25) (layer "F.Cu") (net "K"))"#;+ let (f, _) = check(&board(branch), &r);+ assert_eq!(count(&f, "kelvin"), 1);+ }++ #[test]+ fn the_chart_gives_his_example_and_margin_is_small_boards_only() {+ let w = crate::current::conservative_mm(3.0, 20.0, 35.0);+ assert!((w - 2.335).abs() < 0.01, "{w}");+ let mut r = Rules::default();+ r.small_board = false;+ assert_eq!(r.rail_width(3.0, true), mm(2.34));+ assert_eq!(r.rail_width(0.2, true), mm(0.06));+ r.small_board = true;+ assert_eq!(r.rail_width(3.0, true), mm(2.92));+ assert_eq!(r.rail_width(0.2, true), mm(0.40));+ assert_eq!(r.rail_width(0.2, false), mm(0.06));+ }++ #[test]+ fn pad_rule_survives_the_file_and_the_standing_minimum_holds() {+ let rule = crate::rules::PadRule { net: "+PS4_SENS".into(), reference: "U3".into(), pad: "11".into(), min: mm(0.225) };+ assert_eq!(crate::rules::PadRule::parse(&rule.condition(), rule.min), Some(rule.clone()));+ assert_eq!(crate::rules::PadRule::parse("A.NetClass == 'USB'", 1), None);+ let dir = std::env::temp_dir().join(format!("adom-route-padrule-{}", std::process::id()));+ std::fs::create_dir_all(&dir).unwrap();+ let (a, b) = (dir.join("in.kicad_pcb"), dir.join("out.kicad_pcb"));+ let path = project::write_dru(a.to_str().unwrap(), b.to_str().unwrap(), std::slice::from_ref(&rule)).unwrap().unwrap();+ let mut r = Rules::default();+ project::apply_files(&dir.join("missing.kicad_pro"), std::path::Path::new(&path), &mut r);+ assert_eq!(r.pad_rules, vec![rule]);+ let _ = std::fs::remove_file(&path);+ }++ #[test]+ fn dru_only_raises_limits_and_says_what_it_skips() {+ let dir = std::env::temp_dir().join(format!("adom-route-dru-{}", std::process::id()));+ std::fs::create_dir_all(&dir).unwrap();+ let dru = dir.join("t.kicad_dru");+ std::fs::write(&dru, "(version 1)\n# comment\n(rule a (constraint clearance (min 0.09mm)))\n(rule b (constraint track_width (min 0.127mm)))\n(rule c (condition \"A.Type == 'Via'\") (constraint hole_size (min 0.3mm)))\n").unwrap();+ let mut r = Rules::default();+ let notes = project::apply_files(&dir.join("missing.kicad_pro"), &dru, &mut r);+ // No project: KiCad's defaults (0.2 mm track and clearance) stand, and the rule file's lower+ // minimums cannot loosen them.+ assert_eq!(r.min.track, mm(0.2));+ assert_eq!(r.clearance_for(1, 2), mm(0.2));+ assert!(notes.iter().any(|n| n.contains("rule \"c\"") && n.contains("not honoured")));+ let _ = std::fs::remove_file(&dru);+ }+}
src/classify.rsadded+752@@ -0,0 +1,752 @@+//! Sort nets into the designer's routing hierarchy (4.2) and work out each net's topology: the order its+//! pads are joined in. Decoupling is cap first, crystals are daisy chains, data prefers a chain.++use crate::board::*;+use crate::geom::*;+use crate::rules::Rules;+use std::collections::{BTreeMap, HashMap, HashSet};++#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]+pub enum Level {+ Kelvin = 0,+ Pair = 1,+ Crystal = 2,+ Data = 3,+ Power = 4,+ PlaneFanout = 5,+ Other = 6,+}++impl Level {+ pub fn name(self) -> &'static str {+ match self {+ Level::Kelvin => "kelvin",+ Level::Pair => "differential pair",+ Level::Crystal => "crystal",+ Level::Data => "data",+ Level::Power => "power",+ Level::PlaneFanout => "plane via",+ Level::Other => "non-priority",+ }+ }+}++pub struct NetInfo {+ pub net: NetId,+ pub level: Level,+ pub pads: Vec<usize>,+ /// Why this level, from the pins and parts on the net. Printed by `plan`, because a wrong class+ /// means wrong widths and the wrong turn in the hierarchy, and the designer has to be able to see it.+ pub why: String,+ /// A switch node (4.2 Switching loops): keep it compact, never pour it, MCU outside the loop.+ pub switching: bool,+ /// Kelvin taps on this net, each a chain of pads ending on the shunt (4.2): dedicated copper,+ /// routed apart from the rest of the net, which is planned without the amplifier's pins.+ pub taps: Vec<Vec<usize>>,+}++pub fn ref_prefix(r: &str) -> &str {+ let end = r.find(|c: char| c.is_ascii_digit()).unwrap_or(r.len());+ &r[..end]+}++/// A power rail by name: +3v3, +5V, VBUS, VCC, VDD... (never by width, 4.2).+pub fn looks_power(name: &str) -> bool {+ let n = name.trim_start_matches('/').to_ascii_uppercase();+ n.starts_with('+') || n.starts_with("VCC") || n.starts_with("VDD") || n.starts_with("VBUS") || n.starts_with("VIN") || n.starts_with("VSYS") || n.starts_with("PWR") || n == "VBAT"+}++pub fn looks_ground(name: &str) -> bool {+ let n = name.trim_start_matches('/').to_ascii_uppercase();+ n == "GND" || n.starts_with("GND") || n == "VSS" || n == "AGND" || n == "DGND" || n == "PGND"+}++/// Non-switching signals that can via often without a noticeable effect (4.2 item 4).+pub fn looks_low_priority(name: &str) -> bool {+ let n = name.trim_start_matches('/').to_ascii_uppercase();+ n.contains("LED") || n.contains("RST") || n.contains("RESET") || n == "RUN" || n.contains("BOOT") || n.contains("SW_") || n.contains("BTN")+}++/// The 90-ohm figures in PCB_Troubleshooting ("A USB pair fails"), measured for one JLC 4-layer stack.+/// Only a last resort when neither the spec nor the project sets a pair: every use is reported.+const TROUBLESHOOTING_PAIR: (f64, f64) = (0.2817, 0.15);++/// Pairs by name when the spec does not list them: ..D+/..D-, .._P/.._N, ..DP/..DN, then the width+/// and gap of every pair (4.2: specs are predetermined, not chosen here): the spec's, else the net+/// class's (`Rules::bind` must have run). Returns a note for each figure that came from neither.+pub fn find_pairs(board: &Board, rules: &mut Rules) -> Vec<String> {+ let names: HashSet<&str> = board.nets.iter().map(|s| s.as_str()).collect();+ let listed: HashSet<String> = rules.pairs.iter().flat_map(|p| [p.p.clone(), p.n.clone()]).collect();+ let mut found = Vec::new();+ for name in board.nets.iter() {+ for (a, b) in [("D+", "D-"), ("_P", "_N"), ("_DP", "_DN"), ("+", "-")] {+ if let Some(stem) = name.strip_suffix(a) {+ let other = format!("{stem}{b}");+ if names.contains(other.as_str()) && !listed.contains(name) && !(a == "+" && stem.is_empty()) {+ if a == "+" && !stem.to_ascii_uppercase().ends_with("USB") && !stem.to_ascii_uppercase().ends_with('D') {+ continue;+ }+ found.push((name.clone(), other));+ break;+ }+ }+ }+ }+ found.sort();+ found.dedup();+ for (p, n) in found {+ rules.pairs.push(crate::rules::Pair { p, n, width: 0, gap: 0 });+ }+ let mut notes = Vec::new();+ let mut pairs = std::mem::take(&mut rules.pairs);+ for p in pairs.iter_mut() {+ let class = board.net_ids.get(&p.p).and_then(|&id| rules.pair_for(id));+ if p.width == 0 {+ p.width = class.map(|c| c.0).unwrap_or(0);+ }+ if p.gap == 0 {+ p.gap = class.map(|c| c.1).unwrap_or(0);+ }+ if p.width == 0 || p.gap == 0 {+ if p.width == 0 {+ p.width = mm(TROUBLESHOOTING_PAIR.0);+ }+ if p.gap == 0 {+ p.gap = mm(TROUBLESHOOTING_PAIR.1);+ }+ notes.push(format!("DEFAULT: pair {}/{} has no width or gap in the spec or a net class, so it takes {:.4}/{:.3} mm from PCB_Troubleshooting's JLC 4-layer table. Confirm it with the fab's calculator for this stack (2.2)", p.p, p.n, to_mm(p.width), to_mm(p.gap)));+ }+ }+ rules.pairs = pairs;+ notes+}++pub fn is_crystal(board: &Board, fp: usize) -> bool {+ let f = &board.footprints[fp];+ let r = ref_prefix(&f.reference);+ (r == "Y" || r == "X" || f.lib.to_ascii_lowercase().contains("crystal")) && f.pads.len() >= 2 && f.pads.len() <= 6+}++/// An LED by its part, not its net name: the reference says LED, or the library part does.+pub fn is_led(board: &Board, fp: usize) -> bool {+ let f = &board.footprints[fp];+ let r = ref_prefix(&f.reference);+ r == "LED" || (r == "D" && f.lib.to_ascii_lowercase().contains("led"))+}++/// A net that drives an LED, directly or through a series resistor: non-switching, may via often+/// (4.2 item 4). A diode that isn't an LED doesn't count: an ESD array on a terminal line is on the+/// line for a reason, and benchmark C's TI/TO lines were going last because of one.+pub fn drives_led(board: &Board, pads: &[usize]) -> bool {+ pads.iter().any(|&p| {+ let fp = board.pads[p].fp;+ if is_led(board, fp) {+ return true;+ }+ let f = &board.footprints[fp];+ if ref_prefix(&f.reference) != "R" || f.pads.len() != 2 {+ return false;+ }+ let other = f.pads.iter().copied().find(|&o| o != p);+ let Some(other) = other else { return false };+ let net = board.pads[other].net;+ // A resistor to GND or a rail also reaches some LED: that's a pull or a set resistor.+ let name = board.net_name(net);+ net != NO_NET && !looks_ground(name) && (!looks_power(name) || name.to_ascii_uppercase().contains("LED")) && board.pads.iter().any(|q| q.net == net && is_led(board, q.fp))+ })+}++/// A part value as ohms: R068, 0R1, 10m, 100mR, 4k7, 22, 0.05. None when it isn't a plain resistance.+pub fn ohms(value: &str) -> Option<f64> {+ let v = value.trim().to_ascii_lowercase().replace("ohms", "").replace("ohm", "").replace('\u{3a9}', "");+ let v = v.split_whitespace().next()?.to_string();+ for (mark, scale) in [('m', 1e-3), ('k', 1e3), ('r', 1.0)] {+ if let Some(i) = v.find(mark) {+ let (head, tail) = (&v[..i], &v[i + 1..]);+ // "100mr" is 100 milliohms: the trailing r only repeats the unit.+ let tail = tail.strip_suffix('r').unwrap_or(tail);+ let head = if head.is_empty() { "0" } else { head };+ let text = if tail.is_empty() { head.to_string() } else { format!("{head}.{tail}") };+ return text.parse::<f64>().ok().map(|x| x * scale);+ }+ }+ v.parse().ok()+}++/// A current-sense shunt: a low-value two-pad resistor. His standard parts read R068 and the like.+fn is_shunt(board: &Board, fp: usize) -> bool {+ let f = &board.footprints[fp];+ ref_prefix(&f.reference).starts_with('R') && f.pads.iter().filter(|&&p| !board.pads[p].layers.is_empty()).count() == 2 && ohms(&f.value).map(|x| x > 0.0 && x < 1.0).unwrap_or(false)+}++/// Each shunt with an amplifier's sense pins across it: (footprint of the shunt, then for each side+/// its pad and the amplifier pins on that side). The nets are rails, so the name says nothing: what+/// makes these Kelvin taps is the amplifier's input pins on both sides of a milliohm resistor.+pub fn kelvin_taps(board: &Board) -> Vec<Vec<usize>> {+ let mut out = Vec::new();+ for (fi, f) in board.footprints.iter().enumerate() {+ if !is_shunt(board, fi) || f.dnp {+ continue;+ }+ let pads: Vec<usize> = f.pads.iter().copied().filter(|&p| !board.pads[p].layers.is_empty()).collect();+ let (a, b) = (pads[0], pads[1]);+ let (na, nb) = (board.pads[a].net, board.pads[b].net);+ if na == NO_NET || nb == NO_NET || na == nb {+ continue;+ }+ for (gi, g) in board.footprints.iter().enumerate() {+ if gi == fi || g.pads.len() < 5 || !matches!(ref_prefix(&g.reference), "U" | "IC") {+ continue;+ }+ // A power pin on the rail is just the part being fed; sense pins are inputs.+ let sense = |net: NetId| -> Vec<usize> { g.pads.iter().copied().filter(|&p| board.pads[p].net == net && !board.pads[p].layers.is_empty() && matches!(board.pads[p].pintype.as_str(), "input" | "passive")).collect() };+ let (sa, sb) = (sense(na), sense(nb));+ if sa.is_empty() || sb.is_empty() {+ continue;+ }+ for (shunt_pad, mut pins) in [(a, sa), (b, sb)] {+ pins.push(shunt_pad);+ out.push(pins);+ }+ }+ }+ out+}++/// Is this a switch node? Topology first: an inductor meeting a diode, a transistor or an IC pin at+/// one net (4.2 Switching loops). A converter module hides its inductor, so a net that is only pins of+/// one IC that also has power pins, and is named for a switch node, counts too (benchmark C's U5 SW).+fn is_switch_node(board: &Board, name: &str, pads: &[usize]) -> Option<String> {+ let prefix = |p: usize| ref_prefix(&board.footprints[board.pads[p].fp].reference).to_string();+ let inductor = pads.iter().find(|&&p| prefix(p) == "L");+ let active = pads.iter().find(|&&p| match prefix(p).as_str() {+ "D" => !is_led(board, board.pads[p].fp),+ "Q" | "U" | "IC" => board.pads[p].pintype != "input",+ _ => false,+ });+ if let (Some(&l), Some(&x)) = (inductor, active) {+ return Some(format!("inductor {} meets {}", board.pad_name(l), board.pad_name(x)));+ }+ let tail = name.rsplit('/').next().unwrap_or(name).to_ascii_uppercase();+ let named = matches!(tail.as_str(), "SW" | "LX" | "PHASE") || tail.starts_with("SW_NODE");+ let first = board.pads[*pads.first()?].fp;+ let one_part = pads.iter().all(|&p| board.pads[p].fp == first);+ let powered = board.footprints[first].pads.iter().any(|&p| matches!(board.pads[p].pintype.as_str(), "power_in" | "power_out"));+ if named && one_part && powered {+ return Some(format!("only pins of {}, a part with power pins, named {tail}", board.footprints[first].reference));+ }+ None+}++/// Which nets carry power, and why, from the pins and parts rather than from names or widths (4.2:+/// never judge current from a net name or a trace width alone). A name counts only where nothing on+/// the net contradicts it: /VBUS_G is named like a rail and is two FET gates.+fn power_nets(board: &Board, rules: &Rules, by_net: &BTreeMap<NetId, Vec<usize>>) -> HashMap<NetId, String> {+ let mut power: HashMap<NetId, String> = HashMap::new();+ let ground = |n: NetId| looks_ground(board.net_name(n));+ let prefix = |p: usize| ref_prefix(&board.footprints[board.pads[p].fp].reference).to_string();+ let is_gate = |p: usize| prefix(p) == "Q" && board.pads[p].pintype == "input";+ let is_power_pin = |p: usize| matches!(board.pads[p].pintype.as_str(), "power_in" | "power_out");+ for (&net, pads) in by_net {+ let name = board.net_name(net);+ if ground(net) {+ continue;+ }+ if rules.wide.contains_key(name) {+ power.insert(net, "listed in the spec's wideNets".to_string());+ } else if let Some(&p) = pads.iter().find(|&&p| is_power_pin(p)) {+ power.insert(net, format!("power pin {} ({})", board.pad_name(p), board.pads[p].pintype));+ } else if looks_power(name) && !pads.iter().any(|&p| is_gate(p)) && !drives_led(board, pads) {+ power.insert(net, "named as a rail, and no gate or LED on it says otherwise".to_string());+ } else {+ // An output pin with only bypass capacitors on it is a regulator's own supply, not a+ // signal: nothing receives it (benchmark C's U5 VCC).+ let outputs = pads.iter().filter(|&&p| board.pads[p].pintype == "output").count();+ let caps_to_ground = pads.iter().all(|&p| {+ board.pads[p].pintype == "output" || (prefix(p) == "C" && board.footprints[board.pads[p].fp].pads.iter().any(|&o| o != p && ground(board.pads[o].net)))+ });+ if outputs > 0 && pads.len() > outputs && caps_to_ground {+ power.insert(net, "an output pin with only bypass capacitors on it".to_string());+ }+ }+ }+ // Power carries through what is in series with it: fuses, ferrites, inductors, shunts, and the+ // channel of a power FET (several pins tied on each side, the gate typed input).+ let mut links: Vec<(NetId, NetId, String)> = Vec::new();+ for (fi, f) in board.footprints.iter().enumerate() {+ let copper: Vec<usize> = f.pads.iter().copied().filter(|&p| !board.pads[p].layers.is_empty() && board.pads[p].net != NO_NET).collect();+ let r = ref_prefix(&f.reference);+ if copper.len() == 2 && (matches!(r, "F" | "FB" | "L") || is_shunt(board, fi)) {+ links.push((board.pads[copper[0]].net, board.pads[copper[1]].net, f.reference.clone()));+ }+ if r == "Q" {+ let mut sides: BTreeMap<NetId, usize> = BTreeMap::new();+ for &p in &copper {+ if !is_gate(p) {+ *sides.entry(board.pads[p].net).or_insert(0) += 1;+ }+ }+ let tied: Vec<NetId> = sides.iter().filter(|(_, &c)| c >= 2).map(|(&n, _)| n).collect();+ for w in tied.iter().enumerate().flat_map(|(i, a)| tied[i + 1..].iter().map(move |b| (*a, *b))) {+ links.push((w.0, w.1, format!("{} channel", f.reference)));+ }+ }+ }+ loop {+ let mut changed = false;+ for (a, b, via) in &links {+ for (from, to) in [(*a, *b), (*b, *a)] {+ if power.contains_key(&from) && !power.contains_key(&to) && !ground(to) && by_net.contains_key(&to) {+ power.insert(to, format!("in series with {} through {via}", board.net_name(from)));+ changed = true;+ }+ }+ }+ if !changed {+ return power;+ }+ }+}++/// Is this footprint fine pitch? 4.2 only says "fine-pitch MCU", so this asks what that means for+/// routing: between two neighbouring pads of different nets, does a straight lane at the neck fit+/// with more than the minimum spacing either side? If it doesn't, spacing inside the escape has to+/// tighten to the minimum, which is what 4.2 allows a fine-pitch escape to do. Two-pad parts have no+/// pin row to escape from.+pub fn is_fine_pitch(board: &Board, rules: &Rules, fp: usize) -> bool {+ let f = &board.footprints[fp];+ let copper: Vec<usize> = f.pads.iter().copied().filter(|&p| !board.pads[p].layers.is_empty()).collect();+ if copper.len() < 3 {+ return false;+ }+ let lane = (rules.neck + 2 * rules.clearance_for(NO_NET, NO_NET).max(rules.floor)) as f64;+ for (i, &a) in copper.iter().enumerate() {+ for &b in &copper[i + 1..] {+ let (pa, pb) = (&board.pads[a], &board.pads[b]);+ if pa.net != pb.net && pa.shape.dist_shape(&pb.shape) < lane {+ return true;+ }+ }+ }+ false+}++pub fn is_passive2(board: &Board, fp: usize) -> bool {+ let f = &board.footprints[fp];+ f.pads.iter().filter(|&&p| !board.pads[p].layers.is_empty()).count() == 2 && matches!(ref_prefix(&f.reference), "C" | "R" | "L" | "FB" | "D")+}++pub fn classify(board: &Board, rules: &Rules) -> Vec<NetInfo> {+ let planes = board.plane_nets();+ let plane_names: HashSet<&str> = rules.plane_nets.iter().map(|s| s.as_str()).collect();+ let pair_nets: HashSet<&str> = rules.pairs.iter().flat_map(|p| [p.p.as_str(), p.n.as_str()]).collect();+ let kelvin: HashSet<&str> = rules.kelvin.iter().map(|s| s.as_str()).collect();+ let skip: HashSet<&str> = rules.skip_refs.iter().map(|s| s.as_str()).collect();+ let mut by_net: BTreeMap<NetId, Vec<usize>> = BTreeMap::new();+ for (i, p) in board.pads.iter().enumerate() {+ if p.net == NO_NET || p.layers.is_empty() {+ continue;+ }+ let f = &board.footprints[p.fp];+ // A part marked do-not-populate is still copper on the board: an option resistor has to be+ // wired or it is no option, and KiCad counts its pads as open (on a current-monitor board, three+ // unpopulated address resistors left five joins open while the router reported all made).+ // Only the spec's skipRefs leave a part out.+ if skip.contains(f.reference.as_str()) {+ continue;+ }+ by_net.entry(p.net).or_default().push(i);+ }+ let power = power_nets(board, rules, &by_net);+ let mut taps: HashMap<NetId, Vec<Vec<usize>>> = HashMap::new();+ for t in kelvin_taps(board) {+ taps.entry(board.pads[t[0]].net).or_default().push(t);+ }+ let mut out = Vec::new();+ for (net, pads) in by_net {+ let name = board.net_name(net);+ // KiCad names a net "unconnected-" when no schematic wire reaches it, but two pads on one+ // such net are one pin brought out twice, and its DRC wants them joined (benchmark C's U2).+ if name.is_empty() || (name.starts_with("unconnected-") && pads.len() < 2) {+ continue;+ }+ let switch = is_switch_node(board, name, &pads);+ let (level, why) = if kelvin.contains(name) {+ (Level::Kelvin, "listed as Kelvin in the spec".to_string())+ } else if pair_nets.contains(name) {+ (Level::Pair, "a differential pair".to_string())+ } else if planes.contains_key(&net) || plane_names.contains(name) || looks_ground(name) && !planes.is_empty() {+ (Level::PlaneFanout, "has a plane".to_string())+ } else if let Some(s) = &switch {+ (Level::Power, format!("switch node: {s}"))+ } else if let Some(p) = power.get(&net) {+ (Level::Power, p.clone())+ } else if drives_led(board, &pads) {+ (Level::Other, "drives an LED".to_string())+ } else if looks_low_priority(name) {+ (Level::Other, "reset, boot or button line by name".to_string())+ } else if pads.iter().any(|&p| is_crystal(board, board.pads[p].fp)) {+ (Level::Crystal, "on a crystal".to_string())+ } else {+ (Level::Data, "signal pins only".to_string())+ };+ let taps = taps.remove(&net).unwrap_or_default();+ out.push(NetInfo { net, level, pads, why, switching: switch.is_some(), taps });+ }+ out+}++/// A connection the router must make: pad to pad, inside one net.+#[derive(Clone, Debug)]+pub struct Task {+ pub net: NetId,+ pub level: Level,+ pub a: usize,+ pub b: usize,+ /// Main rail run rather than a stub to a pin.+ pub main: bool,+ /// Join this pad to its net's copper fill (index into World::zones) instead of another pad.+ pub zone: Option<usize>,+}++/// `Task::zone` value meaning: join pad `a` to the net's rail copper, or, when `a != b`, to pad `b`+/// and whatever copper it already has. The targets are worked out from the copper at route time.+pub const RAIL_TAP: usize = usize::MAX;++fn dist(board: &Board, a: usize, b: usize) -> f64 {+ hypot(sub(board.pads[a].center.into(), board.pads[b].center.into()))+}++/// Daisy chain through `pads`, starting from the end that makes the chain shortest (4.2).+fn chain(board: &Board, pads: &[usize]) -> Vec<usize> {+ if pads.len() <= 2 {+ return pads.to_vec();+ }+ let mut best: Option<(f64, Vec<usize>)> = None;+ for &start in pads {+ let mut left: Vec<usize> = pads.iter().copied().filter(|&p| p != start).collect();+ let mut seq = vec![start];+ let mut total = 0.0;+ while !left.is_empty() {+ let cur = *seq.last().unwrap();+ let (i, d) = left.iter().enumerate().map(|(i, &p)| (i, dist(board, cur, p))).min_by(|a, b| a.1.total_cmp(&b.1)).unwrap();+ total += d;+ seq.push(left.swap_remove(i));+ }+ if best.as_ref().map(|b| total < b.0).unwrap_or(true) {+ best = Some((total, seq));+ }+ }+ best.unwrap().1+}++/// Minimum spanning tree edges between groups, nearest pads first (Prim).+fn tree(board: &Board, groups: &[Vec<usize>]) -> Vec<(usize, usize)> {+ let mut joined = vec![0usize];+ let mut out = Vec::new();+ while joined.len() < groups.len() {+ let mut best: Option<(f64, usize, usize, usize)> = None;+ for &g in &joined {+ for h in 0..groups.len() {+ if joined.contains(&h) {+ continue;+ }+ for &a in &groups[g] {+ for &b in &groups[h] {+ let d = dist(board, a, b);+ if best.map(|x| d < x.0).unwrap_or(true) {+ best = Some((d, a, b, h));+ }+ }+ }+ }+ }+ let (_, a, b, h) = best.unwrap();+ out.push((a, b));+ joined.push(h);+ }+ out+}++/// Plan the pad-to-pad connections for one signal or power net, given which pads the board's+/// copper already joins.+pub fn plan_net(board: &Board, info: &NetInfo, groups: &[Vec<usize>]) -> Vec<Task> {+ let mk = |a, b, main| Task { net: info.net, level: info.level, a, b, main, zone: None };+ let live: Vec<Vec<usize>> = groups+ .iter()+ .map(|g| g.iter().copied().filter(|p| info.pads.contains(p)).collect::<Vec<_>>())+ .filter(|g: &Vec<usize>| !g.is_empty())+ .collect();+ if live.len() < 2 {+ return Vec::new();+ }+ let singles = live.iter().all(|g| g.len() == 1);+ match info.level {+ Level::Crystal if singles => {+ // IC pin -> load-cap pad -> crystal pad (4.2 crystal routing).+ let pads: Vec<usize> = live.iter().map(|g| g[0]).collect();+ let mut xtal: Vec<usize> = pads.iter().copied().filter(|&p| is_crystal(board, board.pads[p].fp)).collect();+ let mut caps: Vec<usize> = pads.iter().copied().filter(|&p| ref_prefix(&board.footprints[board.pads[p].fp].reference) == "C").collect();+ let mut rest: Vec<usize> = pads.iter().copied().filter(|p| !xtal.contains(p) && !caps.contains(p)).collect();+ let far = xtal.first().copied();+ if let Some(f) = far {+ caps.sort_by(|a, b| dist(board, *b, f).total_cmp(&dist(board, *a, f)));+ rest.sort_by(|a, b| dist(board, *b, f).total_cmp(&dist(board, *a, f)));+ }+ let mut seq = Vec::new();+ seq.append(&mut rest);+ seq.append(&mut caps);+ seq.append(&mut xtal);+ seq.windows(2).map(|w| mk(w[0], w[1], false)).collect()+ }+ Level::Power => plan_rail(board, info, &live, &[], &|_, _| false, f64::INFINITY),+ _ if singles => {+ let pads: Vec<usize> = live.iter().map(|g| g[0]).collect();+ // Each new pad joins what is already laid, so it starts as one pad and a route has no+ // copper of its own behind it to double back along. `targets_of` lets it land on any of+ // that copper, weighed against reaching the previous pad (4.2: a daisy chain preferred).+ chain(board, &pads).windows(2).map(|w| mk(w[1], w[0], false)).collect()+ }+ _ => tree(board, &live).into_iter().map(|(a, b)| mk(a, b, false)).collect(),+ }+}++/// Plan one rail (4.2 power): supply pins join their own fill or their cap first (never pin ->+/// junction -> cap), then each resulting cluster joins the rail on the bus layer, tapping onto+/// whatever rail copper is nearest when its turn comes. `zones`: this net's fills, as (index in World::zones,+/// layer, outline). `stub_clear`: can this pad reach this fill straight, on its own layer? A fill+/// behind another net's copper is reached from the bus instead (4.7: DVDD pins go out to their caps).+/// `cap_reach`: how far a pin's stub to its cap may run. Past where a stub on the pad layer costs more+/// than going down to the bus and back up, the pin and the cap each join the rail on their own.+pub fn plan_rail(board: &Board, info: &NetInfo, live: &[Vec<usize>], zones: &[(usize, usize, &Vec<V>)], stub_clear: &dyn Fn(usize, usize) -> bool, cap_reach: f64) -> Vec<Task> {+ let mut tasks = Vec::new();+ let is_ic_pin = |p: usize| {+ let f = &board.footprints[board.pads[p].fp];+ f.pads.len() > 4 && matches!(ref_prefix(&f.reference), "U" | "IC" | "Q")+ };+ let is_cap = |p: usize| is_passive2(board, board.pads[p].fp) && ref_prefix(&board.footprints[board.pads[p].fp].reference) == "C";+ let (ng, nz) = (live.len(), zones.len());+ let mut uf = crate::conn::Uf::new(ng + nz);+ let mut group_of: HashMap<usize, usize> = HashMap::new();+ for (i, g) in live.iter().enumerate() {+ for &p in g {+ group_of.insert(p, i);+ }+ }+ let edge_dist = |p: usize, poly: &Vec<V>| -> f64 {+ let n = poly.len();+ if in_poly(board.pads[p].center.into(), poly) {+ return 0.0;+ }+ (0..n).map(|i| board.pads[p].shape.dist_seg(poly[i], poly[(i + 1) % n])).fold(f64::INFINITY, f64::min)+ };+ for &p in &info.pads {+ let Some(&g) = group_of.get(&p) else { continue };+ for (zi, (_, layer, poly)) in zones.iter().enumerate() {+ if board.pads[p].layers.contains(layer) && edge_dist(p, poly) <= 1.0 {+ uf.union(g as u32, (ng + zi) as u32);+ }+ }+ }+ // A pin next to its net's fill stubs into it (4.7's method, where the designer drew the fill).+ for &p in &info.pads {+ let Some(&g) = group_of.get(&p) else { continue };+ if !is_ic_pin(p) {+ continue;+ }+ for (zi, (z, layer, poly)) in zones.iter().enumerate() {+ let same = uf.find(g as u32) == uf.find((ng + zi) as u32);+ if !same && board.pads[p].layers.contains(layer) && edge_dist(p, poly) <= 1.0 * NM && stub_clear(p, *z) {+ tasks.push(Task { net: info.net, level: info.level, a: p, b: p, main: false, zone: Some(*z) });+ uf.union(g as u32, (ng + zi) as u32);+ break;+ }+ }+ }+ let caps: Vec<usize> = info.pads.iter().copied().filter(|&p| is_cap(p)).collect();+ let mut pins: Vec<usize> = info.pads.iter().copied().filter(|&p| is_ic_pin(p)).collect();+ pins.sort_by(|&a, &b| {+ let da = caps.iter().map(|&c| dist(board, a, c)).fold(f64::INFINITY, f64::min);+ let db = caps.iter().map(|&c| dist(board, b, c)).fold(f64::INFINITY, f64::min);+ da.total_cmp(&db)+ });+ for pin in pins {+ let Some(&cap) = caps.iter().min_by(|&&a, &&b| dist(board, pin, a).total_cmp(&dist(board, pin, b))) else { continue };+ if dist(board, pin, cap) > cap_reach {+ continue;+ }+ let (Some(&ga), Some(&gb)) = (group_of.get(&pin), group_of.get(&cap)) else { continue };+ if uf.find(ga as u32) != uf.find(gb as u32) {+ uf.union(ga as u32, gb as u32);+ // Rail-routed, so the search itself weighs a long stub against the bus. `main: false`+ // holds it to the cap's own pad: cap first, never a junction on the way (4.2).+ tasks.push(Task { net: info.net, level: info.level, a: pin, b: cap, main: false, zone: Some(RAIL_TAP) });+ }+ }+ // Clusters, and the pads each one is fed from: a cap if it has one (cap first), else a passive+ // or connector pad, else the pin.+ let mut clusters: BTreeMap<u32, Vec<usize>> = BTreeMap::new();+ for (i, g) in live.iter().enumerate() {+ clusters.entry(uf.find(i as u32)).or_default().extend(g.iter().copied());+ }+ let feeds: Vec<Vec<usize>> = clusters+ .values()+ .map(|pads| {+ let c: Vec<usize> = pads.iter().copied().filter(|&p| is_cap(p)).collect();+ if !c.is_empty() {+ return c;+ }+ let o: Vec<usize> = pads.iter().copied().filter(|&p| !is_ic_pin(p)).collect();+ if !o.is_empty() { o } else { pads.clone() }+ })+ .collect();+ // With a fill on the net, the fill is the hub and every cluster taps toward it at the main width.+ // Without one, the two clusters farthest apart make the trunk and the rest tap onto it. Nearest+ // to what is already joined goes next either way, so each tap finds rail close by.+ let mut joined: Vec<usize> = Vec::new();+ let mut left: Vec<usize> = (0..feeds.len()).collect();+ if zones.is_empty() && feeds.len() >= 2 {+ let mut far: Option<(f64, usize, usize, usize, usize)> = None;+ for i in 0..feeds.len() {+ for j in i + 1..feeds.len() {+ for &a in &feeds[i] {+ for &b in &feeds[j] {+ let d = dist(board, a, b);+ if far.map(|x| d > x.0).unwrap_or(true) {+ far = Some((d, i, j, a, b));+ }+ }+ }+ }+ }+ let (_, i, j, a, b) = far.unwrap();+ tasks.push(Task { net: info.net, level: info.level, a, b, main: true, zone: Some(RAIL_TAP) });+ joined = vec![a, b];+ left.retain(|&k| k != i && k != j);+ }+ let hub = !zones.is_empty();+ while !left.is_empty() {+ let near = |p: usize| -> f64 {+ let to_pads = joined.iter().map(|&q| dist(board, p, q)).fold(f64::INFINITY, f64::min);+ zones.iter().map(|(_, _, poly)| edge_dist(p, poly)).fold(to_pads, f64::min)+ };+ let (pos, pad) = left+ .iter()+ .enumerate()+ .flat_map(|(pos, &k)| feeds[k].iter().map(move |&p| (pos, p)))+ .min_by(|x, y| near(x.1).total_cmp(&near(y.1)))+ .unwrap();+ left.swap_remove(pos);+ joined.push(pad);+ tasks.push(Task { net: info.net, level: info.level, a: pad, b: pad, main: hub, zone: Some(RAIL_TAP) });+ }+ // A fill with no pad of its own still has to be fed: an island is never acceptable (4.3). These+ // go last: by then the pads are one piece, so the tap's only target is the fill itself.+ let reps: Vec<usize> = feeds.iter().flatten().copied().collect();+ for (zi, (_, _, poly)) in zones.iter().enumerate() {+ let root = uf.find((ng + zi) as u32);+ let has_pad = (0..ng).any(|g| uf.find(g as u32) == root);+ if !has_pad && !poly.is_empty() {+ let c = V(poly.iter().map(|p| p.0).sum::<f64>() / poly.len() as f64, poly.iter().map(|p| p.1).sum::<f64>() / poly.len() as f64);+ if let Some(&near) = reps.iter().min_by(|&&x, &&y| hypot(sub(board.pads[x].center.into(), c)).total_cmp(&hypot(sub(board.pads[y].center.into(), c)))) {+ tasks.push(Task { net: info.net, level: info.level, a: near, b: near, main: false, zone: Some(RAIL_TAP) });+ }+ }+ }+ tasks+}++#[cfg(test)]+mod tests {+ use super::*;++ /// A reference board, or None where the reference boards are not present (they do not ship with+ /// the source): the test that asked is then skipped.+ fn load(name: &str) -> Option<(Board, Rules)> {+ let path = format!("{}/bench/boards/{name}.ref.kicad_pcb", env!("CARGO_MANIFEST_DIR"));+ if !std::path::Path::new(&path).exists() {+ eprintln!("skipped: {path} is not here");+ return None;+ }+ let b = Board::read(&path).unwrap();+ let mut r = Rules::default();+ crate::project::apply(&path, &mut r);+ Some((b, r))+ }++ fn level_of(b: &Board, infos: &[NetInfo], name: &str) -> Level {+ infos.iter().find(|i| b.net_name(i.net) == name).unwrap_or_else(|| panic!("{name} not classified")).level+ }++ #[test]+ fn values_read_as_ohms() {+ for (text, ohm) in [("R068", 0.068), ("0R1", 0.1), ("10m", 0.01), ("100mR", 0.1), ("4k7", 4700.0), ("22", 22.0), ("0.05", 0.05), ("100mOhm", 0.1), ("1k", 1000.0)] {+ assert!((ohms(text).unwrap() - ohm).abs() < 1e-9, "{text}");+ }+ assert_eq!(ohms("BLU"), None);+ }++ /// The reviewer's list on benchmark C, and what the parts on those nets say.+ #[test]+ fn benchmark_c_classes_come_from_its_parts() {+ let Some((b, r)) = load("benchc_v10") else { return };+ let infos = classify(&b, &r);+ assert_eq!(level_of(&b, &infos, "/MCU_Upstream/5V_VCC"), Level::Power);+ assert_eq!(level_of(&b, &infos, "/MCU_Upstream/SW"), Level::Power);+ assert!(infos.iter().find(|i| b.net_name(i.net) == "/MCU_Upstream/SW").unwrap().switching);+ assert_eq!(level_of(&b, &infos, "Net-(D3-K)"), Level::Power);+ assert_eq!(level_of(&b, &infos, "Net-(D3-A)"), Level::Power);+ // An ESD array on a terminal line isn't an LED; a real LED's anode net is low priority.+ assert_eq!(level_of(&b, &infos, "/TI_0_J"), Level::Data);+ assert_eq!(level_of(&b, &infos, "/MCU_Upstream/+3V3_AN"), Level::Other);+ // A set resistor to GND doesn't drive an LED just because GND reaches one.+ assert_eq!(level_of(&b, &infos, "/MCU_Downstream/ILIM"), Level::Data);+ // RS1 is the shunt, U11 the amplifier, and its two tied pins on the far side share one tap.+ let tap = |name: &str| infos.iter().find(|i| b.net_name(i.net) == name).unwrap().taps.iter().map(|t| t.iter().map(|&p| b.pad_name(p)).collect::<Vec<_>>()).collect::<Vec<_>>();+ assert_eq!(tap("+3V3_EXT"), vec![vec!["U11.10".to_string(), "RS1.1".to_string()]]);+ assert_eq!(tap("+3V3_EXT_SENS"), vec![vec!["U11.8".to_string(), "U11.9".to_string(), "RS1.2".to_string()]]);+ }++ #[test]+ fn benchmark_b_classes_come_from_its_parts() {+ let Some((b, r)) = load("benchb_v210") else { return };+ let infos = classify(&b, &r);+ assert_eq!(level_of(&b, &infos, "-PS1"), Level::Power);+ assert_eq!(level_of(&b, &infos, "/USB2_RLY"), Level::Power);+ // Named like a rail, and is two FET gates.+ assert_eq!(level_of(&b, &infos, "/VBUS_G"), Level::Data);+ // Through the FET's channel from a power pin; the name says nothing.+ assert_eq!(level_of(&b, &infos, "/USB2_VBUS"), Level::Power);+ assert_eq!(level_of(&b, &infos, "/VBUS_CS"), Level::Power);+ assert_eq!(level_of(&b, &infos, "/USER_IO.0"), Level::Data);+ }++ #[test]+ fn piprobe_classes_come_from_its_parts() {+ let Some((b, r)) = load("piprobe_v13") else { return };+ let infos = classify(&b, &r);+ assert_eq!(level_of(&b, &infos, "+1V1"), Level::Power);+ assert_eq!(level_of(&b, &infos, "QSPI_SD0"), Level::Data);+ // Through R4 to /PWR_LED: the name of that net starts like a rail, and it's an LED.+ assert_eq!(level_of(&b, &infos, "GPIO2"), Level::Other);+ assert_eq!(level_of(&b, &infos, "XIN"), Level::Crystal);+ }++ /// Fine pitch from the pads, against the reference's parts: 0.4 and 0.5 mm QFNs are, a 2 mm module and a+ /// 1.27 mm SOIC aren't.+ #[test]+ fn fine_pitch_comes_from_the_pad_gap() {+ let fine = |b: &Board, r: &Rules, name: &str| is_fine_pitch(b, r, b.footprints.iter().position(|f| f.reference == name).unwrap());+ let Some((b, r)) = load("piprobe_v13") else { return };+ assert!(fine(&b, &r, "U1"));+ assert!(fine(&b, &r, "U2"));+ assert!(!fine(&b, &r, "C4"));+ let Some((b, r)) = load("benchb_v210") else { return };+ assert!(fine(&b, &r, "U3"));+ assert!(!fine(&b, &r, "U2"));+ assert!(!fine(&b, &r, "Q1"));+ let Some((b, r)) = load("benchc_v10") else { return };+ assert!(fine(&b, &r, "U6"));+ assert!(fine(&b, &r, "U11"));+ }+}
src/clock.rsadded+66@@ -0,0 +1,66 @@+//! The router's clock counts work done, not seconds passed.+//!+//! Every budget in the router (the run's limit, a connection's turn, a repair's share, a closing+//! pass's grant) used to be wall-clock time, so the same board routed differently depending on how+//! busy the machine was: on benchmark C one binary gave 301 joins in one run and 294 in another+//! (2026-11-06 and 11-09), and no change smaller than that could be measured. Here a "second" is a+//! fixed amount of search work, so the same input always gives the same board, on any machine and+//! under any load. `Instant` has the few methods of `std::time::Instant` the router uses, so the+//! budgets read as they did.+//!+//! What counts as work: one unit per search node expanded, a fixed charge per search started, and+//! one unit each time the clock is read, so a loop that only watches the clock still ends.++use std::cell::Cell;+use std::time::Duration;++/// Work units to one clock second. Measured 2026-11-09 on the development machine, each board+/// routed alone: benchmark C 164,090 units per wall second, benchmark B 239,069, piprobe_v13+/// 344,868 (a denser board spends longer on each search step). Set to benchmark C's, the board whose+/// run is bound by its limit, so its 790 s means the work it meant before; the smaller boards+/// finish before their limits. A fixed number on purpose: changing it changes every board's result.+pub const UNITS_PER_SECOND: f64 = 164_000.0;+/// What starting one search costs, in units: building its lines and tables.+pub const SEARCH_START: u64 = 200;++thread_local! {+ static WORK: Cell<u64> = const { Cell::new(0) };+}++pub fn tick(n: u64) {+ WORK.with(|w| w.set(w.get() + n));+}++pub fn units() -> u64 {+ WORK.with(|w| w.get())+}++#[derive(Clone, Copy, Debug, PartialEq, PartialOrd)]+pub struct Instant(f64);++impl Instant {+ pub fn now() -> Instant {+ tick(1);+ Instant(units() as f64 / UNITS_PER_SECOND)+ }++ pub fn elapsed(&self) -> Duration {+ Instant::now().saturating_duration_since(*self)+ }++ /// The earlier of two instants.+ pub fn min(self, other: Instant) -> Instant {+ if other.0 < self.0 { other } else { self }+ }++ pub fn saturating_duration_since(&self, earlier: Instant) -> Duration {+ Duration::from_secs_f64((self.0 - earlier.0).max(0.0))+ }+}++impl std::ops::Add<Duration> for Instant {+ type Output = Instant;+ fn add(self, d: Duration) -> Instant {+ Instant(self.0 + d.as_secs_f64())+ }+}
src/compare.rsadded+368@@ -0,0 +1,368 @@+//! Net by net against a reference board: length, vias, layers, widths, bends and spacing. The designer's+//! finished boards are the grade (4.6), so this is how cost weights are calibrated: change a weight,+//! route, compare, and keep it only if the board moves toward the reference copper. Reads only the two files.++use crate::board::*;+use crate::classify::*;+use crate::conn;+use crate::geom::*;+use crate::index::*;+use crate::router::build_world;+use crate::rules::Rules;+use serde_json::{json, Value};+use std::collections::{BTreeMap, HashMap};++#[derive(Default, Clone, Debug)]+pub struct NetStat {+ pub level: String,+ pub len: f64,+ pub top: f64,+ pub bottom: f64,+ pub vias: usize,+ pub bends45: usize,+ pub bends90: usize,+ /// Copper length per width in mm, keyed by the width in microns.+ pub widths: BTreeMap<i64, f64>,+ /// The shortest octilinear tree over the net's pads (lower bound for its copper).+ pub tree: f64,+ pub open: usize,+ /// Places where the width changes along a run.+ pub steps: usize,+ /// Same-net tracks running side by side: within 0.4 mm centre to centre, for 0.3 mm or more.+ pub doubled: usize,+}++/// Length-weighted spacing, over copper outside fine-pitch escapes and inside them.+#[derive(Default, Clone, Debug)]+pub struct Spacing {+ pub open_total: f64,+ pub open_3w: f64,+ pub open_2w: f64,+ pub open_min: f64,+ pub esc_total: f64,+ pub data_open_total: f64,+ pub data_open_in_band: f64,+ pub data_open_neck: f64,+}++fn octile(a: V, b: V) -> f64 {+ let (dx, dy) = ((a.0 - b.0).abs(), (a.1 - b.1).abs());+ let (hi, lo) = if dx > dy { (dx, dy) } else { (dy, dx) };+ (hi - lo) + lo * SQRT2+}++fn key(p: P) -> P {+ ((p.0 + 1000).div_euclid(2000), (p.1 + 1000).div_euclid(2000))+}++/// Prim over the net's pads, octile metric: the length a straight daisy chain could not beat by much.+fn tree_len(board: &Board, pads: &[usize]) -> f64 {+ if pads.len() < 2 {+ return 0.0;+ }+ let c: Vec<V> = pads.iter().map(|&p| board.pads[p].center.into()).collect();+ let mut best: Vec<f64> = c.iter().map(|&x| octile(x, c[0])).collect();+ let mut done = vec![false; c.len()];+ done[0] = true;+ let mut total = 0.0;+ for _ in 1..c.len() {+ let (i, d) = best.iter().enumerate().filter(|(i, _)| !done[*i]).map(|(i, d)| (i, *d)).min_by(|a, b| a.1.total_cmp(&b.1)).unwrap();+ total += d;+ done[i] = true;+ for j in 0..c.len() {+ best[j] = best[j].min(octile(c[i], c[j]));+ }+ }+ total+}++pub fn measure(board: &Board, rules: &Rules) -> (BTreeMap<String, NetStat>, Spacing) {+ let infos = classify(board, rules);+ let groups = conn::pad_groups(board, &[]);+ let world = build_world(board, rules);+ let mut out: BTreeMap<String, NetStat> = BTreeMap::new();+ let mut level_of: HashMap<NetId, Level> = HashMap::new();+ for i in &infos {+ let live = groups.get(&i.net).map(|g| g.iter().filter(|grp| grp.iter().any(|p| i.pads.contains(p))).count()).unwrap_or(0);+ level_of.insert(i.net, i.level);+ out.insert(board.net_name(i.net).to_string(), NetStat { level: i.level.name().to_string(), tree: tree_len(board, &i.pads) / NM, open: live.saturating_sub(1), ..Default::default() });+ }+ let last = board.layers.len() - 1;+ for t in &board.tracks {+ let e = out.entry(board.net_name(t.net).to_string()).or_default();+ let l = hypot(sub(t.a.into(), t.b.into())) / NM;+ e.len += l;+ if t.layer == 0 {+ e.top += l;+ } else if t.layer == last {+ e.bottom += l;+ }+ *e.widths.entry(t.width / 1000).or_insert(0.0) += l;+ }+ for v in &board.vias {+ out.entry(board.net_name(v.net).to_string()).or_default().vias += 1;+ }+ // Bends: a node on one layer with exactly two tracks, not hidden in a pad or its own fill.+ let mut nodes: HashMap<(NetId, usize, P), Vec<(P, P)>> = HashMap::new();+ let mut widths: HashMap<(NetId, usize, P), Vec<i64>> = HashMap::new();+ for t in &board.tracks {+ if t.a != t.b {+ nodes.entry((t.net, t.layer, key(t.a))).or_default().push((t.a, t.b));+ nodes.entry((t.net, t.layer, key(t.b))).or_default().push((t.b, t.a));+ widths.entry((t.net, t.layer, key(t.a))).or_default().push(t.width);+ widths.entry((t.net, t.layer, key(t.b))).or_default().push(t.width);+ }+ }+ for (k, w) in &widths {+ if w.len() == 2 && w[0] != w[1] {+ out.entry(board.net_name(k.0).to_string()).or_default().steps += 1;+ }+ }+ for (i, t) in board.tracks.iter().enumerate() {+ for o in board.tracks[i + 1..].iter().filter(|o| o.net == t.net && o.layer == t.layer) {+ if side_by_side(t, o) {+ out.entry(board.net_name(t.net).to_string()).or_default().doubled += 1;+ }+ }+ }+ for ((net, layer, _), list) in &nodes {+ if list.len() != 2 {+ continue;+ }+ let at: V = list[0].0.into();+ let hidden = board.pads.iter().any(|q| q.net == *net && q.layers.contains(layer) && q.shape.dist_pt(at) <= 0.25 * NM) || world.zones.iter().any(|(zn, zl, poly)| zn == net && zl == layer && in_poly(at, poly));+ if hidden {+ continue;+ }+ let ang = |x: (P, P)| ((x.1 .1 - x.0 .1) as f64).atan2((x.1 .0 - x.0 .0) as f64).to_degrees();+ let d = (ang(list[0]) - ang(list[1])).rem_euclid(360.0);+ let turn = 180.0 - d.min(360.0 - d);+ let e = out.entry(board.net_name(*net).to_string()).or_default();+ if (turn - 45.0).abs() < 3.0 {+ e.bends45 += 1;+ } else if (turn - 90.0).abs() < 3.0 {+ e.bends90 += 1;+ }+ }+ (out, spacing(board, rules, &world, &level_of))+}++/// Of the signal vias, how many have a via of a plane net within `reach` (4.4: any signal via benefits+/// from a GND via close by). Returns (with one close by, all signal vias).+pub fn vias_near_ground(board: &Board, reach: f64) -> (usize, usize) {+ let planes = board.plane_nets();+ let ground: Vec<V> = board.vias.iter().filter(|v| planes.contains_key(&v.net)).map(|v| v.at.into()).collect();+ let power: Vec<&Via> = board.vias.iter().filter(|v| !planes.contains_key(&v.net) && !looks_power(board.net_name(v.net))).collect();+ let near = power.iter().filter(|v| ground.iter().any(|g| hypot(sub(*g, v.at.into())) <= reach)).count();+ (near, power.len())+}++fn side_by_side(t: &Track, o: &Track) -> bool {+ let (a, b): (V, V) = (t.a.into(), t.b.into());+ let (c, d): (V, V) = (o.a.into(), o.b.into());+ let (l, m) = (hypot(sub(b, a)), hypot(sub(d, c)));+ if l < 1.0 || m < 1.0 {+ return false;+ }+ let u = V((b.0 - a.0) / l, (b.1 - a.1) / l);+ if ((d.1 - c.1) / m * u.0 - (d.0 - c.0) / m * u.1).abs() > 0.02 {+ return false;+ }+ let off = ((c.0 - a.0) * -u.1 + (c.1 - a.1) * u.0).abs();+ let t0 = (c.0 - a.0) * u.0 + (c.1 - a.1) * u.1;+ let t1 = (d.0 - a.0) * u.0 + (d.1 - a.1) * u.1;+ off <= 0.4 * NM && l.min(t0.max(t1)) - t0.min(t1).max(0.0) >= 0.3 * NM+}++/// Walks all copper in small steps and buckets each step by its distance to other nets. 3W and 2W+/// are measured on the track's own width, as 4.2 states them. Fills are skipped: they refill around+/// tracks, so a gap to one says nothing about the routing.+fn spacing(board: &Board, rules: &Rules, world: &crate::search::World, level_of: &HashMap<NetId, Level>) -> Spacing {+ let mut s = Spacing::default();+ let step = 0.2 * NM;+ for t in &board.tracks {+ let (a, b): (V, V) = (t.a.into(), t.b.into());+ let len = hypot(sub(a, b));+ let n = (len / step).ceil().max(1.0) as usize;+ let half = t.width as f64 / 2.0;+ for i in 0..n {+ let (u, v) = (i as f64 / n as f64, (i + 1) as f64 / n as f64);+ let p = V(a.0 + (b.0 - a.0) * u, a.1 + (b.1 - a.1) * u);+ let q = V(a.0 + (b.0 - a.0) * v, a.1 + (b.1 - a.1) * v);+ let piece = len / n as f64 / NM;+ let m = V((p.0 + q.0) / 2.0, (p.1 + q.1) / 2.0);+ if world.in_escape(m) {+ s.esc_total += piece;+ continue;+ }+ let reach = half + t.width as f64 * 3.0 + 1000.0;+ let bb = (p.0.min(q.0) - reach, p.1.min(q.1) - reach, p.0.max(q.0) + reach, p.1.max(q.1) + reach);+ let mut gap = f64::INFINITY;+ world.layers[t.layer].visit(bb, |o| {+ if o.net != t.net && !matches!(o.owner, Owner::Zone(_)) {+ gap = gap.min(o.shape.dist_seg(p, q) - half);+ }+ true+ });+ s.open_total += piece;+ let w = t.width as f64;+ // 3W and 2W are centre to centre: 3W leaves a gap of 2W, 2W a gap of 1W (4.2).+ if gap >= w * 2.0 - 1000.0 {+ s.open_3w += piece;+ } else if gap >= w - 1000.0 {+ s.open_2w += piece;+ } else {+ s.open_min += piece;+ }+ if matches!(level_of.get(&t.net), Some(Level::Data) | Some(Level::Other)) {+ s.data_open_total += piece;+ if t.width >= rules.track - 1000 && t.width <= mm(0.3) + 1000 {+ s.data_open_in_band += piece;+ } else if t.width <= rules.neck + 1000 {+ s.data_open_neck += piece;+ }+ }+ }+ }+ s+}++/// Per pair: length each side, skew, and how much of the positive net runs coupled to the negative+/// one (edge to edge within 0.35 mm on the same layer; 4.2 wants the spec held but at pad and via).+pub fn pairs(board: &Board, rules: &Rules) -> Vec<(String, f64, f64, f64, f64)> {+ let mut out = Vec::new();+ for pr in &rules.pairs {+ let (Some(&p), Some(&n)) = (board.net_ids.get(&pr.p), board.net_ids.get(&pr.n)) else { continue };+ let len = |net: NetId| -> f64 { board.tracks.iter().filter(|t| t.net == net).map(|t| hypot(sub(t.a.into(), t.b.into()))).sum::<f64>() / NM };+ let mut coupled = 0.0;+ let mut gap_sum = 0.0;+ for t in board.tracks.iter().filter(|t| t.net == p) {+ let (a, b): (V, V) = (t.a.into(), t.b.into());+ let l = hypot(sub(a, b));+ let steps = (l / (0.05 * NM)).ceil().max(1.0) as usize;+ for i in 0..steps {+ let u = (i as f64 + 0.5) / steps as f64;+ let c = V(a.0 + (b.0 - a.0) * u, a.1 + (b.1 - a.1) * u);+ let mut near = f64::INFINITY;+ for o in board.tracks.iter().filter(|o| o.net == n && o.layer == t.layer) {+ near = near.min(pt_seg(c, o.a.into(), o.b.into()) - (t.width + o.width) as f64 / 2.0);+ }+ if near <= 0.35 * NM {+ coupled += l / steps as f64 / NM;+ gap_sum += near * (l / steps as f64) / NM / NM;+ }+ }+ }+ out.push((format!("{}/{}", pr.p, pr.n), len(p), len(n), coupled, if coupled > 0.0 { gap_sum / coupled } else { 0.0 }));+ }+ out+}++fn widths_text(w: &BTreeMap<i64, f64>) -> String {+ let mut v: Vec<(&i64, &f64)> = w.iter().collect();+ v.sort_by(|a, b| b.1.total_cmp(a.1));+ v.iter().take(3).map(|(k, l)| format!("{:.2}:{:.1}", **k as f64 / 1000.0, l)).collect::<Vec<_>>().join(" ")+}++fn pct(a: f64, t: f64) -> f64 {+ if t > 0.0 { 100.0 * a / t } else { 0.0 }+}++fn spacing_json(s: &Spacing) -> Value {+ json!({+ "openMm": s.open_total, "escapeMm": s.esc_total,+ "at3wPct": pct(s.open_3w, s.open_total), "at2wPct": pct(s.open_2w, s.open_total), "tighterPct": pct(s.open_min, s.open_total),+ "dataOpenMm": s.data_open_total, "dataInBandPct": pct(s.data_open_in_band, s.data_open_total), "dataAtNeckPct": pct(s.data_open_neck, s.data_open_total),+ })+}++/// Prints the comparison, biggest length difference first. The summary line carries the one number+/// to minimise when calibrating: the sum over nets of |ours - his| length, plus 3 mm per via off.+pub fn report(ours: &Board, reference: &Board, rules: &Rules, as_json: bool) -> Value {+ let (a, sa) = measure(ours, rules);+ let (b, sb) = measure(reference, rules);+ let (bus_n, bus_ours, bus_mm) = crate::bus::coherence(ours, rules);+ let (_, bus_his, bus_his_mm) = crate::bus::coherence(reference, rules);+ if !as_json {+ println!(" buses: {bus_n} bus lines; {:.0}% of their length runs beside the next line in the bus (reference {:.0}%), over {:.0} mm (reference {:.0} mm).", bus_ours * 100.0, bus_his * 100.0, bus_mm, bus_his_mm);+ }+ let mut rows: Vec<(String, NetStat, NetStat)> = Vec::new();+ for (n, x) in &b {+ if x.len > 0.0 || x.vias > 0 || a.get(n).map(|y| y.len > 0.0).unwrap_or(false) {+ rows.push((n.clone(), a.get(n).cloned().unwrap_or_default(), x.clone()));+ }+ }+ rows.sort_by(|p, q| (q.1.len - q.2.len).abs().total_cmp(&(p.1.len - p.2.len).abs()));+ let mut score = 0.0;+ let (mut la, mut lb, mut va, mut vb, mut ba, mut bb, mut open) = (0.0, 0.0, 0, 0, 0, 0, 0);+ let mut per_net = Vec::new();+ // The same distance over only the nets we closed, bends included, so a calibration run isn't+ // judged by how far the clock let it get.+ let (mut closed, mut closed_nets) = (0.0, 0);+ for (n, x, y) in &rows {+ score += (x.len - y.len).abs() + 3.0 * (x.vias as f64 - y.vias as f64).abs();+ if x.open == 0 {+ closed += (x.len - y.len).abs() + 3.0 * (x.vias as f64 - y.vias as f64).abs() + 0.3 * ((x.bends45 + x.bends90) as f64 - (y.bends45 + y.bends90) as f64).abs();+ closed_nets += 1;+ }+ la += x.len;+ lb += y.len;+ va += x.vias;+ vb += y.vias;+ ba += x.bends45 + x.bends90;+ bb += y.bends45 + y.bends90;+ open += x.open;+ per_net.push(json!({"net": n, "level": y.level, "treeMm": y.tree, "open": x.open,+ "ours": {"mm": x.len, "top": x.top, "bottom": x.bottom, "vias": x.vias, "bends45": x.bends45, "bends90": x.bends90, "widths": widths_text(&x.widths)},+ "his": {"mm": y.len, "top": y.top, "bottom": y.bottom, "vias": y.vias, "bends45": y.bends45, "bends90": y.bends90, "widths": widths_text(&y.widths)}}));+ }+ let tally = |m: &BTreeMap<String, NetStat>| (m.values().map(|n| n.steps).sum::<usize>(), m.values().map(|n| n.doubled).sum::<usize>());+ let (ta, tb) = (tally(&a), tally(&b));+ let v = json!({"widthSteps": [ta.0, tb.0], "doubledRuns": [ta.1, tb.1], "score": score, "closedScore": closed, "closedNets": closed_nets, "lengthMm": [la, lb], "vias": [va, vb], "bends": [ba, bb], "open": open, "spacing": {"ours": spacing_json(&sa), "his": spacing_json(&sb)}, "nets": per_net});+ if !as_json {+ println!("OK: distance from the reference copper {:.1} (sum of |length diff| + 3 per via off); {:.1} mm against the reference's {:.1}, {} vias against {}, {} bends against {}, {} joins open. On the {} nets closed: {:.1} with bends at 0.3 each.", score, la, lb, va, vb, ba, bb, open, closed_nets, closed);+ println!(" width steps {} against the reference's {}; same-net doubled runs {} against the reference's {}.", ta.0, tb.0, ta.1, tb.1);+ for (who, s) in [("ours", &sa), ("ref ", &sb)] {+ println!(" spacing {who}: outside escapes {:.1} mm: {:.0}% at 3W, {:.0}% at 2W, {:.0}% tighter; in escapes {:.1} mm. Data outside escapes {:.1} mm: {:.0}% at 0.2-0.3 wide, {:.0}% at the neck.", s.open_total, pct(s.open_3w, s.open_total), pct(s.open_2w, s.open_total), pct(s.open_min, s.open_total), s.esc_total, s.data_open_total, pct(s.data_open_in_band, s.data_open_total), pct(s.data_open_neck, s.data_open_total));+ }+ let long = |pick: &dyn Fn(&(String, NetStat, NetStat)) -> f64| rows.iter().filter(|r| r.2.tree > 1.0 * NM / NM && r.2.level != "plane via" && pick(r) > r.2.tree * 1.4 + 2.5).map(|r| r.0.as_str()).collect::<Vec<_>>().join(" ");+ println!(" over 1.4 x tree + 2.5 mm, ours: [{}]; reference: [{}].", long(&|r| r.1.len), long(&|r| r.2.len));+ for (who, b) in [("ours", ours), ("ref ", reference)] {+ let (n, t) = vias_near_ground(b, 1.5 * NM);+ println!(" signal vias {who}: {n} of {t} have a plane via within 1.5 mm.");+ }+ for (who, list) in [("ours", pairs(ours, rules)), ("ref ", pairs(reference, rules))] {+ for (name, lp, ln, c, g) in list {+ println!(" pair {who} {name}: {lp:.2} / {ln:.2} mm, skew {:.3} mm, coupled {c:.2} mm ({:.0}% of the positive net), mean gap {g:.3} mm.", (lp - ln).abs(), if lp > 0.0 { 100.0 * c / lp } else { 0.0 });+ }+ }+ println!(" {:<16} {:<10} {:>6} | {:>7} {:>7} {:>6} {:>6} {:>4} {:>5} {:<22} | {:>7} {:>7} {:>6} {:>6} {:>4} {:>5} {}", "net", "level", "tree", "mm", "ratio", "top", "bot", "via", "bends", "widths mm:len", "ref mm", "ratio", "top", "bot", "via", "bends", "widths");+ for (n, x, y) in &rows {+ let r = |l: f64| if y.tree > 0.0 { l / y.tree } else { 0.0 };+ println!(+ " {:<16} {:<10} {:>6.1} | {:>7.1} {:>7.2} {:>6.1} {:>6.1} {:>4} {:>2}/{:<2} {:<22} | {:>7.1} {:>7.2} {:>6.1} {:>6.1} {:>4} {:>2}/{:<2} {}{}",+ n.chars().take(16).collect::<String>(),+ y.level.chars().take(10).collect::<String>(),+ y.tree,+ x.len,+ r(x.len),+ x.top,+ x.bottom,+ x.vias,+ x.bends45,+ x.bends90,+ widths_text(&x.widths),+ y.len,+ r(y.len),+ y.top,+ y.bottom,+ y.vias,+ y.bends45,+ y.bends90,+ widths_text(&y.widths),+ if x.open > 0 { format!(" OPEN {}", x.open) } else { String::new() }+ );+ }+ }+ v+}
src/conn.rsadded+315@@ -0,0 +1,315 @@+//! Connectivity from copper alone: which pads of a net are joined by tracks, vias and zones.+//! Used by the checker and to find what still has to be routed on a partly routed board.++use crate::board::*;+use crate::geom::*;+use std::collections::HashMap;++#[derive(Clone)]+pub struct Uf(Vec<u32>);+impl Uf {+ pub fn new(n: usize) -> Uf {+ Uf((0..n as u32).collect())+ }+ pub fn find(&mut self, a: u32) -> u32 {+ let mut r = a;+ while self.0[r as usize] != r {+ r = self.0[r as usize];+ }+ let mut c = a;+ while self.0[c as usize] != r {+ let n = self.0[c as usize];+ self.0[c as usize] = r;+ c = n;+ }+ r+ }+ pub fn union(&mut self, a: u32, b: u32) {+ let (x, y) = (self.find(a), self.find(b));+ if x != y {+ self.0[x as usize] = y;+ }+ }+}++/// One piece of copper on one or more layers, for connectivity.+pub enum Piece {+ Pad(usize),+ Seg { layer: usize, a: V, b: V, r: f64 },+ Via { at: V, r: f64 },+ Fill { layer: usize, pts: Vec<V> },+}++pub fn touches(p: &Piece, q: &Piece, board: &Board) -> bool {+ use Piece::*;+ match (p, q) {+ (Pad(i), Pad(j)) => {+ let (a, b) = (&board.pads[*i], &board.pads[*j]);+ a.layers.iter().any(|l| b.layers.contains(l)) && a.shape.dist_shape(&b.shape) <= 1.0+ }+ (Pad(i), Seg { layer, a, b, r }) | (Seg { layer, a, b, r }, Pad(i)) => {+ let pad = &board.pads[*i];+ pad.layers.contains(layer) && pad.shape.dist_seg(*a, *b) <= r + 1.0+ }+ (Pad(i), Via { at, r }) | (Via { at, r }, Pad(i)) => board.pads[*i].shape.dist_pt(*at) <= r + 1.0 && !board.pads[*i].layers.is_empty(),+ (Pad(i), Fill { layer, pts }) | (Fill { layer, pts }, Pad(i)) => {+ let pad = &board.pads[*i];+ pad.layers.contains(layer) && shape_meets_poly(&pad.shape, pts)+ }+ (Seg { layer: l1, a, b, r }, Seg { layer: l2, a: c, b: d, r: s }) => l1 == l2 && seg_seg(*a, *b, *c, *d) <= r + s + 1.0,+ (Seg { a, b, r, .. }, Via { at, r: s }) | (Via { at, r: s }, Seg { a, b, r, .. }) => pt_seg(*at, *a, *b) <= r + s + 1.0,+ (Seg { layer: l1, a, b, r }, Fill { layer: l2, pts }) | (Fill { layer: l2, pts }, Seg { layer: l1, a, b, r }) => {+ l1 == l2 && (in_poly(*a, pts) || in_poly(*b, pts) || poly_seg_dist(pts, *a, *b) <= *r + 1.0)+ }+ (Via { at: a, r }, Via { at: b, r: s }) => hypot(sub(*a, *b)) <= r + s + 1.0,+ (Via { at, r }, Fill { pts, .. }) | (Fill { pts, .. }, Via { at, r }) => in_poly(*at, pts) || poly_seg_dist(pts, *at, *at) <= *r,+ (Fill { layer: l1, pts: a }, Fill { layer: l2, pts: b }) => l1 == l2 && polys_meet(a, b),+ }+}++/// Two fills are one piece of copper as soon as they overlap anywhere. Testing a single vertex+/// missed overlapping zones whose first vertices lie outside each other, which split nets KiCad+/// sees as joined (benchmark B's GND, +PS1_SENS, -PS1 and +PS2_RLY fills).+fn polys_meet(a: &[V], b: &[V]) -> bool {+ let bb = |p: &[V]| p.iter().fold((f64::MAX, f64::MAX, f64::MIN, f64::MIN), |m, v| (m.0.min(v.0), m.1.min(v.1), m.2.max(v.0), m.3.max(v.1)));+ let (ba, bc) = (bb(a), bb(b));+ if ba.0 > bc.2 + 1.0 || bc.0 > ba.2 + 1.0 || ba.1 > bc.3 + 1.0 || bc.1 > ba.3 + 1.0 {+ return false;+ }+ if a.iter().any(|p| in_poly(*p, b)) || b.iter().any(|p| in_poly(*p, a)) {+ return true;+ }+ let (n, m) = (a.len(), b.len());+ for i in 0..n {+ let (p, q) = (a[i], a[(i + 1) % n]);+ if p.0.max(q.0) < bc.0 || p.0.min(q.0) > bc.2 || p.1.max(q.1) < bc.1 || p.1.min(q.1) > bc.3 {+ continue;+ }+ for j in 0..m {+ if seg_seg(p, q, b[j], b[(j + 1) % m]) <= 1.0 {+ return true;+ }+ }+ }+ false+}++fn poly_seg_dist(pts: &[V], a: V, b: V) -> f64 {+ let n = pts.len();+ let mut d = f64::INFINITY;+ for i in 0..n {+ d = d.min(seg_seg(a, b, pts[i], pts[(i + 1) % n]));+ }+ d+}++pub(crate) fn shape_meets_poly(s: &Shape, pts: &[V]) -> bool {+ let c = s.center();+ if in_poly(c, pts) {+ return true;+ }+ let n = pts.len();+ for i in 0..n {+ if s.dist_seg(pts[i], pts[(i + 1) % n]) <= 1.0 {+ return true;+ }+ }+ false+}++/// Groups of pad indices per net that the copper on `board` already joins. `extra` adds copper+/// from a route in progress, as (net, piece).+/// One connected piece of a net's copper.+pub struct Island {+ pub net: NetId,+ pub has_pad: bool,+ pub fill_pieces: usize,+ pub tracks: usize,+ pub vias: usize,+ pub layer: usize,+ pub at: V,+ pub area: f64,+}++/// Every connected piece of copper on the board, net by net, pads or none. `pad_groups` keeps only+/// what touches a pad, so a fill nothing feeds is invisible to it.+pub fn islands(board: &Board) -> Vec<Island> {+ let mut by_net: HashMap<NetId, Vec<Piece>> = HashMap::new();+ for (i, p) in board.pads.iter().enumerate() {+ if p.net != NO_NET && !p.layers.is_empty() {+ by_net.entry(p.net).or_default().push(Piece::Pad(i));+ }+ }+ for t in &board.tracks {+ if t.net != NO_NET {+ by_net.entry(t.net).or_default().push(Piece::Seg { layer: t.layer, a: t.a.into(), b: t.b.into(), r: t.width as f64 / 2.0 });+ }+ }+ for v in &board.vias {+ if v.net != NO_NET {+ by_net.entry(v.net).or_default().push(Piece::Via { at: v.at.into(), r: v.size as f64 / 2.0 });+ }+ }+ for z in &board.zones {+ if z.net == NO_NET || z.keepout_tracks {+ continue;+ }+ if z.fills.is_empty() {+ for &l in &z.layers {+ by_net.entry(z.net).or_default().push(Piece::Fill { layer: l, pts: z.outline.clone() });+ }+ } else {+ for (l, pts) in &z.fills {+ by_net.entry(z.net).or_default().push(Piece::Fill { layer: *l, pts: pts.clone() });+ }+ }+ }+ let mut out = Vec::new();+ for (net, pieces) in by_net {+ let mut uf = Uf::new(pieces.len());+ for i in 0..pieces.len() {+ for j in i + 1..pieces.len() {+ if uf.find(i as u32) != uf.find(j as u32) && touches(&pieces[i], &pieces[j], board) {+ uf.union(i as u32, j as u32);+ }+ }+ }+ let mut groups: HashMap<u32, Island> = HashMap::new();+ for (i, p) in pieces.iter().enumerate() {+ let isl = groups.entry(uf.find(i as u32)).or_insert(Island { net, has_pad: false, fill_pieces: 0, tracks: 0, vias: 0, layer: 0, at: V(0.0, 0.0), area: 0.0 });+ match p {+ Piece::Pad(_) => isl.has_pad = true,+ Piece::Seg { layer, a, .. } => {+ isl.tracks += 1;+ if isl.fill_pieces == 0 {+ isl.layer = *layer;+ isl.at = *a;+ }+ }+ Piece::Via { at, .. } => {+ isl.vias += 1;+ if isl.fill_pieces == 0 && isl.tracks == 0 {+ isl.at = *at;+ }+ }+ Piece::Fill { layer, pts } => {+ isl.fill_pieces += 1;+ isl.layer = *layer;+ isl.at = pts.first().copied().unwrap_or(V(0.0, 0.0));+ let n = pts.len();+ let a: f64 = (0..n).map(|k| pts[k].0 * pts[(k + 1) % n].1 - pts[(k + 1) % n].0 * pts[k].1).sum();+ isl.area += (a / 2.0).abs();+ }+ }+ }+ out.extend(groups.into_values());+ }+ out.sort_by(|a, b| (a.net, a.at.0.to_bits()).cmp(&(b.net, b.at.0.to_bits())));+ out+}++pub fn pad_groups(board: &Board, extra: &[(NetId, Piece)]) -> HashMap<NetId, Vec<Vec<usize>>> {+ let mut by_net: HashMap<NetId, Vec<Piece>> = HashMap::new();+ for (i, p) in board.pads.iter().enumerate() {+ if p.net != NO_NET && !p.layers.is_empty() {+ by_net.entry(p.net).or_default().push(Piece::Pad(i));+ }+ }+ for t in &board.tracks {+ by_net.entry(t.net).or_default().push(Piece::Seg { layer: t.layer, a: t.a.into(), b: t.b.into(), r: t.width as f64 / 2.0 });+ }+ for v in &board.vias {+ by_net.entry(v.net).or_default().push(Piece::Via { at: v.at.into(), r: v.size as f64 / 2.0 });+ }+ for z in &board.zones {+ if z.net == NO_NET || z.keepout_tracks {+ continue;+ }+ if z.fills.is_empty() {+ for &l in &z.layers {+ by_net.entry(z.net).or_default().push(Piece::Fill { layer: l, pts: z.outline.clone() });+ }+ } else {+ for (l, pts) in &z.fills {+ by_net.entry(z.net).or_default().push(Piece::Fill { layer: *l, pts: pts.clone() });+ }+ }+ }+ for (n, p) in extra {+ let q = match p {+ Piece::Pad(i) => Piece::Pad(*i),+ Piece::Seg { layer, a, b, r } => Piece::Seg { layer: *layer, a: *a, b: *b, r: *r },+ Piece::Via { at, r } => Piece::Via { at: *at, r: *r },+ Piece::Fill { layer, pts } => Piece::Fill { layer: *layer, pts: pts.clone() },+ };+ by_net.entry(*n).or_default().push(q);+ }+ let mut out = HashMap::new();+ for (net, pieces) in by_net {+ let mut uf = Uf::new(pieces.len());+ for i in 0..pieces.len() {+ for j in i + 1..pieces.len() {+ if uf.find(i as u32) != uf.find(j as u32) && touches(&pieces[i], &pieces[j], board) {+ uf.union(i as u32, j as u32);+ }+ }+ }+ let mut groups: HashMap<u32, Vec<usize>> = HashMap::new();+ for (i, p) in pieces.iter().enumerate() {+ if let Piece::Pad(k) = p {+ groups.entry(uf.find(i as u32)).or_default().push(*k);+ }+ }+ let mut v: Vec<Vec<usize>> = groups.into_values().collect();+ v.sort();+ out.insert(net, v);+ }+ out+}++/// `pad_groups` for one net, with routed copper in progress: cheap enough to ask during routing.+pub fn net_groups(board: &Board, net: NetId, extra: &[(NetId, Piece)]) -> Vec<Vec<usize>> {+ let mut pieces: Vec<&Piece> = Vec::new();+ let mut own: Vec<Piece> = Vec::new();+ for (i, p) in board.pads.iter().enumerate() {+ if p.net == net && !p.layers.is_empty() {+ own.push(Piece::Pad(i));+ }+ }+ for t in board.tracks.iter().filter(|t| t.net == net) {+ own.push(Piece::Seg { layer: t.layer, a: t.a.into(), b: t.b.into(), r: t.width as f64 / 2.0 });+ }+ for v in board.vias.iter().filter(|v| v.net == net) {+ own.push(Piece::Via { at: v.at.into(), r: v.size as f64 / 2.0 });+ }+ for z in board.zones.iter().filter(|z| z.net == net && !z.keepout_tracks) {+ if z.fills.is_empty() {+ for &l in &z.layers {+ own.push(Piece::Fill { layer: l, pts: z.outline.clone() });+ }+ } else {+ for (l, pts) in &z.fills {+ own.push(Piece::Fill { layer: *l, pts: pts.clone() });+ }+ }+ }+ pieces.extend(own.iter());+ pieces.extend(extra.iter().filter(|(n, _)| *n == net).map(|(_, p)| p));+ let mut uf = Uf::new(pieces.len());+ for i in 0..pieces.len() {+ for j in i + 1..pieces.len() {+ if uf.find(i as u32) != uf.find(j as u32) && touches(pieces[i], pieces[j], board) {+ uf.union(i as u32, j as u32);+ }+ }+ }+ let mut groups: HashMap<u32, Vec<usize>> = HashMap::new();+ for (i, p) in pieces.iter().enumerate() {+ if let Piece::Pad(k) = p {+ groups.entry(uf.find(i as u32)).or_default().push(*k);+ }+ }+ let mut v: Vec<Vec<usize>> = groups.into_values().collect();+ v.sort();+ v+}
src/current.rsadded+327@@ -0,0 +1,327 @@+//! What a rail's copper must carry (4.2 Current capacity). The chart, and the current through each+//! track found from the copper actually connected: pours included, sources at one end and each+//! part's own draw at the other, so a thin track to a pull-up is a branch and not the feed.+//!+//! The chart is IPC-2152's conservative chart, which is the old IPC-2221 internal-conductor chart,+//! as a published curve fit: I = 0.024 dT^0.44 A^0.725, A in square mils. Checked in this repo's+//! work against MIL-STD-275E Figure 4b within 5% at 1 A and above at 20 C; 3 A at 20 C in 35 um gives+//! 2.34 mm against the reference's 2.3 mm. IPC-2152 itself is paywalled and was not read. A board of 3 x 3 in or+//! more with such a plane under the trace may use the universal chart with modifiers; that chart+//! is not sourced here, so it gets this one, which is wider and so safe.++use crate::board::*;+use crate::conn::{touches, Piece};+use crate::geom::*;+use crate::rules::{RailSpec, Rules};++const MIL2_PER_MM2: f64 = 1550.0031;++/// The most of a rail's voltage its copper may lose between a source and a load: the designer's limit,+/// 2026-11-09 (0.25% of the rail). Currents as the rail spec rates them: an off-board contact by+/// what its supply can deliver, an internal rail by the most its devices can pull.+pub const DROP_LIMIT: f64 = 0.0025;+/// Copper's resistivity at 20 C, ohm metres (the International Annealed Copper Standard).+pub const RHO_CU: f64 = 1.7241e-8;+/// Hole wall copper: JLCPCB's capability table, "Average Hole Plating Thickness 18 um"+/// (jlcpcb.com/capabilities/pcb-capabilities, read 2026-11-09).+pub const VIA_PLATING_UM: f64 = 18.0;+/// A via's length for its resistance: a standard 1.6 mm board (the stackup isn't read here).+const VIA_LENGTH_MM: f64 = 1.6;++/// What one load loses in the copper from its source.+pub struct Drop {+ pub load: String,+ pub amps: f64,+ pub volts: f64,+ pub limit: f64,+}++pub fn conservative_mm(amps: f64, rise_c: f64, copper_um: f64) -> f64 {+ if amps <= 0.0 || rise_c <= 0.0 || copper_um <= 0.0 {+ return 0.0;+ }+ (amps / (0.024 * rise_c.powf(0.44))).powf(1.0 / 0.725) / MIL2_PER_MM2 / (copper_um / 1000.0)+}++/// The board's F.Cu thickness from its stackup, 35 um when it states none.+pub fn outer_copper_um(text: &str) -> f64 {+ let Some(st) = text.find("(stackup") else { return 35.0 };+ let rest = &text[st..];+ let Some(l) = rest.find("(layer \"F.Cu\"") else { return 35.0 };+ let rest = &rest[l..];+ let Some(t) = rest.find("(thickness ") else { return 35.0 };+ let rest = &rest[t + 11..];+ rest.split(|c: char| c == ')' || c.is_whitespace()).next().and_then(|x| x.parse::<f64>().ok()).filter(|x| *x > 0.0).map(|x| x * 1000.0).unwrap_or(35.0)+}++/// One track's rating: index into `board.tracks`, what flows through it, and the width that needs.+pub struct Rated {+ pub track: usize,+ pub amps: f64,+ pub need: i64,+ /// The width comes from the voltage-drop limit, not the chart.+ pub by_drop: bool,+}++/// Each track of one rail that current has to pass through. A track in a loop has another way+/// round, so nothing here says it alone carries the load: it is left out rather than guessed at.+pub fn rate_rail(board: &Board, rules: &Rules, net: NetId, rail: &RailSpec) -> Vec<Rated> {+ analyse(board, rules, net, rail).0+}++/// What each load of a rail loses in its copper from the source, where the spec gives the rail's+/// voltage. Tracks and vias count; a pour on the way counts as no loss (it is far wider).+pub fn rail_drops(board: &Board, rules: &Rules, net: NetId, rail: &RailSpec) -> Vec<Drop> {+ analyse(board, rules, net, rail).1+}++fn analyse(board: &Board, rules: &Rules, net: NetId, rail: &RailSpec) -> (Vec<Rated>, Vec<Drop>) {+ let mut pieces: Vec<Piece> = Vec::new();+ let mut pad_of: Vec<Option<usize>> = Vec::new();+ let mut track_of: Vec<Option<usize>> = Vec::new();+ for (i, p) in board.pads.iter().enumerate().filter(|(_, p)| p.net == net && !p.layers.is_empty()) {+ pieces.push(Piece::Pad(i));+ pad_of.push(Some(i));+ track_of.push(None);+ }+ for (i, t) in board.tracks.iter().enumerate().filter(|(_, t)| t.net == net) {+ pieces.push(Piece::Seg { layer: t.layer, a: t.a.into(), b: t.b.into(), r: t.width as f64 / 2.0 });+ pad_of.push(None);+ track_of.push(Some(i));+ }+ for v in board.vias.iter().filter(|v| v.net == net) {+ pieces.push(Piece::Via { at: v.at.into(), r: v.size as f64 / 2.0 });+ pad_of.push(None);+ track_of.push(None);+ }+ for z in board.zones.iter().filter(|z| z.net == net && !z.keepout_tracks) {+ if z.fills.is_empty() {+ for &l in &z.layers {+ pieces.push(Piece::Fill { layer: l, pts: z.outline.clone() });+ pad_of.push(None);+ track_of.push(None);+ }+ } else {+ for (l, f) in &z.fills {+ pieces.push(Piece::Fill { layer: *l, pts: f.clone() });+ pad_of.push(None);+ track_of.push(None);+ }+ }+ }+ let n = pieces.len();+ let mut adj: Vec<Vec<usize>> = vec![Vec::new(); n];+ for i in 0..n {+ for j in i + 1..n {+ if touches(&pieces[i], &pieces[j], board) {+ adj[i].push(j);+ adj[j].push(i);+ }+ }+ }+ let reference = |k: usize| pad_of[k].map(|p| board.footprints[board.pads[p].fp].reference.as_str());+ let sources: Vec<usize> = (0..n).filter(|&k| reference(k).map(|r| rail.sources.iter().any(|s| s == r)).unwrap_or(false)).collect();+ if sources.is_empty() {+ return (Vec::new(), Vec::new());+ }+ let reach = |skip: Option<usize>| -> Vec<bool> {+ let mut seen = vec![false; n];+ let mut stack: Vec<usize> = sources.clone();+ for &s in &sources {+ seen[s] = true;+ }+ while let Some(k) = stack.pop() {+ for &m in &adj[k] {+ if !seen[m] && Some(m) != skip {+ seen[m] = true;+ stack.push(m);+ }+ }+ }+ seen+ };+ let all = reach(None);+ let mut out = Vec::new();+ for k in 0..n {+ let Some(t) = track_of[k] else { continue };+ if !all[k] {+ continue;+ }+ let seen = reach(Some(k));+ // A part is cut off when none of its pads on this net can still be reached.+ let mut flowing = 0.0;+ for (name, amps) in &rail.loads {+ let mine: Vec<usize> = (0..n).filter(|&m| reference(m) == Some(name.as_str())).collect();+ if !mine.is_empty() && mine.iter().all(|&m| !seen[m]) && mine.iter().any(|&m| all[m]) {+ flowing += *amps;+ }+ }+ let amps = flowing.min(rail.amps);+ if amps > 0.0 {+ let main = amps >= rail.amps - 1e-9;+ out.push(Rated { track: t, amps, need: rules.rail_width(amps, main), by_drop: false });+ }+ }+ // The drop to each load, along the copper that joins it to a source; where it is over the+ // limit, every track on the way widens by the same factor.+ let mut drops = Vec::new();+ if let Some(volts) = rail.volts.filter(|v| *v > 0.0) {+ let limit = volts * DROP_LIMIT;+ let mut parent: Vec<Option<usize>> = vec![None; n];+ let mut depth: Vec<usize> = vec![usize::MAX; n];+ let mut queue: std::collections::VecDeque<usize> = sources.iter().copied().collect();+ for &s in &sources {+ depth[s] = 0;+ }+ while let Some(k) = queue.pop_front() {+ for &m in &adj[k] {+ if depth[m] == usize::MAX {+ depth[m] = depth[k] + 1;+ parent[m] = Some(k);+ queue.push_back(m);+ }+ }+ }+ let t_cu = rules.copper_um * 1e-6;+ let (plate, drill) = (VIA_PLATING_UM * 1e-6, to_mm(rules.via_for(net).1) * 1e-3);+ let r_via = RHO_CU * VIA_LENGTH_MM * 1e-3 / (std::f64::consts::PI * (drill + plate) * plate);+ let mut loads: Vec<(&String, &f64)> = rail.loads.iter().filter(|(_, a)| **a > 0.0).collect();+ // Heaviest first, each seeing the widths the ones before it asked for: a pull-up at the end+ // of a trunk loses what the trunk loses, and is put right by the trunk, not by widening+ // its own thin branch.+ loads.sort_by(|x, y| y.1.total_cmp(x.1).then(x.0.cmp(y.0)));+ let mut eff: std::collections::HashMap<usize, i64> = Default::default();+ for (name, amps) in loads {+ let Some(end) = (0..n).filter(|&m| depth[m] != usize::MAX && reference(m) == Some(name.as_str())).min_by_key(|&m| depth[m]) else { continue };+ let (mut v_tracks, mut v_vias, mut v_drawn) = (0.0, 0.0, 0.0);+ let mut on: Vec<(usize, f64)> = Vec::new();+ let mut k = end;+ while let Some(p) = parent[k] {+ match &pieces[k] {+ Piece::Seg { a, b, r, .. } => {+ if let Some(t) = track_of[k] {+ let i = out.iter().find(|x| x.track == t).map(|x| x.amps).unwrap_or(*amps).max(*amps).min(rail.amps);+ let (len, w) = (hypot(sub(*a, *b)) * 1e-9, eff.get(&t).map(|w| *w as f64).unwrap_or(2.0 * r) * 1e-9);+ if w > 0.0 {+ v_drawn += i * RHO_CU * len / (2.0 * r * 1e-9 * t_cu);+ v_tracks += i * RHO_CU * len / (w * t_cu);+ on.push((t, i));+ }+ }+ }+ // Vias of the net side by side share the current.+ Piece::Via { at, .. } => {+ let group = board.vias.iter().filter(|v| v.net == net && hypot(sub(V::from(v.at), *at)) <= 1.5 * NM).count().max(1);+ v_vias += amps * r_via / group as f64;+ }+ _ => {}+ }+ k = p;+ }+ let total = v_tracks + v_vias;+ drops.push(Drop { load: name.clone(), amps: *amps, volts: v_drawn + v_vias, limit });+ if total > limit && v_tracks > 0.0 && limit > v_vias {+ // Only the stretches that matter widen: one that loses under a twentieth of the+ // path's drop (a thin branch carrying milliamps at the end of a trunk) stays.+ let lost = |t: usize, i: f64| -> f64 {+ let tr = &board.tracks[t];+ let w = eff.get(&t).copied().unwrap_or(tr.width) as f64 * 1e-9;+ i * RHO_CU * hypot(sub(tr.a.into(), tr.b.into())) * 1e-9 / (w * t_cu)+ };+ let (big, small): (Vec<(usize, f64)>, Vec<(usize, f64)>) = on.iter().copied().partition(|&(t, i)| lost(t, i) >= v_tracks / 20.0);+ let (v_big, v_small): (f64, f64) = (big.iter().map(|&(t, i)| lost(t, i)).sum(), small.iter().map(|&(t, i)| lost(t, i)).sum());+ let room = limit - v_vias - v_small;+ let (on, f) = if room > 0.0 && v_big > 0.0 { (big, v_big / room) } else { (on, v_tracks / (limit - v_vias)) };+ for (t, i) in on {+ let need = mm((to_mm(eff.get(&t).copied().unwrap_or(board.tracks[t].width)) * f * 100.0).ceil() / 100.0);+ eff.insert(t, need);+ match out.iter_mut().find(|x| x.track == t) {+ Some(x) => {+ if need > x.need {+ x.need = need;+ x.by_drop = true;+ }+ }+ None => out.push(Rated { track: t, amps: i, need, by_drop: true }),+ }+ }+ }+ }+ }+ (out, drops)+}++/// What pad `pad` itself draws from its rail, if its net has one and its part is listed.+pub fn pad_load(board: &Board, rules: &Rules, pad: usize) -> Option<f64> {+ let p = &board.pads[pad];+ let rail = rules.rail(board.net_name(p.net))?;+ let name = board.footprints[p.fp].reference.as_str();+ if rail.sources.iter().any(|s| s == name) {+ return Some(rail.amps);+ }+ Some(rail.loads.get(name).copied().unwrap_or(0.0).min(rail.amps))+}++/// What flows into each pad's branch of a rail: its own draw plus everything the rail feeds through+/// it. Parts are joined in the shortest tree from the sources (each part to the nearest one already+/// joined, as the router lays a rail's taps), and a part's branch carries its own load and the loads+/// of the parts hanging off it. A decoupling capacitor draws nothing itself, but the tap that reaches+/// it first carries its IC too: sized by the capacitor's own 0 A, benchmark C's +5V ran 33.6 mm at+/// 0.20 mm to U10, which draws 1.24 A (2026-11-06 to 11-09). Never more than the rail's current.+/// Empty when no source part of the rail is on the board.+pub fn branch_amps(board: &Board, net: NetId, rail: &RailSpec) -> std::collections::HashMap<usize, f64> {+ let mut parts: Vec<(usize, Vec<usize>)> = Vec::new();+ for (i, p) in board.pads.iter().enumerate().filter(|(_, p)| p.net == net && !p.layers.is_empty()) {+ match parts.iter_mut().find(|(fp, _)| *fp == p.fp) {+ Some((_, pads)) => pads.push(i),+ None => parts.push((p.fp, vec![i])),+ }+ }+ let n = parts.len();+ let name = |k: usize| board.footprints[parts[k].0].reference.as_str();+ let is_source = |k: usize| rail.sources.iter().any(|s| s == name(k));+ let mut out = std::collections::HashMap::new();+ if !(0..n).any(is_source) {+ return out;+ }+ let apart = |a: usize, b: usize| -> f64 {+ let mut best = f64::INFINITY;+ for &x in &parts[a].1 {+ for &y in &parts[b].1 {+ best = best.min(hypot(sub(board.pads[x].center.into(), board.pads[y].center.into())));+ }+ }+ best+ };+ let mut parent: Vec<Option<usize>> = vec![None; n];+ let mut joined: Vec<bool> = (0..n).map(is_source).collect();+ let mut order: Vec<usize> = Vec::new();+ loop {+ let mut best: Option<(f64, usize, usize)> = None;+ for k in (0..n).filter(|&k| !joined[k]) {+ for j in (0..n).filter(|&j| joined[j]) {+ let d = apart(k, j);+ if best.map(|b| d < b.0).unwrap_or(true) {+ best = Some((d, k, j));+ }+ }+ }+ let Some((_, k, j)) = best else { break };+ joined[k] = true;+ parent[k] = Some(j);+ order.push(k);+ }+ let mut amps: Vec<f64> = (0..n).map(|k| if is_source(k) { 0.0 } else { rail.loads.get(name(k)).copied().unwrap_or(0.0) }).collect();+ for &k in order.iter().rev() {+ if let Some(p) = parent[k] {+ amps[p] += amps[k];+ }+ }+ for k in 0..n {+ let a = if is_source(k) { rail.amps } else { amps[k].min(rail.amps) };+ for &p in &parts[k].1 {+ out.insert(p, a);+ }+ }+ out+}
src/fixtures.rsadded+207@@ -0,0 +1,207 @@+//! Fast tests cut from the reference boards: a few nets of a reference board routed alone, in seconds, with+//! pass/fail checks on the result. The whole-board benchmark takes 13 minutes on benchmark C and says+//! only how many joins were made; a fixture says whether one behaviour is right (the feed to U10+//! holds its width, the trunk does not run wide to a part that draws 3 mA) and says it at once.+//!+//! A fixture strips the board, keeps every pad and pour as it is, and routes only the nets it+//! names. The other nets stay unrouted, so it tests a behaviour, not congestion: the whole-board+//! benchmark is still what says the board routes.+//!+//! `known_fail` marks a fixture that records a target the router does not meet yet. It is reported+//! and does not fail the run; when it starts passing the run says so, and the mark comes off.++use crate::board::*;+use crate::geom::*;+use serde_json::Value;+use std::path::Path;++struct Outcome {+ pass: bool,+ lines: Vec<String>,+}++/// Every fixture at once, each in its own process (each has its own work clock, so running side by+/// side changes no result), reported in the file's order. The set takes as long as its slowest one.+pub fn run_all(file: &str, only: Option<&str>, keep: Option<&str>) -> bool {+ let text = std::fs::read_to_string(file).unwrap_or_else(|e| crate::fail(format!("{file}: {e}"), "Pass the fixtures JSON, e.g. bench/fixtures.json."));+ let v: Value = serde_json::from_str(&text).unwrap_or_else(|e| crate::fail(format!("{file}: {e}"), ""));+ let names: Vec<String> = v["fixtures"].as_array().map(|a| a.iter().filter_map(|x| x["name"].as_str().map(String::from)).collect()).unwrap_or_default();+ let names: Vec<String> = names.into_iter().filter(|n| only.map(|o| n.contains(o)).unwrap_or(true)).collect();+ let exe = std::env::current_exe().unwrap_or_else(|e| crate::fail(e, ""));+ let t0 = std::time::Instant::now();+ let kids: Vec<(String, std::process::Child)> = names.iter().map(|n| {+ let mut c = std::process::Command::new(&exe);+ c.args(["fixtures", file, "--one", n]);+ if let Some(k) = keep {+ c.args(["--keep", k]);+ }+ (n.clone(), c.stdout(std::process::Stdio::piped()).spawn().unwrap_or_else(|e| crate::fail(e, "")))+ }).collect();+ let (mut ok, mut skipped) = (true, 0usize);+ for (n, kid) in kids {+ let out = kid.wait_with_output().unwrap_or_else(|e| crate::fail(e, ""));+ let text = String::from_utf8_lossy(&out.stdout);+ for l in text.lines().filter(|l| !l.starts_with("OK: ") && !l.starts_with("ERROR: ")) {+ println!("{l}");+ }+ if text.contains("SKIP ") {+ skipped += 1;+ }+ if !out.status.success() {+ ok = false;+ if text.trim().is_empty() {+ println!("FAIL {n}: it stopped without a result");+ }+ }+ }+ println!("OK: {} fixtures run in {:.0} s, {skipped} skipped{}", names.len() - skipped, t0.elapsed().as_secs_f64(), if ok { "." } else { "; at least one FAIL above." });+ ok+}++pub fn run(file: &str, only: Option<&str>, keep: Option<&str>) -> bool {+ let text = std::fs::read_to_string(file).unwrap_or_else(|e| crate::fail(format!("{file}: {e}"), "Pass the fixtures JSON, e.g. bench/fixtures.json."));+ let v: Value = serde_json::from_str(&text).unwrap_or_else(|e| crate::fail(format!("{file}: {e}"), ""));+ let base = Path::new(file).parent().unwrap_or(Path::new("."));+ let work = keep.map(std::path::PathBuf::from).unwrap_or_else(|| std::env::temp_dir().join("adom-route-fixtures"));+ std::fs::create_dir_all(&work).unwrap_or_else(|e| crate::fail(e, ""));+ let (mut ok, mut ran, mut skipped) = (true, 0usize, 0usize);+ for fx in v["fixtures"].as_array().cloned().unwrap_or_default() {+ let name = fx["name"].as_str().unwrap_or("?").to_string();+ if only.map(|o| name != o).unwrap_or(false) {+ continue;+ }+ let board = base.join(fx["board"].as_str().unwrap_or(""));+ if !board.exists() {+ println!("SKIP {name}: its board is not here ({})", board.display());+ skipped += 1;+ continue;+ }+ let t0 = std::time::Instant::now();+ let out = one(&fx, &name, &board, base, &work);+ let known = fx["known_fail"].as_bool().unwrap_or(false);+ let secs = t0.elapsed().as_secs_f64();+ ran += 1;+ match (out.pass, known) {+ (true, false) => println!("PASS {name} ({secs:.1} s)"),+ (true, true) => println!("FIXED {name} ({secs:.1} s): it passes now, take its known_fail mark off"),+ (false, true) => println!("KNOWN FAIL {name} ({secs:.1} s): a target the router does not meet yet"),+ (false, false) => {+ println!("FAIL {name} ({secs:.1} s)");+ ok = false;+ }+ }+ for l in &out.lines {+ println!(" {l}");+ }+ }+ println!("{} {ran} fixtures run, {skipped} skipped.", if ok { "OK:" } else { "ERROR:" });+ ok+}++fn one(fx: &Value, name: &str, board: &Path, base: &Path, work: &Path) -> Outcome {+ let mut lines: Vec<String> = Vec::new();+ let spec: Option<String> = fx["spec"].as_str().map(|s| base.join(s).to_string_lossy().to_string());+ let nets: Vec<String> = fx["nets"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(String::from)).collect()).unwrap_or_default();+ // Strip, with the project beside it so the same rules bind.+ let src = Board::read(&board.to_string_lossy()).unwrap_or_else(|e| crate::fail(e, ""));+ let stripped = work.join(format!("{name}.stripped.kicad_pcb"));+ let routed = work.join(format!("{name}.routed.kicad_pcb"));+ std::fs::write(&stripped, crate::write::strip(&src)).unwrap_or_else(|e| crate::fail(e, ""));+ let pro = board.with_extension("kicad_pro");+ if pro.exists() {+ for p in [&stripped, &routed] {+ let _ = std::fs::copy(&pro, p.with_extension("kicad_pro"));+ }+ }+ let (b, mut r) = crate::load(&stripped.to_string_lossy(), &spec);+ let missing: Vec<&String> = nets.iter().filter(|n| !b.net_ids.contains_key(n.as_str())).collect();+ if !missing.is_empty() {+ return Outcome { pass: false, lines: vec![format!("not on the board: {missing:?}")] };+ }+ let mut plan = crate::router::make_plan(&b, &r);+ let wanted = |n: NetId| nets.iter().any(|x| x == b.net_name(n));+ plan.tasks.retain(|t| wanted(t.net));+ plan.pairs.retain(|p| wanted(p.0) && wanted(p.1));+ r.time_limit = fx["limit"].as_f64().unwrap_or(60.0);+ let mut rt = crate::router::Router::new(&b, r.clone());+ rt.only = Some(nets.iter().filter_map(|n| b.net_ids.get(n.as_str()).copied()).collect());+ let o = rt.run(&plan);+ let mut routes: Vec<&crate::router::Route> = rt.routes.values().collect();+ routes.sort_by_key(|x| x.id);+ std::fs::write(&routed, crate::write::render(&b, &routes, &r)).unwrap_or_else(|e| crate::fail(e, ""));+ let _ = crate::project::write_dru(&stripped.to_string_lossy(), &routed.to_string_lossy(), &rt.rules.pad_rules);+ let _ = o;+ let _ = std::fs::write(work.join(format!("{name}.log")), rt.log.join("\n") + "\n" + &rt.notes.join("\n"));+ lines.push(format!("board kept at {}", routed.display()));+ // Judge the written board, as `check` would.+ let (rb, rr) = crate::load(&routed.to_string_lossy(), &spec);+ let (findings, _) = crate::check::check(&rb, &rr);+ let mine = |text: &str| nets.iter().any(|n| text.starts_with(&format!("{n}:")));+ let mut pass = true;+ let mut say = |ok: bool, what: String, lines: &mut Vec<String>| {+ lines.push(format!("{} {what}", if ok { "ok " } else { "FAIL" }));+ if !ok {+ pass = false;+ }+ };+ for e in fx["expect"].as_array().cloned().unwrap_or_default() {+ if e.get("joined").is_some() {+ let open: Vec<&str> = findings.iter().filter(|f| f.rule == "unconnected" && mine(&f.text)).map(|f| f.text.as_str()).collect();+ say(open.is_empty(), format!("joined: every named net is one piece{}", if open.is_empty() { String::new() } else { format!(" ({})", open.join("; ")) }), &mut lines);+ }+ if let Some(rules) = e.get("clean").and_then(|x| x.as_array()) {+ for rule in rules.iter().filter_map(|x| x.as_str()) {+ let hits: Vec<&str> = findings.iter().filter(|f| f.rule == rule && mine(&f.text)).map(|f| f.text.as_str()).collect();+ say(hits.is_empty(), format!("clean {rule}{}", if hits.is_empty() { String::new() } else { format!(": {}", hits.join("; ")) }), &mut lines);+ }+ }+ if let Some(w) = e.get("width_near") {+ let (net, part) = (w["net"].as_str().unwrap_or(""), w["part"].as_str().unwrap_or(""));+ let radius = w["radius"].as_f64().unwrap_or(3.0) * NM;+ let id = rb.net_ids.get(net).copied();+ let pads: Vec<V> = rb.pads.iter().filter(|p| Some(p.net) == id && rb.footprints[p.fp].reference == part).map(|p| V::from(p.center)).collect();+ // Runs, not links: a track shorter than `min_len` (2 mm unless given) is a tie between a+ // pad and its via or capacitor, not the run to the part that this check is about.+ let min_len = w["min_len"].as_f64().unwrap_or(2.0) * NM;+ let near: Vec<i64> = rb.tracks.iter().filter(|t| Some(t.net) == id && hypot(sub(t.a.into(), t.b.into())) >= min_len && pads.iter().any(|c| pt_seg(*c, t.a.into(), t.b.into()) <= radius)).map(|t| t.width).collect();+ if near.is_empty() {+ say(false, format!("width near {part} on {net}: no track of the net within {:.1} mm of it", radius / NM), &mut lines);+ continue;+ }+ let (lo, hi) = (to_mm(*near.iter().min().unwrap()), to_mm(*near.iter().max().unwrap()));+ if let Some(m) = w["max"].as_f64() {+ say(hi <= m + 1e-6, format!("width near {part} on {net}: widest {hi:.2} mm, at most {m:.2} mm allowed"), &mut lines);+ }+ if let Some(m) = w["min"].as_f64() {+ say(lo >= m - 1e-6, format!("width near {part} on {net}: narrowest {lo:.2} mm, at least {m:.2} mm wanted"), &mut lines);+ }+ }+ if let Some(d) = e.get("drop_max") {+ let (net, load, pct) = (d["net"].as_str().unwrap_or(""), d["load"].as_str().unwrap_or(""), d["pct"].as_f64().unwrap_or(0.25));+ let found = rr.rails.iter().find(|(k, _)| k.eq_ignore_ascii_case(net)).and_then(|(_, rail)| {+ let id = rb.net_ids.iter().find(|(k, _)| k.eq_ignore_ascii_case(net)).map(|(_, v)| *v)?;+ let v = rail.volts?;+ crate::current::rail_drops(&rb, &rr, id, rail).into_iter().find(|x| x.load == load).map(|x| (x.volts, v))+ });+ match found {+ Some((volts, v)) => say(volts / v * 100.0 <= pct + 1e-6, format!("drop on {net} to {load}: {:.1} mV, {:.2}% of {v} V, at most {pct:.2}% allowed", volts * 1000.0, volts / v * 100.0), &mut lines),+ None => say(false, format!("drop on {net} to {load}: not found (no rail, voltage or path)"), &mut lines),+ }+ }+ if let Some(m) = e.get("max_vias").and_then(|x| x.as_u64()) {+ let n = rb.vias.iter().filter(|v| nets.iter().any(|x| x == rb.net_name(v.net))).count();+ say(n as u64 <= m, format!("vias on the named nets: {n}, at most {m} allowed"), &mut lines);+ }+ if let Some(p) = e.get("pair_coupled") {+ let (want, min) = (p["pair"].as_str().unwrap_or(""), p["min_pct"].as_f64().unwrap_or(80.0));+ match crate::compare::pairs(&rb, &rr).into_iter().find(|x| x.0 == want) {+ Some((_, lp, _, c, _)) => {+ let pct = if lp > 0.0 { 100.0 * c / lp } else { 0.0 };+ say(pct >= min, format!("pair {want}: {pct:.0}% coupled, at least {min:.0}% wanted"), &mut lines);+ }+ None => say(false, format!("pair {want}: not found on the routed board"), &mut lines),+ }+ }+ }+ Outcome { pass, lines }+}
src/flow.rsadded+143@@ -0,0 +1,143 @@+//! The routing stage as a guided flow. `flows/flow.json` (compiled in) holds the steps in order, the+//! file that shows each one was done, and what the AI does in it. `next` names the first step whose+//! file is missing. It is a guide and never a gate: no command checks that an earlier step was done,+//! and a step may be skipped or done another way.+//!+//! A project keeps its state in `<project>/routing-flow/`: `state.json` (the board and spec it was+//! started on), `run.jsonl` (one line per command: time, the AI thread, what happened) and the files+//! the steps leave. Earlier ledger lines are never rewritten.+use serde_json::{json, Value};+use std::io::Write;+use std::path::{Path, PathBuf};++pub const FLOW_DIR: &str = "routing-flow";++pub struct Step {+ pub name: String,+ pub done: Vec<String>,+ pub todo: String,+}++pub fn steps() -> Vec<Step> {+ let v: Value = serde_json::from_str(include_str!("../flows/flow.json")).expect("flows/flow.json is valid JSON");+ v["steps"]+ .as_array()+ .map(|a| {+ a.iter()+ .map(|s| Step {+ name: s["name"].as_str().unwrap_or("").to_string(),+ done: s["done"].as_array().map(|d| d.iter().filter_map(|x| x.as_str().map(String::from)).collect()).unwrap_or_default(),+ todo: s["do"].as_str().unwrap_or("").to_string(),+ })+ .collect()+ })+ .unwrap_or_default()+}++pub struct Project {+ pub dir: PathBuf,+ pub flow_dir: PathBuf,+ pub state: Value,+}++impl Project {+ /// The nearest folder at or above `start` that holds `routing-flow/state.json`.+ pub fn find(start: &Path) -> Option<Project> {+ let mut d = std::fs::canonicalize(start).ok().or_else(|| Some(start.to_path_buf()));+ while let Some(p) = d {+ let s = p.join(FLOW_DIR).join("state.json");+ if s.is_file() {+ let state = std::fs::read_to_string(&s).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(json!({}));+ return Some(Project { flow_dir: p.join(FLOW_DIR), dir: p, state });+ }+ d = p.parent().map(Path::to_path_buf);+ }+ None+ }++ pub fn create(dir: &Path, state: Value) -> std::io::Result<Project> {+ let flow_dir = dir.join(FLOW_DIR);+ std::fs::create_dir_all(&flow_dir)?;+ let p = Project { dir: dir.to_path_buf(), flow_dir, state };+ std::fs::write(p.flow_dir.join("state.json"), serde_json::to_string_pretty(&p.state)? + "\n")?;+ Ok(p)+ }++ pub fn path(&self, rel: &str) -> PathBuf {+ self.flow_dir.join(rel)+ }++ /// One line in the ledger. A ledger that cannot be written never stops a command.+ pub fn log(&self, thread: &str, ev: &str, data: Value) {+ let line = json!({"t": now(), "aiThread": thread, "ev": ev, "data": data});+ if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(self.flow_dir.join("run.jsonl")) {+ let _ = writeln!(f, "{line}");+ }+ }++ pub fn is_done(&self, step: &Step) -> bool {+ step.done.iter().all(|p| self.flow_dir.join(p).exists())+ }++ /// The first step whose file is missing, with its place in the list (1-based).+ pub fn current<'a>(&self, steps: &'a [Step]) -> Option<(usize, &'a Step)> {+ steps.iter().enumerate().find(|(_, s)| !self.is_done(s)).map(|(i, s)| (i + 1, s))+ }+}++/// UTC now as 2026-11-10T12:00:00Z, from the system clock (no outside program).+pub fn now() -> String {+ let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0) as i64;+ let (days, rem) = (secs.div_euclid(86_400), secs.rem_euclid(86_400));+ // Days since 1970-01-01 to a civil date (Howard Hinnant's algorithm).+ let z = days + 719_468;+ let era = z.div_euclid(146_097);+ let doe = z.rem_euclid(146_097);+ let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;+ let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);+ let mp = (5 * doy + 2) / 153;+ let d = doy - (153 * mp + 2) / 5 + 1;+ let m = if mp < 10 { mp + 3 } else { mp - 9 };+ let y = yoe + era * 400 + if m <= 2 { 1 } else { 0 };+ format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z", rem / 3_600, rem % 3_600 / 60, rem % 60)+}++/// The file's sha256 where `sha256sum` is installed; empty otherwise (it is a record, not a lock).+pub fn sha256(p: &Path) -> String {+ std::process::Command::new("sha256sum")+ .arg(p)+ .output()+ .ok()+ .and_then(|o| String::from_utf8_lossy(&o.stdout).split_whitespace().next().map(str::to_string))+ .unwrap_or_default()+}++#[cfg(test)]+mod tests {+ use super::*;++ #[test]+ fn the_flow_file_reads_and_next_is_the_first_step_with_no_file() {+ let s = steps();+ assert!(s.len() >= 4 && s[0].name == "intake" && s.iter().all(|x| !x.done.is_empty() && !x.todo.is_empty()));+ let d = std::env::temp_dir().join(format!("adom-route-flow-{}", std::process::id()));+ let _ = std::fs::remove_dir_all(&d);+ let p = Project::create(&d, json!({"board": "b.kicad_pcb"})).unwrap();+ assert_eq!(p.current(&s).map(|(i, x)| (i, x.name.as_str())), Some((2, "plan")));+ std::fs::write(p.path("plan.txt"), "x").unwrap();+ assert_eq!(p.current(&s).map(|(_, x)| x.name.as_str()), Some("route"));+ // A later step done first is fine: next still names the first one missing, and nothing refuses.+ std::fs::write(p.path("check.json"), "{}").unwrap();+ assert_eq!(p.current(&s).map(|(_, x)| x.name.as_str()), Some("route"));+ p.log("t", "test", json!({}));+ assert!(std::fs::read_to_string(p.path("run.jsonl")).unwrap().contains("\"ev\":\"test\""));+ assert!(Project::find(&d.join(FLOW_DIR)).is_some());+ let _ = std::fs::remove_dir_all(&d);+ }++ #[test]+ fn now_is_a_utc_stamp() {+ let n = now();+ assert!(n.len() == 20 && n.ends_with('Z') && n.as_bytes()[10] == b'T', "{n}");+ }+}
src/geom.rsadded+211@@ -0,0 +1,211 @@+//! Exact geometry. Coordinates are integer nanometres on the board; distances are f64 nanometres.++pub type P = (i64, i64);+pub const NM: f64 = 1e6; // nanometres per millimetre+pub const SQRT2: f64 = std::f64::consts::SQRT_2;+/// How far under a clearance copper may land by coordinate rounding alone, nm. KiCad tolerates about+/// 500 nm (it flagged 0.1991 against 0.2 on the benchmark C). His benchmark C's 0.2 mm stub down the middle of U5.9+/// sits exactly 0.200 mm from both neighbours; without this, a few nm of rounding called it illegal.+pub const DRC_TOL: f64 = 400.0;++pub fn mm(v: f64) -> i64 {+ (v * NM).round() as i64+}+pub fn to_mm(v: i64) -> f64 {+ v as f64 / NM+}++#[derive(Clone, Copy, Debug, PartialEq)]+pub struct V(pub f64, pub f64);++impl From<P> for V {+ fn from(p: P) -> V {+ V(p.0 as f64, p.1 as f64)+ }+}++#[inline]+pub fn dot(a: V, b: V) -> f64 {+ a.0 * b.0 + a.1 * b.1+}+#[inline]+pub fn sub(a: V, b: V) -> V {+ V(a.0 - b.0, a.1 - b.1)+}+#[inline]+pub fn hypot(a: V) -> f64 {+ (a.0 * a.0 + a.1 * a.1).sqrt()+}++pub fn pt_seg(p: V, a: V, b: V) -> f64 {+ let ab = sub(b, a);+ let l2 = dot(ab, ab);+ if l2 <= 0.0 {+ return hypot(sub(p, a));+ }+ let t = (dot(sub(p, a), ab) / l2).clamp(0.0, 1.0);+ hypot(sub(p, V(a.0 + ab.0 * t, a.1 + ab.1 * t)))+}++fn orient(a: V, b: V, c: V) -> f64 {+ (b.0 - a.0) * (c.1 - a.1) - (b.1 - a.1) * (c.0 - a.0)+}++pub fn seg_cross(a: V, b: V, c: V, d: V) -> bool {+ let (o1, o2, o3, o4) = (orient(a, b, c), orient(a, b, d), orient(c, d, a), orient(c, d, b));+ (o1 > 0.0) != (o2 > 0.0) && (o3 > 0.0) != (o4 > 0.0)+}++pub fn seg_seg(a: V, b: V, c: V, d: V) -> f64 {+ if seg_cross(a, b, c, d) {+ return 0.0;+ }+ pt_seg(a, c, d).min(pt_seg(b, c, d)).min(pt_seg(c, a, b)).min(pt_seg(d, a, b))+}++pub fn in_poly(p: V, pts: &[V]) -> bool {+ let mut inside = false;+ let n = pts.len();+ let mut j = n.wrapping_sub(1);+ for i in 0..n {+ let (a, b) = (pts[i], pts[j]);+ if (a.1 > p.1) != (b.1 > p.1) && p.0 < (b.0 - a.0) * (p.1 - a.1) / (b.1 - a.1) + a.0 {+ inside = !inside;+ }+ j = i;+ }+ inside+}++/// Copper or a hole, as an exact outline. `Poly` carries an outward inflation (zero for most).+#[derive(Clone, Debug)]+pub enum Shape {+ Circle { c: V, r: f64 },+ Capsule { a: V, b: V, r: f64 },+ Poly { pts: Vec<V>, r: f64 },+ /// Several outlines that are one piece of copper: a custom pad's anchor and its primitives.+ Multi(Vec<Shape>),+}++impl Shape {+ /// Edge-to-edge distance from a zero-width segment to this outline; 0 when they touch.+ pub fn dist_seg(&self, a: V, b: V) -> f64 {+ match self {+ Shape::Circle { c, r } => (pt_seg(*c, a, b) - r).max(0.0),+ Shape::Capsule { a: p, b: q, r } => (seg_seg(a, b, *p, *q) - r).max(0.0),+ Shape::Poly { pts, r } => {+ if in_poly(a, pts) || in_poly(b, pts) {+ return 0.0;+ }+ let n = pts.len();+ let mut d = f64::INFINITY;+ for i in 0..n {+ d = d.min(seg_seg(a, b, pts[i], pts[(i + 1) % n]));+ if d <= 0.0 {+ return 0.0;+ }+ }+ (d - r).max(0.0)+ }+ Shape::Multi(v) => v.iter().map(|s| s.dist_seg(a, b)).fold(f64::INFINITY, f64::min),+ }+ }+ pub fn dist_pt(&self, p: V) -> f64 {+ self.dist_seg(p, p)+ }+ /// Edge-to-edge distance between two outlines.+ pub fn dist_shape(&self, o: &Shape) -> f64 {+ match o {+ Shape::Circle { c, r } => (self.dist_pt(*c) - r).max(0.0),+ Shape::Capsule { a, b, r } => (self.dist_seg(*a, *b) - r).max(0.0),+ Shape::Poly { pts, r } => match self {+ Shape::Poly { pts: mine, r: mr } => {+ if pts.iter().any(|p| in_poly(*p, mine)) || mine.iter().any(|p| in_poly(*p, pts)) {+ return 0.0;+ }+ let mut d = f64::INFINITY;+ let n = pts.len();+ let m = mine.len();+ for i in 0..n {+ for j in 0..m {+ d = d.min(seg_seg(pts[i], pts[(i + 1) % n], mine[j], mine[(j + 1) % m]));+ }+ }+ (d - r - mr).max(0.0)+ }+ _ => o.dist_shape(self),+ },+ Shape::Multi(v) => v.iter().map(|s| self.dist_shape(s)).fold(f64::INFINITY, f64::min),+ }+ }+ pub fn bbox(&self) -> (f64, f64, f64, f64) {+ match self {+ Shape::Circle { c, r } => (c.0 - r, c.1 - r, c.0 + r, c.1 + r),+ Shape::Capsule { a, b, r } => (a.0.min(b.0) - r, a.1.min(b.1) - r, a.0.max(b.0) + r, a.1.max(b.1) + r),+ Shape::Poly { pts, r } => {+ let mut bb = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);+ for p in pts {+ bb = (bb.0.min(p.0), bb.1.min(p.1), bb.2.max(p.0), bb.3.max(p.1));+ }+ (bb.0 - r, bb.1 - r, bb.2 + r, bb.3 + r)+ }+ Shape::Multi(v) => {+ let mut bb = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);+ for s in v {+ let b = s.bbox();+ bb = (bb.0.min(b.0), bb.1.min(b.1), bb.2.max(b.2), bb.3.max(b.3));+ }+ bb+ }+ }+ }+ /// Largest value of `ux*x + uy*y` over the outline, for tangent lines in any direction.+ pub fn support(&self, ux: f64, uy: f64) -> f64 {+ let len = (ux * ux + uy * uy).sqrt();+ match self {+ Shape::Circle { c, r } => ux * c.0 + uy * c.1 + r * len,+ Shape::Capsule { a, b, r } => (ux * a.0 + uy * a.1).max(ux * b.0 + uy * b.1) + r * len,+ Shape::Poly { pts, r } => pts.iter().map(|p| ux * p.0 + uy * p.1).fold(f64::MIN, f64::max) + r * len,+ Shape::Multi(v) => v.iter().map(|s| s.support(ux, uy)).fold(f64::MIN, f64::max),+ }+ }+ pub fn center(&self) -> V {+ let b = self.bbox();+ V((b.0 + b.2) / 2.0, (b.1 + b.3) / 2.0)+ }+}++/// Rotate a local point the way KiCad does (y down, positive angle counter-clockwise on screen).+pub fn rot(x: f64, y: f64, deg: f64) -> (f64, f64) {+ if deg == 0.0 {+ return (x, y);+ }+ let (s, c) = deg.to_radians().sin_cos();+ (x * c + y * s, -x * s + y * c)+}++pub fn arc_points(s: V, m: V, e: V, step_nm: f64) -> Vec<V> {+ let d = 2.0 * (s.0 * (m.1 - e.1) + m.0 * (e.1 - s.1) + e.0 * (s.1 - m.1));+ if d.abs() < 1.0 {+ return vec![s, e];+ }+ let sq = |p: V| p.0 * p.0 + p.1 * p.1;+ let ux = (sq(s) * (m.1 - e.1) + sq(m) * (e.1 - s.1) + sq(e) * (s.1 - m.1)) / d;+ let uy = (sq(s) * (e.0 - m.0) + sq(m) * (s.0 - e.0) + sq(e) * (m.0 - s.0)) / d;+ let r = ((s.0 - ux).powi(2) + (s.1 - uy).powi(2)).sqrt();+ let a0 = (s.1 - uy).atan2(s.0 - ux);+ let am = (m.1 - uy).atan2(m.0 - ux);+ let a1 = (e.1 - uy).atan2(e.0 - ux);+ let tau = std::f64::consts::TAU;+ let norm = |x: f64| ((x % tau) + tau) % tau;+ let ccw_m = norm(am - a0);+ let ccw_e = norm(a1 - a0);+ let sweep = if ccw_m <= ccw_e { ccw_e } else { ccw_e - tau };+ let n = ((sweep.abs() * r / step_nm).ceil() as usize).clamp(2, 64);+ (0..=n)+ .map(|i| {+ let a = a0 + sweep * i as f64 / n as f64;+ V(ux + r * a.cos(), uy + r * a.sin())+ })+ .collect()+}
src/global.rsadded+1444@@ -0,0 +1,1444 @@+//! The coarse view of the board: what a detailed search can't see from where it stands.+//!+//! Two jobs share one map. First, a backward search from the target over coarse cells on each+//! routing layer, with vias as edges (VTR's router lookahead, Navit's search from the destination),+//! resumed only as far as the detailed search asks (reverse resumable A*). That gives the detailed+//! A* the true way round obstacles, and a quick "no path" when the map is closed. Second, the global+//! stage further down, which uses the same boundaries as capacities.+//!+//! The map must stay optimistic: "closed" is only ever a proof. A boundary between two cells is cut+//! into short samples; a sample is closed only when one obstacle is closer to its middle than the+//! narrowest legal centreline allows, by more than half the sample's length, so no point of that+//! sample could carry a legal track. A fine-pitch lane therefore can't be closed by rounding: The designer's+//! rule that a router's "no path" at fine pitch is not evidence (Troubleshooting, "Routing practices")+//! is why the map has no slop. Copper of the query's own net never closes anything.++use crate::board::*;+use crate::geom::*;+use crate::index::*;+use crate::rules::Rules;+use crate::search::{Query, Terminal, World};+use std::cmp::Reverse;+use std::collections::BinaryHeap;++/// A sample closed by copper of more than one net, or by something with no net.+const MANY: u16 = u16::MAX;+/// Samples per boundary. Must fit the u16 masks.+const K: usize = 5;+const FULL: u16 = (1u16 << K) - 1;+/// A track's keep-out at the neck is about 0.6 mm across, or 0.85 mm along an axis at 45 degrees,+/// so at this size no boundary line can slip through a wall of tracks at any angle.+const CELL: f64 = 0.25 * NM;+const VIA_SUB: usize = 2;+/// How much dearer the estimate makes a step off the planned corridor. Swept 0.3 and 1.0: the same+/// completion on all three boards within one connection, fewer bends at 0.3 (piprobe_v13 66 to 72).+const CORRIDOR_BIAS: f32 = 0.3;+/// What the coarse estimate charges a soft search per crossing of committed copper (the router's+/// cheapest price for taking a route's place).+const SOFT_CROSSING: f64 = 2.0 * NM;++#[inline]+fn net16(n: NetId) -> u16 {+ if n == NO_NET { MANY } else { n.min(MANY as u32 - 1) as u16 }+}++pub struct Coarse {+ pub x0: f64,+ pub y0: f64,+ pub cell: f64,+ s: f64,+ pub w: usize,+ pub h: usize,+ /// World layer of each coarse layer, and back.+ pub route: Vec<usize>,+ index: Vec<usize>,+ /// Narrowest legal centreline's distance to copper, and to the board edge.+ need: f64,+ need_edge: f64,+ /// Closed samples per boundary, as a bit mask: copper that stays, and committed routes.+ mask_s: Vec<u16>,+ mask_d: Vec<u16>,+ /// Who closes each sample: 0 nobody, otherwise one net, or MANY.+ blk_s: Vec<u16>,+ blk_d: Vec<u16>,+ /// Bumped when committed copper changes, and when anything else does.+ pub rev_d: u64,+ pub rev_s: u64,+}++impl Coarse {+ pub fn build(w: &World, r: &Rules) -> Coarse {+ let mut bb = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);+ let mut grow = |b: (f64, f64, f64, f64)| {+ bb = (bb.0.min(b.0), bb.1.min(b.1), bb.2.max(b.2), bb.3.max(b.3));+ };+ for o in w.edge.iter() {+ grow(o.bb);+ }+ for g in &w.layers {+ for o in g.iter() {+ if matches!(o.owner, Owner::Pad(_)) {+ grow(o.bb);+ }+ }+ }+ let cell = CELL;+ let s = cell / K as f64;+ let x0 = bb.0 - 2.0 * cell;+ let y0 = bb.1 - 2.0 * cell;+ let cw = ((bb.2 - bb.0) / cell).ceil() as usize + 4;+ let ch = ((bb.3 - bb.1) / cell).ceil() as usize + 4;+ let route = w.route_layers.clone();+ let mut index = vec![usize::MAX; w.layers.len()];+ for (i, &l) in route.iter().enumerate() {+ index[l] = i;+ }+ let n = route.len() * 2 * cw * ch;+ let mut c = Coarse {+ x0,+ y0,+ cell,+ s,+ w: cw,+ h: ch,+ route,+ index,+ need: r.neck as f64 / 2.0 + r.clearance as f64,+ need_edge: r.neck as f64 / 2.0 + r.edge as f64,+ mask_s: vec![0; n],+ mask_d: vec![0; n],+ blk_s: vec![0; n * K],+ blk_d: vec![0; n * K],+ rev_d: 1,+ rev_s: 1,+ };+ for l in 0..c.route.len() {+ let g = &w.layers[c.route[l]];+ g.drain_log();+ for o in g.iter() {+ // "Closed" is a proof, and a pour some nets may cross proves nothing about them.+ if let Owner::Zone(z) = o.owner {+ if w.pour_open.get(z as usize).map(|v| !v.is_empty()).unwrap_or(false) {+ continue;+ }+ }+ c.mark(l, &o.shape, o.net, o.owner, c.need);+ }+ for o in w.edge.iter() {+ c.mark(l, &o.shape, NO_NET, Owner::Edge, c.need_edge);+ }+ }+ c+ }++ /// Bring the map up to date with what was committed and ripped since last time.+ pub fn sync(&mut self, w: &World) {+ for l in 0..self.route.len() {+ let log = w.layers[self.route[l]].drain_log();+ for (added, o) in log {+ if added {+ self.mark(l, &o.shape, o.net, o.owner, self.need);+ } else {+ self.unmark(w, l, &o.shape);+ }+ if matches!(o.owner, Owner::Conn(_)) {+ self.rev_d += 1;+ } else {+ self.rev_s += 1;+ self.rev_d += 1;+ }+ }+ }+ }++ #[inline]+ fn boundary(&self, l: usize, d: usize, i: usize, j: usize) -> usize {+ ((l * 2 + d) * self.h + j) * self.w + i+ }++ /// Visit the samples any obstacle piece could close. `d` 0: the boundary from (i,j) to+ /// (i+1,j); 1: from (i,j) to (i,j+1).+ fn near_samples(&self, bb: (f64, f64, f64, f64), mut f: impl FnMut(usize, usize, usize, usize, V)) {+ let (w, h, cell, s, x0, y0) = (self.w as i64, self.h as i64, self.cell, self.s, self.x0, self.y0);+ let lo = |v: f64, o: f64, st: f64| ((v - o) / st).ceil() as i64;+ let hi = |v: f64, o: f64, st: f64| ((v - o) / st).floor() as i64;+ let k = K as i64;+ // Vertical boundary lines x = x0 + (i+1) cell, samples along y.+ for line in lo(bb.0, x0, cell)..=hi(bb.2, x0, cell) {+ let i = line - 1;+ if i < 0 || i >= w {+ continue;+ }+ for n in (hi(bb.1, y0, s)).max(0)..=(hi(bb.3, y0, s)).min(h * k - 1) {+ let m = V(x0 + line as f64 * cell, y0 + (n as f64 + 0.5) * s);+ f(0, i as usize, (n / k) as usize, (n % k) as usize, m);+ }+ }+ for line in lo(bb.1, y0, cell)..=hi(bb.3, y0, cell) {+ let j = line - 1;+ if j < 0 || j >= h {+ continue;+ }+ for n in (hi(bb.0, x0, s)).max(0)..=(hi(bb.2, x0, s)).min(w * k - 1) {+ let m = V(x0 + (n as f64 + 0.5) * s, y0 + line as f64 * cell);+ f(1, (n / k) as usize, j as usize, (n % k) as usize, m);+ }+ }+ }++ /// Long diagonal tracks have huge boxes, so a capsule is walked in short pieces.+ fn pieces(&self, shape: &Shape, reach: f64) -> Vec<(f64, f64, f64, f64)> {+ if let Shape::Capsule { a, b, r } = shape {+ let len = hypot(sub(*a, *b));+ let n = (len / (2.0 * self.cell)).ceil().max(1.0) as usize;+ let rr = r + reach;+ return (0..n)+ .map(|i| {+ let (t0, t1) = (i as f64 / n as f64, (i + 1) as f64 / n as f64);+ let p = V(a.0 + (b.0 - a.0) * t0, a.1 + (b.1 - a.1) * t0);+ let q = V(a.0 + (b.0 - a.0) * t1, a.1 + (b.1 - a.1) * t1);+ (p.0.min(q.0) - rr, p.1.min(q.1) - rr, p.0.max(q.0) + rr, p.1.max(q.1) + rr)+ })+ .collect();+ }+ let b = shape.bbox();+ vec![(b.0 - reach, b.1 - reach, b.2 + reach, b.3 + reach)]+ }++ fn mark(&mut self, l: usize, shape: &Shape, net: NetId, owner: Owner, need: f64) {+ let half = self.s / 2.0;+ let who = net16(net);+ let dynamic = matches!(owner, Owner::Conn(_));+ let mut hits: Vec<(usize, usize)> = Vec::new();+ if let Shape::Poly { pts, r } = shape {+ // A pour can have thousands of points, and testing every sample in its box against all+ // of them took 10 s on benchmark B. Its inside comes from scanlines, its rim from its+ // edges as thin capsules.+ self.poly_inside(l, pts, &mut hits);+ let n = pts.len();+ for i in 0..n {+ let seg = Shape::Capsule { a: pts[i], b: pts[(i + 1) % n], r: *r };+ for bb in self.pieces(&seg, need) {+ self.near_samples(bb, |d, a, b, bit, m| {+ if seg.dist_pt(m) + half < need {+ hits.push((self.boundary(l, d, a, b), bit));+ }+ });+ }+ }+ } else {+ for bb in self.pieces(shape, need) {+ self.near_samples(bb, |d, i, j, bit, m| {+ if shape.dist_pt(m) + half < need {+ hits.push((self.boundary(l, d, i, j), bit));+ }+ });+ }+ }+ for (b, bit) in hits {+ let (blk, mask) = if dynamic { (&mut self.blk_d, &mut self.mask_d) } else { (&mut self.blk_s, &mut self.mask_s) };+ let v = &mut blk[b * K + bit];+ *v = if *v == 0 || *v == who { who } else { MANY };+ mask[b] |= 1 << bit;+ }+ }++ /// Samples strictly inside a polygon, boundary line by boundary line.+ fn poly_inside(&self, l: usize, pts: &[V], hits: &mut Vec<(usize, usize)>) {+ let n = pts.len();+ let k = K as i64;+ let mut xs: Vec<f64> = Vec::new();+ let mut bb = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);+ for p in pts {+ bb = (bb.0.min(p.0), bb.1.min(p.1), bb.2.max(p.0), bb.3.max(p.1));+ }+ // d 0: lines of constant x, walking y; d 1: the other way round.+ for d in 0..2usize {+ let (lo, hi, o, t_o, t_n, l_n) = if d == 0 { (bb.0, bb.2, self.x0, self.y0, self.h as i64, self.w as i64) } else { (bb.1, bb.3, self.y0, self.x0, self.w as i64, self.h as i64) };+ let first = ((lo - o) / self.cell).ceil() as i64;+ let last = ((hi - o) / self.cell).floor() as i64;+ for line in first..=last {+ let idx = line - 1;+ if idx < 0 || idx >= l_n {+ continue;+ }+ let at = o + line as f64 * self.cell;+ xs.clear();+ for i in 0..n {+ let (a, b) = (pts[i], pts[(i + 1) % n]);+ let (ax, ay, bx, by) = if d == 0 { (a.0, a.1, b.0, b.1) } else { (a.1, a.0, b.1, b.0) };+ if (ax > at) != (bx > at) {+ xs.push(ay + (at - ax) / (bx - ax) * (by - ay));+ }+ }+ xs.sort_by(f64::total_cmp);+ for w in xs.chunks_exact(2) {+ let from = (((w[0] - t_o) / self.s).ceil() as i64).max(0);+ let to = (((w[1] - t_o) / self.s).floor() as i64 - 1).min(t_n * k - 1);+ for m in from..=to {+ let (a, b) = if d == 0 { (idx as usize, (m / k) as usize) } else { ((m / k) as usize, idx as usize) };+ hits.push((self.boundary(l, d, a, b), (m % k) as usize));+ }+ }+ }+ }+ }++ /// Something went away: work out again who closes each sample it could have closed.+ fn unmark(&mut self, w: &World, l: usize, shape: &Shape) {+ let (half, need) = (self.s / 2.0, self.need);+ let mut todo: Vec<(usize, usize, V)> = Vec::new();+ for bb in self.pieces(shape, need) {+ self.near_samples(bb, |d, i, j, bit, m| {+ if shape.dist_pt(m) + half < need {+ todo.push((self.boundary(l, d, i, j), bit, m));+ }+ });+ }+ let g = &w.layers[self.route[l]];+ for (b, bit, m) in todo {+ let (mut sv, mut dv) = (0u16, 0u16);+ let box_ = (m.0 - need, m.1 - need, m.0 + need, m.1 + need);+ g.visit(box_, |o| {+ if o.shape.dist_pt(m) + half < need {+ let who = net16(o.net);+ let v = if matches!(o.owner, Owner::Conn(_)) { &mut dv } else { &mut sv };+ *v = if *v == 0 || *v == who { who } else { MANY };+ }+ true+ });+ let e = self.need_edge;+ w.edge.visit((m.0 - e, m.1 - e, m.0 + e, m.1 + e), |o| {+ if o.shape.dist_pt(m) + half < e {+ sv = MANY;+ }+ true+ });+ self.blk_s[b * K + bit] = sv;+ self.blk_d[b * K + bit] = dv;+ let set = |mask: &mut u16, on: bool| {+ if on { *mask |= 1 << bit } else { *mask &= !(1 << bit) }+ };+ set(&mut self.mask_s[b], sv != 0);+ set(&mut self.mask_d[b], dv != 0);+ }+ }++ /// Can a centreline of this net cross this boundary at all?+ #[inline]+ pub fn open(&self, b: usize, who: u16, soft: bool) -> bool {+ let m = if soft { self.mask_s[b] } else { self.mask_s[b] | self.mask_d[b] };+ if m != FULL {+ return true;+ }+ for i in 0..K {+ let (s, d) = (self.blk_s[b * K + i], self.blk_d[b * K + i]);+ if (s == 0 || s == who) && (soft || d == 0 || d == who) {+ return true;+ }+ }+ false+ }++ #[inline]+ pub fn cell_of(&self, p: V) -> (i64, i64) {+ (((p.0 - self.x0) / self.cell).floor() as i64, ((p.1 - self.y0) / self.cell).floor() as i64)+ }+ #[inline]+ pub fn layer_index(&self, l: usize) -> usize {+ self.index[l]+ }++ /// Can a via stand anywhere in this cell? Same logic as the samples: only a proof counts.+ fn via_open(&self, w: &World, r: &Rules, net: NetId, soft: bool, i: usize, j: usize) -> bool {+ let sub = self.cell / VIA_SUB as f64;+ let hd = sub * 0.7072;+ let need = r.via as f64 / 2.0 + r.clearance as f64;+ let edge = r.via as f64 / 2.0 + r.edge as f64;+ for a in 0..VIA_SUB {+ 'sq: for b in 0..VIA_SUB {+ let c = V(self.x0 + (i as f64 + (a as f64 + 0.5) / VIA_SUB as f64) * self.cell, self.y0 + (j as f64 + (b as f64 + 0.5) / VIA_SUB as f64) * self.cell);+ let bb = (c.0 - need, c.1 - need, c.0 + need, c.1 + need);+ for g in &w.layers {+ let mut closed = false;+ g.visit(bb, |o| {+ if (o.net == net && net != NO_NET) || (soft && matches!(o.owner, Owner::Conn(_))) {+ return true;+ }+ if o.shape.dist_pt(c) + hd < need {+ closed = true;+ return false;+ }+ true+ });+ if closed {+ continue 'sq;+ }+ }+ let mut closed = false;+ w.edge.visit((c.0 - edge, c.1 - edge, c.0 + edge, c.1 + edge), |o| {+ if o.shape.dist_pt(c) + hd < edge {+ closed = true;+ return false;+ }+ true+ });+ if closed {+ continue 'sq;+ }+ return true;+ }+ }+ false+ }+}++/// One backward search, resumed as the detailed search asks. Keyed on everything that changes its+/// answer, so a later attempt for the same target with the same window and via rule reuses it.+pub struct Back {+ key: (NetId, u64, bool, bool, (i64, i64, i64, i64), u64),+ corridor: Option<Rc<Corridor>>,+ rev: u64,+ i0: i64,+ j0: i64,+ cw: usize,+ ch: usize,+ nl: usize,+ g: Vec<f32>,+ closed: Vec<bool>,+ via: Vec<u8>,+ heap: BinaryHeap<Reverse<(u32, u32)>>,+ src: (i64, i64),+ net: NetId,+ soft: bool,+ no_vias: bool,+ pub pops: u64,+}++/// Terminals by where they are, whichever layers a search allows them on: a bus member's first+/// tries are held to its layer, and it should still find its corridor.+fn ends_hash(ts: &[Terminal]) -> u64 {+ let mut h = 0xcbf2_9ce4_8422_2325u64;+ for t in ts {+ if let Terminal::Point { p, .. } = t {+ h = (h ^ p.0 as u64).wrapping_mul(0x0000_0100_0000_01b3);+ h = (h ^ p.1 as u64).wrapping_mul(0x0000_0100_0000_01b3);+ }+ }+ h+}++fn terminal_hash(ts: &[Terminal]) -> u64 {+ let mut h = 0xcbf2_9ce4_8422_2325u64;+ let mut mix = |v: u64| h = (h ^ v).wrapping_mul(0x0000_0100_0000_01b3);+ for t in ts {+ match t {+ Terminal::Point { p, layers } => {+ mix(p.0 as u64);+ mix(p.1 as u64);+ for &l in layers {+ mix(l as u64 + 1);+ }+ }+ Terminal::Zone { layer, poly } => {+ mix(*layer as u64 + 100);+ mix(poly.len() as u64);+ if let Some(p) = poly.first() {+ mix(p.0.to_bits());+ mix(p.1.to_bits());+ }+ }+ Terminal::Track { a, b, layer, .. } => {+ mix(*layer as u64 + 200);+ mix(a.0 as u64);+ mix(a.1 as u64);+ mix(b.0 as u64);+ mix(b.1 as u64);+ }+ }+ }+ h+}++impl Back {+ fn state(&self, i: i64, j: i64, l: usize) -> Option<usize> {+ let (a, b) = (i - self.i0, j - self.j0);+ if a < 0 || b < 0 || a >= self.cw as i64 || b >= self.ch as i64 {+ return None;+ }+ Some((l * self.ch + b as usize) * self.cw + a as usize)+ }++ #[inline]+ fn est(&self, i: i64, j: i64, cell: f64) -> f32 {+ let (dx, dy) = ((i - self.src.0).abs() as f64, (j - self.src.1).abs() as f64);+ let (hi, lo) = if dx > dy { (dx, dy) } else { (dy, dx) };+ (((hi - lo) + lo * SQRT2) * cell) as f32+ }++ fn relax(&mut self, cell: f64, s: usize, i: i64, j: i64, g: f32) {+ if g < self.g[s] {+ self.g[s] = g;+ let f = g + self.est(i, j, cell);+ // Micrometres: enough to order cells of half a millimetre.+ self.heap.push(Reverse(((f / 1000.0) as u32, s as u32)));+ }+ }++ /// Settle states until this one is settled, or nothing is left.+ fn lookup(&mut self, c: &Coarse, w: &World, r: &Rules, i: i64, j: i64, l: usize) -> f32 {+ let Some(want) = self.state(i, j, l) else { return f32::INFINITY };+ if self.closed[want] {+ return self.g[want];+ }+ let who = net16(self.net);+ let soft = self.soft;+ while let Some(Reverse((_, s))) = self.heap.pop() {+ let s = s as usize;+ if self.closed[s] {+ continue;+ }+ self.closed[s] = true;+ self.pops += 1;+ let (ci, cj, cl) = (s % self.cw, (s / self.cw) % self.ch, s / (self.cw * self.ch));+ let (ai, aj) = (ci as i64 + self.i0, cj as i64 + self.j0);+ let g = self.g[s];+ let mut unit = c.cell as f32 * if c.route[cl] == 0 { 1.0 } else { r.cost_bottom as f32 };+ // Off the planned corridor costs more in the estimate only: a preference, not a wall.+ if let Some(cr) = &self.corridor {+ if !cr.contains(ai, aj, cl) {+ unit *= 1.0 + CORRIDOR_BIAS;+ }+ }+ // What crossing a boundary adds, or infinity when it is closed. A soft search may cross+ // committed routes at a price, and an estimate that knows this heads straight for the+ // crossing instead of flooding everything cheaper first.+ let cross = |d: usize, a: i64, b: i64| -> f32 {+ if a < 0 || b < 0 || a as usize >= c.w || b as usize >= c.h {+ return f32::INFINITY;+ }+ let e = c.boundary(cl, d, a as usize, b as usize);+ if c.open(e, who, false) {+ 0.0+ } else if soft && c.open(e, who, true) {+ SOFT_CROSSING as f32+ } else {+ f32::INFINITY+ }+ };+ let e = cross(0, ai, aj);+ let wst = cross(0, ai - 1, aj);+ let so = cross(1, ai, aj);+ let n = cross(1, ai, aj - 1);+ for (x, dx, dy) in [(e, 1i64, 0i64), (wst, -1, 0), (so, 0, 1), (n, 0, -1)] {+ if x.is_finite() {+ if let Some(t) = self.state(ai + dx, aj + dy, cl) {+ self.relax(c.cell, t, ai + dx, aj + dy, g + unit + x);+ }+ }+ }+ // A diagonal step needs one of its two L-shaped ways open: any 45-degree line passes+ // through one of them.+ for (dx, dy, a, b) in [(1i64, 1i64, e, so), (1, -1, e, n), (-1, 1, wst, so), (-1, -1, wst, n)] {+ let via_x = a + if dy > 0 { cross(1, ai + dx, aj) } else { cross(1, ai + dx, aj - 1) };+ let via_y = b + if dx > 0 { cross(0, ai, aj + dy) } else { cross(0, ai - 1, aj + dy) };+ let x = via_x.min(via_y);+ if x.is_finite() {+ if let Some(t) = self.state(ai + dx, aj + dy, cl) {+ self.relax(c.cell, t, ai + dx, aj + dy, g + unit * SQRT2 as f32 + x);+ }+ }+ }+ if !self.no_vias && self.nl > 1 {+ let v = ci + cj * self.cw;+ if self.via[v] == 0 {+ self.via[v] = if c.via_open(w, r, self.net, soft, ai as usize, aj as usize) { 1 } else { 2 };+ }+ if self.via[v] == 1 {+ for o in 0..self.nl {+ if o != cl {+ let t = (o * self.ch + cj) * self.cw + ci;+ self.relax(c.cell, t, ai, aj, g + r.cost_via as f32);+ }+ }+ }+ }+ if s == want {+ return g;+ }+ }+ f32::INFINITY+ }+}++/// What the search keeps between calls: the map and its recent backward searches.+#[derive(Default)]+pub struct Guide {+ pub coarse: Option<Coarse>,+ tables: Vec<Back>,+ pub back_pops: u64,+ pub closed_early: u64,+ pub pockets_closed: u64,+ pub searches: u64,+ pub nodes_ok: u64,+ pub nodes_failed: u64,+ /// The last query that failed, so a retry can afford a closer look.+ failed: Option<(NetId, u64, u64)>,+ pockets: Vec<((u64, NetId, bool, bool), u64, bool)>,+ /// When the first search of the run began, for the hard stop inside a long search (4.6).+ pub start: Option<crate::clock::Instant>,+ pub plan: Option<GlobalPlan>,+ /// What the connection being routed should look like, if it belongs to a bus.+ pub hint: Option<Hint>,+}++/// A bus member's preference: its neighbour's shape carried over to its own pins, and a layer. Only+/// ever a small cost: the search still finds its own way where the neighbour's shape doesn't fit.+#[derive(Clone, Default)]+pub struct Hint {+ pub net: NetId,+ /// Runs to follow, and the layer each is on.+ pub lanes: Vec<(V, V, usize)>,+ pub layer: Option<usize>,+}++/// How much dearer a step away from the lanes is. All octilinear routes between two points of one+/// length tie, so this only has to break ties, and more than the search's own 20% inflation would+/// flood. At 0, 0.1 and 0.25 the boards' results vary with no trend (benchmark C 269, 287, 259 of 300);+/// 0.1 is what benchmark B's picture was judged at.+const OFF_LANE: f64 = 0.1;++impl Hint {+ #[inline]+ pub fn extra(&self, a: V, b: V, layer: usize) -> f64 {+ let mut k = 0.0;+ if let Some(l) = self.layer {+ if l != layer {+ k += OFF_LANE;+ }+ }+ if !self.lanes.is_empty() && !self.lanes.iter().any(|(p, q, l)| *l == layer && pt_seg(a, *p, *q) <= 2000.0 && pt_seg(b, *p, *q) <= 2000.0) {+ k += OFF_LANE;+ }+ k+ }+}++/// The pocket check looks this far around a terminal, in squares of this size.+const POCKET_R: f64 = 2.0 * NM;+const POCKET_S: f64 = 0.05 * NM;++/// Is this terminal walled in? A flood at the fine scale from the pad, over squares that a single+/// obstacle doesn't provably cover, looking for the edge of the area, the other terminal, or a place+/// a via could stand. Measured on benchmark C: most failing searches come within two cells of a pin+/// they can't get into and then spend their whole node budget circling it. Same rule as the map:+/// walled in means proven, never rounded, and a lane that exactly fits stays open.+fn walled_in(c: &Coarse, w: &World, q: &Query, p: P, layers: &[usize], other: &[Terminal]) -> bool {+ let n = (2.0 * POCKET_R / POCKET_S) as i64;+ let (ox, oy) = (p.0 as f64 - POCKET_R, p.1 as f64 - POCKET_R);+ let hd = POCKET_S * 0.7072;+ let nl = c.route.len();+ let soft = q.soft.is_some();+ let un = n as usize;+ let mut closed = vec![false; un * un * nl];+ let centre = |i: i64, j: i64| V(ox + (i as f64 + 0.5) * POCKET_S, oy + (j as f64 + 0.5) * POCKET_S);+ let region = (ox, oy, ox + 2.0 * POCKET_R, oy + 2.0 * POCKET_R);+ let mut paint = |l: usize, shape: &Shape, bb: (f64, f64, f64, f64), need: f64| {+ let lo = |v: f64, o: f64| (((v - need - o) / POCKET_S).floor() as i64).clamp(0, n);+ let hi = |v: f64, o: f64| (((v + need - o) / POCKET_S).ceil() as i64).clamp(0, n);+ for j in lo(bb.1, oy)..hi(bb.3, oy) {+ for i in lo(bb.0, ox)..hi(bb.2, ox) {+ let at = (l * un + j as usize) * un + i as usize;+ if !closed[at] && shape.dist_pt(centre(i, j)) + hd < need {+ closed[at] = true;+ }+ }+ }+ };+ for l in 0..nl {+ let need = c.need;+ let r = (region.0 - need, region.1 - need, region.2 + need, region.3 + need);+ w.layers[c.route[l]].visit(r, |o| {+ if (o.net == q.net && q.net != NO_NET) || (soft && matches!(o.owner, Owner::Conn(_))) || w.crosses_pour(o, q.net) {+ return true;+ }+ paint(l, &o.shape, o.bb, need);+ true+ });+ let e = c.need_edge;+ w.edge.visit((region.0 - e, region.1 - e, region.2 + e, region.3 + e), |o| {+ paint(l, &o.shape, o.bb, e);+ true+ });+ }+ let goal: Vec<(i64, i64, usize)> = other+ .iter()+ .filter_map(|t| if let Terminal::Point { p: g, layers } = t { Some((*g, layers)) } else { None })+ .flat_map(|(g, ls)| {+ let (i, j) = (((g.0 as f64 - ox) / POCKET_S).floor() as i64, ((g.1 as f64 - oy) / POCKET_S).floor() as i64);+ ls.iter().filter(|&&l| c.index[l] != usize::MAX).map(move |&l| (i, j, l)).collect::<Vec<_>>()+ })+ .map(|(i, j, l)| (i, j, c.index[l]))+ .collect();+ let mut seen = vec![false; un * un * nl];+ let mut via_tried = vec![false; un * un / 4 + un + 1];+ let mut stack: Vec<(i64, i64, usize)> = Vec::new();+ let (si, sj) = (n / 2, n / 2);+ for &l in layers {+ if c.index[l] != usize::MAX {+ let cl = c.index[l];+ seen[(cl * un + sj as usize) * un + si as usize] = true;+ stack.push((si, sj, cl));+ }+ }+ let via_need = q.rules.via as f64 / 2.0 + q.rules.clearance as f64;+ let via_edge = q.rules.via as f64 / 2.0 + q.rules.edge as f64;+ let via_hd = 2.0 * POCKET_S * 0.7072;+ let can_via = q.profile.max_vias > 0 && nl > 1;+ while let Some((i, j, l)) = stack.pop() {+ if i == 0 || j == 0 || i == n - 1 || j == n - 1 || goal.contains(&(i, j, l)) {+ return false;+ }+ if can_via {+ // Tested on squares twice the size, so each proof covers four flood squares.+ let (vi, vj) = (i / 2, j / 2);+ let tag = (vj * n / 2 + vi) as usize;+ if !via_tried[tag] {+ via_tried[tag] = true;+ let m = V(ox + (vi as f64 + 0.5) * 2.0 * POCKET_S, oy + (vj as f64 + 0.5) * 2.0 * POCKET_S);+ let bb = (m.0 - via_need, m.1 - via_need, m.0 + via_need, m.1 + via_need);+ let mut stands = true;+ for g in &w.layers {+ g.visit(bb, |o| {+ if (o.net == q.net && q.net != NO_NET) || (soft && matches!(o.owner, Owner::Conn(_))) {+ return true;+ }+ if o.shape.dist_pt(m) + via_hd < via_need {+ stands = false;+ return false;+ }+ true+ });+ if !stands {+ break;+ }+ }+ if stands {+ w.edge.visit((m.0 - via_edge, m.1 - via_edge, m.0 + via_edge, m.1 + via_edge), |o| {+ if o.shape.dist_pt(m) + via_hd < via_edge {+ stands = false;+ return false;+ }+ true+ });+ }+ if stands {+ for o in 0..nl {+ let at = (o * un + j as usize) * un + i as usize;+ if !seen[at] && !closed[at] {+ seen[at] = true;+ stack.push((i, j, o));+ }+ }+ }+ }+ }+ for dj in -1..=1i64 {+ for di in -1..=1i64 {+ let (a, b) = (i + di, j + dj);+ let at = (l * un + b as usize) * un + a as usize;+ if !seen[at] && !closed[at] {+ seen[at] = true;+ stack.push((a, b, l));+ }+ }+ }+ }+ true+}++impl Guide {+ /// Prepare the backward search for a query, or say the query isn't guided. Pair centrelines+ /// and fan-ins skip checks the map can't know about, so they stay out of it.+ pub fn prepare(&mut self, w: &World, q: &Query, window: (f64, f64, f64, f64)) -> Option<usize> {+ if q.also.is_some() || !q.free.is_empty() || q.targets.is_empty() || q.profile.neck < q.rules.neck || q.profile.clearance < q.rules.clearance {+ return None;+ }+ if self.coarse.is_none() {+ self.coarse = Some(Coarse::build(w, q.rules));+ }+ let c = self.coarse.as_mut().unwrap();+ c.sync(w);+ let c = self.coarse.as_ref().unwrap();+ let soft = q.soft.is_some();+ let no_vias = q.profile.max_vias == 0;+ let (a, b) = (c.cell_of(V(window.0, window.1)), c.cell_of(V(window.2, window.3)));+ let (i0, j0) = (a.0.max(0), a.1.max(0));+ let (i1, j1) = (b.0.min(c.w as i64 - 1), b.1.min(c.h as i64 - 1));+ if i0 > i1 || j0 > j1 {+ return None;+ }+ let corridor = if soft || q.profile.margin >= 8.0 * NM { None } else { self.corridor_of(q) };+ let key = (q.net, terminal_hash(q.targets), soft, no_vias, (i0, j0, i1, j1), terminal_hash(q.sources) * corridor.is_some() as u64);+ let c = self.coarse.as_ref().unwrap();+ let rev = if soft { c.rev_s } else { c.rev_d };+ self.tables.retain(|t| t.rev == rev);+ if let Some(i) = self.tables.iter().position(|t| t.key == key) {+ return Some(i);+ }+ if self.tables.len() >= 8 {+ self.tables.remove(0);+ }+ let (cw, ch, nl) = ((i1 - i0 + 1) as usize, (j1 - j0 + 1) as usize, c.route.len());+ let n = cw * ch * nl;+ let mut src = (0, 0);+ for t in q.sources {+ if let Terminal::Point { p, .. } = t {+ src = c.cell_of((*p).into());+ break;+ }+ }+ let mut t = Back { key, corridor, rev, i0, j0, cw, ch, nl, g: vec![f32::INFINITY; n], closed: vec![false; n], via: vec![0; cw * ch], heap: BinaryHeap::new(), src, net: q.net, soft, no_vias, pops: 0 };+ for term in q.targets {+ match term {+ // A tap onto a rail: every cell along the track is a goal.+ Terminal::Track { a, b, layer, .. } => {+ if c.index[*layer] == usize::MAX {+ continue;+ }+ let l = c.index[*layer];+ let (va, vb): (V, V) = ((*a).into(), (*b).into());+ let steps = (hypot(sub(va, vb)) / (c.cell * 0.5)).ceil().max(1.0) as usize;+ for k in 0..=steps {+ let f = k as f64 / steps as f64;+ let (i, j) = c.cell_of(V(va.0 + (vb.0 - va.0) * f, va.1 + (vb.1 - va.1) * f));+ if let Some(s) = t.state(i, j, l) {+ t.relax(c.cell, s, i, j, 0.0);+ }+ }+ }+ Terminal::Point { p, layers } => {+ let (i, j) = c.cell_of((*p).into());+ for &l in layers {+ if c.index[l] != usize::MAX {+ if let Some(s) = t.state(i, j, c.index[l]) {+ t.relax(c.cell, s, i, j, 0.0);+ }+ }+ }+ }+ Terminal::Zone { layer, poly } => {+ if c.index[*layer] == usize::MAX {+ continue;+ }+ let l = c.index[*layer];+ let b = Shape::Poly { pts: poly.clone(), r: 0.0 }.bbox();+ let (u, v) = (c.cell_of(V(b.0, b.1)), c.cell_of(V(b.2, b.3)));+ let reach = c.cell * 0.7072;+ let edge = Shape::Poly { pts: poly.clone(), r: 0.0 };+ for j in u.1.max(j0)..=v.1.min(j1) {+ for i in u.0.max(i0)..=v.0.min(i1) {+ let m = V(c.x0 + (i as f64 + 0.5) * c.cell, c.y0 + (j as f64 + 0.5) * c.cell);+ // dist_pt is zero inside the outline.+ if edge.dist_pt(m) <= reach {+ if let Some(s) = t.state(i, j, l) {+ t.relax(c.cell, s, i, j, 0.0);+ }+ }+ }+ }+ }+ }+ }+ self.tables.push(t);+ Some(self.tables.len() - 1)+ }++ /// One line for the run's report: what the search spent, and what the map saved it.+ pub fn summary(&self) -> String {+ format!("search: {} searches, {} nodes in those that succeeded, {} in those that failed; {} closed by the coarse map, {} by a walled-in terminal; {} coarse states", self.searches, self.nodes_ok, self.nodes_failed, self.closed_early, self.pockets_closed, self.back_pops)+ }++ /// Could a via stand in this point's cell? False is a proof, as everywhere on the map.+ pub fn via_possible(&mut self, table: usize, w: &World, r: &Rules, p: V) -> bool {+ let c = self.coarse.as_ref().unwrap();+ let (i, j) = c.cell_of(p);+ let t = &mut self.tables[table];+ let (a, b) = (i - t.i0, j - t.j0);+ if a < 0 || b < 0 || a >= t.cw as i64 || b >= t.ch as i64 {+ return true;+ }+ let v = b as usize * t.cw + a as usize;+ if t.via[v] == 0 {+ t.via[v] = if c.via_open(w, r, t.net, t.soft, i as usize, j as usize) { 1 } else { 2 };+ }+ t.via[v] == 1+ }++ pub fn corridor_of(&self, q: &Query) -> Option<Rc<Corridor>> {+ let plan = self.plan.as_ref()?;+ plan.corridors.get(&(q.net, ends_hash(q.sources), ends_hash(q.targets))).cloned()+ }++ pub fn note_failed(&mut self, q: &Query) {+ if let Some(c) = &self.coarse {+ self.failed = Some((q.net, terminal_hash(q.targets), if q.soft.is_some() { c.rev_s } else { c.rev_d }));+ }+ }++ /// Retrying after a failure: is either end walled in? Only then is the closer look worth it,+ /// and the answer carries over to every later attempt for the same pins.+ pub fn hopeless(&mut self, w: &World, q: &Query) -> bool {+ let Some(c) = self.coarse.as_ref() else { return false };+ let soft = q.soft.is_some();+ let rev = if soft { c.rev_s } else { c.rev_d };+ if self.failed != Some((q.net, terminal_hash(q.targets), rev)) {+ return false;+ }+ let no_vias = q.profile.max_vias == 0;+ self.pockets.retain(|x| x.1 == rev);+ let mut closed = false;+ for (ours, others) in [(q.sources, q.targets), (q.targets, q.sources)] {+ for t in ours {+ let Terminal::Point { p, layers } = t else { continue };+ if others.iter().any(|o| matches!(o, Terminal::Zone { .. })) {+ continue;+ }+ let key = (terminal_hash(std::slice::from_ref(t)) ^ terminal_hash(others), q.net, soft, no_vias);+ let v = match self.pockets.iter().find(|x| x.0 == key) {+ Some(x) => x.2,+ None => {+ let v = walled_in(c, w, q, *p, layers, others);+ self.pockets.push((key, rev, v));+ v+ }+ };+ closed |= v;+ }+ }+ if closed {+ self.pockets_closed += 1;+ }+ closed+ }++ /// The coarse cost from a point on a layer to the target; infinity is a proof of no way there.+ #[inline]+ pub fn cost(&mut self, table: usize, w: &World, r: &Rules, p: V, layer: usize) -> f64 {+ let c = self.coarse.as_ref().unwrap();+ let (i, j) = c.cell_of(p);+ let t = &mut self.tables[table];+ let before = t.pops;+ let g = t.lookup(c, w, r, i, j, c.index[layer]);+ self.back_pops += t.pops - before;+ if g.is_finite() {+ // Each end of a real route can be up to a cell closer than the cell centres say.+ (g as f64 - 2.0 * c.cell).max(0.0)+ } else {+ f64::INFINITY+ }+ }+}++// ---------------------------------------------------------------------------------------------+// The global stage: before any copper is drawn, give every connection a corridor of coarse cells.+//+// Negotiation lives here and only here. PathFinder at lattice level scored poorly on ordinary boards+// (OrthoRoute's README cites 0.02 clean pass), while as an iterated assignment over a coarse network+// it converges (SUMO's duaIterate, VTR's rounds): route everything, price what is overused, repeat,+// with present and accumulated cost as in VTR's get_rr_cong_cost. A corridor can never make a route+// illegal: the detailed search still checks every step against real copper, and it only reads the+// corridor as a preference in its estimate, dropped when the search has to widen.+// ---------------------------------------------------------------------------------------------++use crate::board::Board;+use crate::classify::{Level, Task};+use std::collections::HashMap;+use std::rc::Rc;++const ROUNDS: usize = 16;+/// VTR's defaults, roughly: first present-cost factor, its growth per round, and the history step.+const PRES_INIT: f32 = 0.5;+const PRES_MULT: f32 = 1.5;+const ACC_FAC: f32 = 0.5;++/// A corridor: which global cells this connection plans to use, per layer.+pub struct Corridor {+ pub bbox: (f64, f64, f64, f64),+ on: Vec<bool>,+ i0: i64,+ j0: i64,+ gw: usize,+ gh: usize,+ f: i64,+ nl: usize,+}++impl Corridor {+ #[inline]+ fn contains(&self, ci: i64, cj: i64, l: usize) -> bool {+ let (i, j) = (ci.div_euclid(self.f) - self.i0, cj.div_euclid(self.f) - self.j0);+ i >= 0 && j >= 0 && (i as usize) < self.gw && (j as usize) < self.gh && l < self.nl && self.on[(l * self.gh + j as usize) * self.gw + i as usize]+ }+}++/// What the global stage hands back to the router: a preference per connection and an order hint.+#[derive(Default, Clone)]+pub struct GlobalPlan {+ corridors: HashMap<(NetId, u64, u64), Rc<Corridor>>,+ pub note: String,+}++struct Conn {+ a: (i64, i64, Vec<usize>),+ b: (i64, i64, Vec<usize>),+ demand: f32,+ path: Vec<(i64, i64, usize)>,+ key: (NetId, u64, u64),+}++fn env_on(name: &str) -> bool {+ std::env::var_os(name).is_some()+}++fn pad_term(board: &Board, w: &World, i: usize) -> Terminal {+ let p = &board.pads[i];+ Terminal::Point { p: p.center, layers: p.layers.iter().copied().filter(|l| w.route_layers.contains(l)).collect() }+}++/// Assign every connection a corridor by negotiated congestion. Called once per routing pass, with+/// the pairs already placed so their room is gone.+///+/// The plan doesn't change the routing order. Tried at 60 s: most contested first lost connections+/// against shortest first (benchmark C 265 against 268), least contested first won on benchmark C (271)+/// and lost on piprobe_v13 (86 against 87). That is within the run-to-run spread, so the order stays+/// as the router sets it.+pub fn plan(w: &World, r: &Rules, board: &Board, tasks: &[Task], held_to: &dyn Fn(&Task) -> Option<usize>) -> GlobalPlan {+ let t0 = std::time::Instant::now();+ let mut out = GlobalPlan::default();+ if env_on("ADOM_ROUTE_NO_GLOBAL") {+ w.guide.borrow_mut().plan = None;+ return out;+ }+ let mut guide = w.guide.borrow_mut();+ if guide.coarse.is_none() {+ guide.coarse = Some(Coarse::build(w, r));+ }+ let c = guide.coarse.as_mut().unwrap();+ c.sync(w);+ let c = guide.coarse.as_ref().unwrap();+ // Global cells of 1 mm on a molecule, 2 mm on a large board, so a round stays in milliseconds.+ let big = c.w as f64 * c.cell > 60.0 * NM || c.h as f64 * c.cell > 60.0 * NM;+ let f: usize = if big { 8 } else { 4 };+ let g = c.cell * f as f64;+ let (gw, gh, nl) = (c.w / f, c.h / f, c.route.len());+ if gw < 2 || gh < 2 {+ return out;+ }+ let pitch = (r.track + r.clearance) as f64;+ // Capacity of each global boundary: tracks that fit in each free run of real centreline room.+ // Layer l, direction d, (i, j): from global cell (i, j) to the next in x (d 0) or y (d 1).+ let ne = nl * 2 * gw * gh;+ let mut cap = vec![0f32; ne];+ let edge = |l: usize, d: usize, i: usize, j: usize| ((l * 2 + d) * gh + j) * gw + i;+ for l in 0..nl {+ for d in 0..2 {+ for j in 0..gh {+ for i in 0..gw {+ let mut tracks = 0.0f32;+ let mut run = 0usize;+ let mut flush = |run: &mut usize| {+ if *run > 0 {+ tracks += (*run as f64 * c.s / pitch).floor() as f32 + 1.0;+ *run = 0;+ }+ };+ for t in 0..f {+ let (bi, bj) = if d == 0 { ((i + 1) * f - 1, j * f + t) } else { (i * f + t, (j + 1) * f - 1) };+ if bi >= c.w || bj >= c.h {+ continue;+ }+ let b = c.boundary(l, d, bi, bj);+ let m = c.mask_s[b] | c.mask_d[b];+ for bit in 0..K {+ if m & (1 << bit) == 0 {+ run += 1;+ } else {+ flush(&mut run);+ }+ }+ }+ flush(&mut run);+ cap[edge(l, d, i, j)] = tracks;+ }+ }+ }+ }+ let mut conns: Vec<Conn> = Vec::new();+ let gcell = |p: P| -> (i64, i64) {+ let (i, j) = c.cell_of(p.into());+ (i.div_euclid(f as i64), j.div_euclid(f as i64))+ };+ for t in tasks {+ if t.zone.is_some() || t.a == t.b {+ continue;+ }+ let (ta, tb) = (pad_term(board, w, t.a), pad_term(board, w, t.b));+ let (Terminal::Point { p: pa, layers: la }, Terminal::Point { p: pb, layers: lb }) = (&ta, &tb) else { continue };+ if la.is_empty() || lb.is_empty() {+ continue;+ }+ let (ca, cb) = (gcell(*pa), gcell(*pb));+ let demand = if t.level == Level::Power && t.main {+ let main = r.wide.get(board.net_name(t.net)).and_then(|v| v.first().copied()).unwrap_or(r.power_floor);+ (((main + r.clearance) as f64) / pitch).max(1.0) as f32+ } else {+ 1.0+ };+ let key = (t.net, ends_hash(std::slice::from_ref(&ta)), ends_hash(std::slice::from_ref(&tb)));+ // A bus member with a layer chosen plans on that layer.+ let on = |ls: &Vec<usize>| -> Vec<usize> {+ match held_to(t) {+ Some(l) if ls.contains(&l) => vec![c.index[l]],+ _ => ls.iter().map(|&l| c.index[l]).collect(),+ }+ };+ conns.push(Conn { a: (ca.0, ca.1, on(la)), b: (cb.0, cb.1, on(lb)), demand, path: Vec::new(), key });+ }+ // Long ones first in the first round, so short ones see where the long ones want to be.+ let mut order: Vec<usize> = (0..conns.len()).collect();+ let span = |x: &Conn| (x.a.0 - x.b.0).abs() + (x.a.1 - x.b.1).abs();+ order.sort_by_key(|&i| -span(&conns[i]));++ let mut occ = vec![0f32; ne];+ let mut hist = vec![1f32; ne];+ let mut pres = PRES_INIT;+ let ng = gw * gh * nl;+ let mut best = vec![f32::INFINITY; ng];+ let mut prev = vec![u32::MAX; ng];+ let mut stamp = vec![0u32; ng];+ let mut tick = 0u32;+ let mut heap: BinaryHeap<Reverse<(u32, u32)>> = BinaryHeap::new();+ let base = (g * 0.75) as f32;+ let via_cost = r.cost_via as f32;+ let (mut first_over, mut last_over, mut rounds) = (0.0f32, 0.0f32, 0usize);+ for round in 0..ROUNDS {+ rounds = round + 1;+ let mut changed = 0;+ for &ci in &order {+ let conn = &mut conns[ci];+ // Only connections through something overused need a new corridor (incremental+ // PathFinder); the rest keep theirs, as SUMO keeps most drivers on their routes.+ let (mut over, mut has) = (false, false);+ for s in conn.path.windows(2) {+ has = true;+ if let Some(e) = charged(conn, s[0], s[1], &cap, gw, gh) {+ if occ[e] > cap[e] {+ over = true;+ break;+ }+ }+ }+ if has && !over {+ continue;+ }+ changed += 1;+ for s in conn.path.windows(2) {+ if let Some(e) = charged(conn, s[0], s[1], &cap, gw, gh) {+ occ[e] -= conn.demand;+ }+ }+ // A* over global cells. Crossing a boundary costs its length, times history, times the+ // present cost of being over capacity.+ tick += 1;+ heap.clear();+ let idx = |i: i64, j: i64, l: usize| (l * gh + j as usize) * gw + i as usize;+ let inb = |i: i64, j: i64| i >= 0 && j >= 0 && (i as usize) < gw && (j as usize) < gh;+ let (ta, tb) = (conn.b.0, conn.b.1);+ let est = |i: i64, j: i64| ((i - ta).abs() + (j - tb).abs()) as f32 * base;+ for &l in &conn.a.2 {+ if inb(conn.a.0, conn.a.1) {+ let s = idx(conn.a.0, conn.a.1, l);+ best[s] = 0.0;+ stamp[s] = tick;+ prev[s] = u32::MAX;+ heap.push(Reverse(((est(conn.a.0, conn.a.1) / 100.0) as u32, s as u32)));+ }+ }+ let mut goal = None;+ while let Some(Reverse((_, s))) = heap.pop() {+ let s = s as usize;+ let (i, j, l) = ((s % gw) as i64, ((s / gw) % gh) as i64, s / (gw * gh));+ if i == ta && j == tb && conn.b.2.contains(&l) {+ goal = Some(s);+ break;+ }+ let gs = best[s];+ let mult = if c.route[l] == 0 { 1.0 } else { r.cost_bottom as f32 };+ for (di, dj) in [(1i64, 0i64), (-1, 0), (0, 1), (0, -1)] {+ let (a, b) = (i + di, j + dj);+ if !inb(a, b) {+ continue;+ }+ let e = step_edge((i, j, l), (a, b, l), gw, gh).unwrap();+ let over_by = (occ[e] + conn.demand - cap[e]).max(0.0);+ // A pin's own cell boundary reads closed to everyone (its pads block the map),+ // so it costs no penalty for the connection that starts or ends there.+ let at_end = (i == conn.a.0 && j == conn.a.1) || (i == ta && j == tb) || (a == conn.a.0 && b == conn.a.1) || (a == ta && b == tb);+ let cong = if at_end && cap[e] < conn.demand { 1.0 } else { hist[e] * (1.0 + pres * over_by) };+ let n = gs + base * mult * cong;+ let t = idx(a, b, l);+ if stamp[t] != tick || n < best[t] {+ stamp[t] = tick;+ best[t] = n;+ prev[t] = s as u32;+ heap.push(Reverse((((n + est(a, b)) / 100.0) as u32, t as u32)));+ }+ }+ for o in 0..nl {+ if o != l {+ let t = idx(i, j, o);+ let n = gs + via_cost;+ if stamp[t] != tick || n < best[t] {+ stamp[t] = tick;+ best[t] = n;+ prev[t] = s as u32;+ heap.push(Reverse(((n + est(i, j)) as u32 / 100, t as u32)));+ }+ }+ }+ }+ conn.path.clear();+ if let Some(mut s) = goal {+ loop {+ conn.path.push(((s % gw) as i64, ((s / gw) % gh) as i64, s / (gw * gh)));+ if prev[s] == u32::MAX {+ break;+ }+ s = prev[s] as usize;+ }+ conn.path.reverse();+ }+ for s in conn.path.windows(2) {+ if let Some(e) = charged(conn, s[0], s[1], &cap, gw, gh) {+ occ[e] += conn.demand;+ }+ }+ }+ let mut over_total = 0.0;+ for e in 0..ne {+ let o = (occ[e] - cap[e]).max(0.0);+ over_total += o;+ hist[e] += ACC_FAC * o;+ }+ if round == 0 {+ first_over = over_total;+ }+ last_over = over_total;+ pres *= PRES_MULT;+ // No wall-clock cut-off (clock.rs): the rounds are bounded by ROUNDS, the same on every run.+ if changed == 0 {+ break;+ }+ }+ // Corridors: the planned cells, a cell wider each way, so the search has room to choose.+ for conn in &conns {+ if conn.path.is_empty() {+ continue;+ }+ let (mut i0, mut j0, mut i1, mut j1) = (i64::MAX, i64::MAX, i64::MIN, i64::MIN);+ for s in &conn.path {+ i0 = i0.min(s.0 - 1);+ j0 = j0.min(s.1 - 1);+ i1 = i1.max(s.0 + 1);+ j1 = j1.max(s.1 + 1);+ }+ let (cw, ch) = ((i1 - i0 + 1) as usize, (j1 - j0 + 1) as usize);+ let mut on = vec![false; cw * ch * nl];+ for s in &conn.path {+ for dj in -1..=1i64 {+ for di in -1..=1i64 {+ on[(s.2 * ch + (s.1 + dj - j0) as usize) * cw + (s.0 + di - i0) as usize] = true;+ }+ }+ }+ let bbox = (c.x0 + i0 as f64 * g, c.y0 + j0 as f64 * g, c.x0 + (i1 + 1) as f64 * g, c.y0 + (j1 + 1) as f64 * g);+ out.corridors.insert(conn.key, Rc::new(Corridor { bbox, on, i0, j0, gw: cw, gh: ch, f: f as i64, nl }));+ }+ out.note = format!("global: {} connections, {} rounds, overuse {:.0} -> {:.0}, {:.0} ms", conns.len(), rounds, first_over, last_over, t0.elapsed().as_secs_f64() * 1000.0);+ guide.plan = Some(out.clone());+ out+}++/// The boundary a step uses up. A pin's own cell boundary reads closed to everyone, since its pads+/// block the map, so a step off a terminal over such a boundary uses nothing up.+#[inline]+fn charged(conn: &Conn, a: (i64, i64, usize), b: (i64, i64, usize), cap: &[f32], gw: usize, gh: usize) -> Option<usize> {+ let e = step_edge(a, b, gw, gh)?;+ let end = |p: (i64, i64, usize)| (p.0 == conn.a.0 && p.1 == conn.a.1) || (p.0 == conn.b.0 && p.1 == conn.b.1);+ if cap[e] < conn.demand && (end(a) || end(b)) {+ return None;+ }+ Some(e)+}++/// Would these two connections cross if each ran straight? The estimate buses use. Coarse on purpose:+/// which of two lines must give way depends on the ends, not on the detail of the route between.+pub fn chords_cross(a: V, b: V, c: V, d: V) -> bool {+ seg_cross(a, b, c, d)+}++/// The boundary between two neighbouring global cells on one layer; None for a via.+#[inline]+fn step_edge(a: (i64, i64, usize), b: (i64, i64, usize), gw: usize, gh: usize) -> Option<usize> {+ if a.2 != b.2 {+ return None;+ }+ let (d, i, j) = if a.1 == b.1 { (0, a.0.min(b.0), a.1) } else { (1, a.0, a.1.min(b.1)) };+ Some(((a.2 * 2 + d) * gh + j as usize) * gw + i as usize)+}++#[cfg(test)]+mod tests {+ use super::*;+ use crate::router::build_world;+ use crate::search::{search, Profile};++ fn board_text(extra: &str) -> String {+ format!(+ r#"(kicad_pcb (version 20240108) (generator test)+ (layers (0 "F.Cu" signal) (31 "B.Cu" signal) (44 "Edge.Cuts" user))+ (net 0 "") (net 1 "A") (net 2 "B")+ (gr_rect (start 0 0) (end 20 10) (layer "Edge.Cuts") (width 0.1))+ {extra}+)"#+ )+ }++ fn term(x: f64, y: f64) -> [Terminal; 1] {+ [Terminal::Point { p: (mm(x), mm(y)), layers: vec![0] }]+ }++ fn profile(r: &Rules, vias: u32) -> Profile {+ Profile { width: r.neck, neck: r.neck, clearance: r.clearance, target_gap: r.clearance, max_vias: vias, margin: 40.0 * NM, layer_bias: None }+ }++ fn query<'a>(r: &'a Rules, s: &'a [Terminal], t: &'a [Terminal], vias: u32) -> Query<'a> {+ Query { net: 99, also: None, sources: s, targets: t, profile: profile(r, vias), rules: r, soft: None, via_in: None, history: None, free: &[], arrive: None }+ }++ /// A wall of two tracks with a gap that a neck-width track fits exactly. Rounding of any kind+ /// would close this lane; the map must not. A tenth of a millimetre less must close it.+ #[test]+ fn a_lane_that_exactly_fits_stays_open() {+ let r = Rules::default();+ let gap = (r.neck + 2 * r.clearance) as f64 / NM;+ let half = r.track as f64 / NM / 2.0;+ let (a, b) = (5.0 - gap / 2.0 - half, 5.0 + gap / 2.0 + half);+ let extra = format!(+ r#"(segment (start 10 0.5) (end 10 {a}) (width 0.2) (layer "F.Cu") (net 2))+ (segment (start 10 {b}) (end 10 9.5) (width 0.2) (layer "F.Cu") (net 2))"#+ );+ let board = Board::parse(board_text(&extra)).unwrap();+ let w = build_world(&board, &r);+ let (s, t) = (term(5.0, 5.0), term(15.0, 5.0));+ let q = query(&r, &s, &t, 0);+ let mut guide = w.guide.borrow_mut();+ let tb = guide.prepare(&w, &q, (0.0, 0.0, 20.0 * NM, 10.0 * NM)).unwrap();+ assert!(guide.cost(tb, &w, &r, V(5.0 * NM, 5.0 * NM), 0).is_finite());+ drop(guide);+ assert!(search(&w, &q).is_some(), "the detailed search should use the lane too");++ let (a, b) = (a + 0.05, b - 0.05);+ let extra = format!(+ r#"(segment (start 10 0.5) (end 10 {a}) (width 0.2) (layer "F.Cu") (net 2))+ (segment (start 10 {b}) (end 10 9.5) (width 0.2) (layer "F.Cu") (net 2))"#+ );+ let board = Board::parse(board_text(&extra)).unwrap();+ let w = build_world(&board, &r);+ let q = query(&r, &s, &t, 0);+ assert!(search(&w, &q).is_none(), "the lane is 0.1 mm too narrow");+ }++ /// The same wall with no lane at all is a proof: no nodes spent.+ #[test]+ fn a_closed_wall_is_proved_without_searching() {+ let r = Rules::default();+ let extra = r#"(segment (start 10 0) (end 10 10) (width 0.2) (layer "F.Cu") (net 2))"#;+ let board = Board::parse(board_text(extra)).unwrap();+ let w = build_world(&board, &r);+ let (s, t) = (term(5.0, 5.0), term(15.0, 5.0));+ let q = query(&r, &s, &t, 0);+ assert!(search(&w, &q).is_none());+ let g = w.guide.borrow();+ assert_eq!(g.closed_early, 1);+ assert_eq!(g.nodes_failed, 0);+ }++ /// A committed route closes the map and taking it away opens it again.+ #[test]+ fn the_map_follows_commits_and_rips() {+ let r = Rules::default();+ let board = Board::parse(board_text("")).unwrap();+ let mut w = build_world(&board, &r);+ let (s, t) = (term(5.0, 5.0), term(15.0, 5.0));+ assert!(search(&w, &query(&r, &s, &t, 0)).is_some());+ let id = w.layers[0].add(Shape::Capsule { a: V(10.0 * NM, 0.0), b: V(10.0 * NM, 10.0 * NM), r: 0.1 * NM }, 2, Owner::Conn(7));+ assert!(search(&w, &query(&r, &s, &t, 0)).is_none());+ assert_eq!(w.guide.borrow().closed_early, 1);+ w.layers[0].remove(id);+ assert!(search(&w, &query(&r, &s, &t, 0)).is_some());+ }++ /// The hard case the map must not get wrong: every pin of the 0.4 mm QFN on PiProbe with its+ /// copper stripped. The designer routes all of these; "no path" here would be the map's mistake.+ #[test]+ fn piprobe_u1_escapes_are_open() {+ let path = concat!(env!("CARGO_MANIFEST_DIR"), "/bench/boards/piprobe_v13.ref.kicad_pcb");+ if !std::path::Path::new(&path).exists() {+ eprintln!("skipped: {path} is not here (the reference boards do not ship with the source)");+ return;+ }+ let mut board = Board::read(path).unwrap();+ board.tracks.clear();+ board.vias.clear();+ let mut r = Rules::default();+ crate::project::apply(path, &mut r);+ let w = build_world(&board, &r);+ let u1 = board.footprints.iter().find(|f| f.pads.iter().any(|&p| board.pad_name(p).starts_with("U1.")));+ let u1 = u1.expect("U1 on the board");+ let mut guide = w.guide.borrow_mut();+ guide.coarse = Some(Coarse::build(&w, &r));+ let c = guide.coarse.as_ref().unwrap();+ let mut checked = 0;+ for &i in &u1.pads {+ let pad = &board.pads[i];+ if pad.net == NO_NET || pad.layers.is_empty() {+ continue;+ }+ let Some(&other) = (0..board.pads.len()).collect::<Vec<_>>().iter().find(|&&o| o != i && board.pads[o].net == pad.net && !u1.pads.contains(&o)) else { continue };+ let layers: Vec<usize> = pad.layers.iter().copied().filter(|l| w.route_layers.contains(l)).collect();+ let s = [Terminal::Point { p: pad.center, layers: layers.clone() }];+ let t = [Terminal::Point { p: board.pads[other].center, layers: board.pads[other].layers.iter().copied().filter(|l| w.route_layers.contains(l)).collect() }];+ let q = Query { net: pad.net, also: None, sources: &s, targets: &t, profile: profile(&r, 2), rules: &r, soft: None, via_in: None, history: None, free: &[], arrive: None };+ assert!(!walled_in(c, &w, &q, pad.center, &layers, &t), "{} called walled in", board.pad_name(i));+ checked += 1;+ }+ assert!(checked >= 20, "only {checked} U1 pins checked");+ drop(guide);+ for &i in &u1.pads {+ let pad = &board.pads[i];+ let Some(&other) = (0..board.pads.len()).collect::<Vec<_>>().iter().find(|&&o| o != i && pad.net != NO_NET && board.pads[o].net == pad.net && !u1.pads.contains(&o)) else { continue };+ let layers: Vec<usize> = pad.layers.iter().copied().filter(|l| w.route_layers.contains(l)).collect();+ if layers.is_empty() {+ continue;+ }+ let s = [Terminal::Point { p: pad.center, layers }];+ let t = [Terminal::Point { p: board.pads[other].center, layers: board.pads[other].layers.iter().copied().filter(|l| w.route_layers.contains(l)).collect() }];+ let q = Query { net: pad.net, also: None, sources: &s, targets: &t, profile: profile(&r, 2), rules: &r, soft: None, via_in: None, history: None, free: &[], arrive: None };+ let mut g = w.guide.borrow_mut();+ let tb = g.prepare(&w, &q, (-1e12, -1e12, 1e12, 1e12)).unwrap();+ assert!(g.cost(tb, &w, &r, pad.center.into(), pad.layers[0]).is_finite(), "{} closed on the coarse map", board.pad_name(i));+ }+ }+}
src/grade.rsadded+154@@ -0,0 +1,154 @@+//! `grade`: runs the checker over the designer's boards and holds it to an expected-results file. His rule+//! (AI_Mistakes_Log R1): a check gates only after it passes the reference boards and fails the layouts he+//! rejected. Findings his own finished boards carry stay listed as known, with their coordinates,+//! for him to decide; a new one fails the grade, and so does a known one that has gone away.++use crate::board::Board;+use crate::check::{self, Finding, GEOMETRY_RULES};+use crate::classify;+use crate::project;+use crate::rules::Rules;+use serde_json::{json, Value};+use std::collections::BTreeMap;+use std::path::Path;++const NEAR_MM: f64 = 0.02;++pub struct Outcome {+ pub ok: bool,+ pub lines: Vec<String>,+ pub json: Value,+}++fn strs(v: Option<&Value>) -> Vec<String> {+ v.and_then(|x| x.as_array()).map(|a| a.iter().filter_map(|s| s.as_str().map(String::from)).collect()).unwrap_or_default()+}++pub fn run(file: &str) -> Result<Outcome, String> {+ let text = std::fs::read_to_string(file).map_err(|e| format!("{file}: {e}"))?;+ let v: Value = serde_json::from_str(&text).map_err(|e| format!("{file}: {e}"))?;+ let base = Path::new(file).parent().unwrap_or(Path::new("."));+ let boards = v.get("boards").and_then(|b| b.as_array()).ok_or("no boards in the grade file")?;+ let mut ok = true;+ let mut lines = Vec::new();+ let mut out = Vec::new();+ for e in boards {+ let name = e.get("name").and_then(|x| x.as_str()).unwrap_or("?");+ let rel = e.get("board").and_then(|x| x.as_str()).ok_or("board entry without a path")?;+ let path = base.join(rel);+ // A synthetic fixture is the reference board with copper added, in memory: `insert` is appended to the+ // file's text, so every check is proven to fire on a fault placed in a board it passes.+ let inserts = strs(e.get("insert"));+ let board = if inserts.is_empty() {+ Board::read(path.to_str().unwrap_or(rel))?+ } else {+ let t = std::fs::read_to_string(&path).map_err(|e| format!("{rel}: {e}"))?;+ let end = t.rfind(')').ok_or("board file has no closing bracket")?;+ Board::parse(format!("{}\n{}\n)\n", &t[..end], inserts.join("\n")))?+ };+ let mut rules = match e.get("spec").and_then(|x| x.as_str()) {+ Some(s) => Rules::load(base.join(s).to_str().unwrap_or(s))?,+ None => Rules::default(),+ };+ let pro = e.get("project").and_then(|x| x.as_str()).map(|p| base.join(p)).unwrap_or_else(|| path.with_extension("kicad_pro"));+ project::apply_files(&pro, &path.with_extension("kicad_dru"), &mut rules);+ for k in strs(e.get("kelvin")) {+ rules.kelvin.push(k);+ }+ rules.bind(&board);+ classify::find_pairs(&board, &mut rules);+ let (f, s) = check::check(&board, &rules);+ let errors: Vec<&Finding> = f.iter().filter(|x| x.severity == "error").collect();+ let count = |rule: &str| errors.iter().filter(|x| x.rule == rule).count();+ let mut fails: Vec<String> = Vec::new();++ let known_at = e.get("knownAt").and_then(|x| x.as_array()).cloned().unwrap_or_default();+ let listed: Vec<(String, f64, f64)> = known_at+ .iter()+ .filter_map(|k| Some((k.get("rule")?.as_str()?.to_string(), k.pointer("/at/0")?.as_f64()?, k.pointer("/at/1")?.as_f64()?)))+ .collect();+ let known_count: BTreeMap<String, usize> = e.get("knownCount").and_then(|x| x.as_object()).map(|o| o.iter().map(|(k, c)| (k.clone(), c.as_u64().unwrap_or(0) as usize)).collect()).unwrap_or_default();+ let is_known = |x: &Finding| known_count.contains_key(x.rule) || listed.iter().any(|(r, a, b)| r == x.rule && (a - x.at.0).abs() <= NEAR_MM && (b - x.at.1).abs() <= NEAR_MM);++ // A finished board: connected, and every geometry rule clean apart from what is listed.+ if e.get("finished").and_then(|x| x.as_bool()).unwrap_or(false) {+ if s.nets_open != 0 {+ fails.push(format!("{} nets open; KiCad and the reference board say 0", s.nets_open));+ }+ for x in errors.iter().filter(|x| GEOMETRY_RULES.contains(&x.rule) && !is_known(x)) {+ fails.push(format!("new finding {} at ({:.2}, {:.2}): {}", x.rule, x.at.0, x.at.1, x.text));+ }+ for (r, a, b) in &listed {+ if !f.iter().any(|x| x.rule == r && (a - x.at.0).abs() <= NEAR_MM && (b - x.at.1).abs() <= NEAR_MM) {+ fails.push(format!("known {r} at ({a:.2}, {b:.2}) is no longer found: the list is stale or the check changed"));+ }+ }+ for (r, c) in &known_count {+ let got = f.iter().filter(|x| x.rule == r).count();+ if got != *c {+ fails.push(format!("known {r}: expected {c}, found {got}"));+ }+ }+ }+ for r in strs(e.get("mustFail")) {+ if count(&r) == 0 {+ fails.push(format!("{r} must fail on this layout and did not"));+ }+ }+ for r in strs(e.get("mustPass")) {+ if count(&r) != 0 {+ fails.push(format!("{r} must pass on this layout and found {}", count(&r)));+ }+ }+ for r in strs(e.get("mustWarn")) {+ if !f.iter().any(|x| x.rule == r && x.severity != "error") {+ fails.push(format!("{r} must warn on this layout and did not"));+ }+ }+ // A fixture must add what it says and nothing else: the check fires on the fault it was+ // built for, and the board's other findings don't move.+ if let Some(add) = e.get("adds").and_then(|x| x.as_object()) {+ let plain = Board::read(path.to_str().unwrap_or(rel))?;+ let (f0, _) = check::check(&plain, &rules);+ let tally = |v: &[Finding]| {+ let mut m: BTreeMap<String, i64> = BTreeMap::new();+ for x in v.iter().filter(|x| x.severity == "error") {+ *m.entry(x.rule.to_string()).or_insert(0) += 1;+ }+ m+ };+ let (t0, t1) = (tally(&f0), tally(&f));+ let mut rules_seen: Vec<&String> = t0.keys().chain(t1.keys()).collect();+ rules_seen.sort();+ rules_seen.dedup();+ for r in rules_seen {+ let d = t1.get(r).copied().unwrap_or(0) - t0.get(r).copied().unwrap_or(0);+ let want = add.get(r).and_then(|x| x.as_i64()).unwrap_or(0);+ if d != want {+ fails.push(format!("the edit moved {r} by {d}, expected {want}"));+ }+ }+ for (r, w) in add {+ if !t1.contains_key(r.as_str()) && w.as_i64().unwrap_or(0) != 0 {+ fails.push(format!("{r} never fired on the edit"));+ }+ }+ }+ let mut per: BTreeMap<&str, usize> = BTreeMap::new();+ for x in &f {+ *per.entry(x.rule).or_insert(0) += 1;+ }+ let known_n = errors.iter().filter(|x| is_known(x)).count();+ let pass = fails.is_empty();+ ok &= pass;+ lines.push(format!("{} {name}: {} errors ({} known), {} warnings, {} nets open. {:?}", if pass { "PASS" } else { "FAIL" }, errors.len(), known_n, f.len() - errors.len(), s.nets_open, per));+ for x in &fails {+ lines.push(format!(" {x}"));+ }+ if let Some(k) = e.get("kicad") {+ lines.push(format!(" KiCad DRC, last run by hand: {k}"));+ }+ out.push(json!({"name": name, "pass": pass, "errors": errors.len(), "known": known_n, "warnings": f.len() - errors.len(), "netsOpen": s.nets_open, "perRule": per, "fails": fails}));+ }+ Ok(Outcome { ok, lines, json: json!({"ok": ok, "boards": out}) })+}
src/index.rsadded+286@@ -0,0 +1,286 @@+//! Spatial index: one bucket grid per copper layer, plus one for holes and one for the edge.+//! Items can be added and removed as routes are ripped up.++use crate::board::NetId;+use crate::geom::*;+use std::cell::{Cell, RefCell};+use std::rc::Rc;++#[derive(Clone, Copy, Debug, PartialEq, Eq)]+pub enum Owner {+ Pad(u32),+ /// A committed route (connection id) on this run.+ Conn(u32),+ /// Copper that was on the board before this run; it never moves.+ Fixed,+ Zone(u32),+ Edge,+ Keepout,+ /// A fine-pitch pin's way out, held until its route is laid (4.7). Other nets keep clear.+ Lane(u32),+}++#[derive(Clone, Debug)]+pub struct Obs {+ pub shape: Shape,+ pub net: NetId,+ pub owner: Owner,+ pub bb: (f64, f64, f64, f64),+ /// A big pour's edges by place, so a query near it doesn't test them all.+ pub fast: Option<Rc<PolyIndex>>,+}++impl Obs {+ /// Edge distance from a segment to this item, exact up to `limit`; past that it may answer+ /// anything at or above `limit`. Pours of 700 to 1100 points took a search on benchmark B to+ /// 25 microseconds a node, against about 1 elsewhere.+ #[inline]+ pub fn dist_within(&self, a: V, b: V, limit: f64) -> f64 {+ match &self.fast {+ Some(f) => f.dist_within(a, b, limit),+ None => self.shape.dist_seg(a, b),+ }+ }+}++/// A polygon's edges in buckets: by cell for distance, by row for inside or out.+#[derive(Debug)]+pub struct PolyIndex {+ pts: Vec<V>,+ r: f64,+ x0: f64,+ y0: f64,+ cell: f64,+ w: usize,+ h: usize,+ cells: Vec<Vec<u32>>,+ rows: Vec<Vec<u32>>,+}++impl PolyIndex {+ /// Worth it only past this many points.+ const MIN_POINTS: usize = 32;++ fn new(pts: &[V], r: f64) -> PolyIndex {+ let mut bb = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);+ for p in pts {+ bb = (bb.0.min(p.0), bb.1.min(p.1), bb.2.max(p.0), bb.3.max(p.1));+ }+ let cell = 1.0 * NM;+ let w = ((bb.2 - bb.0) / cell) as usize + 1;+ let h = ((bb.3 - bb.1) / cell) as usize + 1;+ let mut ix = PolyIndex { pts: pts.to_vec(), r, x0: bb.0, y0: bb.1, cell, w, h, cells: vec![Vec::new(); w * h], rows: vec![Vec::new(); h] };+ let n = pts.len();+ for e in 0..n {+ let (p, q) = (pts[e], pts[(e + 1) % n]);+ let (j0, j1) = (ix.row(p.1.min(q.1)), ix.row(p.1.max(q.1)));+ for j in j0..=j1 {+ ix.rows[j].push(e as u32);+ // Only the columns this edge reaches inside this row: long diagonals are common.+ let (ya, yb) = (ix.y0 + j as f64 * cell, ix.y0 + (j + 1) as f64 * cell);+ let at = |y: f64| if (q.1 - p.1).abs() < 1.0 { p.0 } else { p.0 + (q.0 - p.0) * ((y - p.1) / (q.1 - p.1)).clamp(0.0, 1.0) };+ let (xa, xb) = (at(ya), at(yb));+ let (i0, i1) = (ix.col(xa.min(xb).min(if j == j0 { p.0.min(q.0) } else { f64::MAX })), ix.col(xa.max(xb).max(if j == j0 { p.0.min(q.0) } else { f64::MIN })));+ let (i0, i1) = if (q.1 - p.1).abs() < 1.0 { (ix.col(p.0.min(q.0)), ix.col(p.0.max(q.0))) } else { (i0, i1) };+ for i in i0..=i1 {+ ix.cells[j * w + i].push(e as u32);+ }+ }+ }+ ix+ }++ #[inline]+ fn col(&self, x: f64) -> usize {+ (((x - self.x0) / self.cell).max(0.0) as usize).min(self.w - 1)+ }+ #[inline]+ fn row(&self, y: f64) -> usize {+ (((y - self.y0) / self.cell).max(0.0) as usize).min(self.h - 1)+ }++ fn inside(&self, p: V) -> bool {+ if p.1 < self.y0 || p.1 > self.y0 + self.h as f64 * self.cell {+ return false;+ }+ let n = self.pts.len();+ let mut inside = false;+ for &e in &self.rows[self.row(p.1)] {+ let (a, b) = (self.pts[e as usize], self.pts[(e as usize + 1) % n]);+ if (a.1 > p.1) != (b.1 > p.1) && p.0 < (b.0 - a.0) * (p.1 - a.1) / (b.1 - a.1) + a.0 {+ inside = !inside;+ }+ }+ inside+ }++ fn dist_within(&self, a: V, b: V, limit: f64) -> f64 {+ if self.inside(a) || self.inside(b) {+ return 0.0;+ }+ let reach = limit + self.r;+ let (i0, i1) = (self.col(a.0.min(b.0) - reach), self.col(a.0.max(b.0) + reach));+ let (j0, j1) = (self.row(a.1.min(b.1) - reach), self.row(a.1.max(b.1) + reach));+ let n = self.pts.len();+ let mut d = limit + self.r;+ for j in j0..=j1 {+ for i in i0..=i1 {+ for &e in &self.cells[j * self.w + i] {+ let (p, q) = (self.pts[e as usize], self.pts[(e as usize + 1) % n]);+ d = d.min(seg_seg(a, b, p, q));+ if d <= 0.0 {+ return 0.0;+ }+ }+ }+ }+ (d - self.r).max(0.0)+ }+}++pub struct Grid {+ x0: f64,+ y0: f64,+ cell: f64,+ w: usize,+ h: usize,+ cells: Vec<Vec<u32>>,+ items: Vec<Option<Obs>>,+ stamp: Vec<Cell<u32>>,+ tick: Cell<u32>,+ free: Vec<u32>,+ /// What changed since the coarse map last looked: (added, item).+ log: RefCell<Vec<(bool, Obs)>>,+}++impl Grid {+ pub fn new(bb: (f64, f64, f64, f64), cell: f64) -> Grid {+ let pad = 5.0 * NM;+ let x0 = bb.0 - pad;+ let y0 = bb.1 - pad;+ let w = (((bb.2 - bb.0 + 2.0 * pad) / cell).ceil() as usize + 1).max(1);+ let h = (((bb.3 - bb.1 + 2.0 * pad) / cell).ceil() as usize + 1).max(1);+ Grid { x0, y0, cell, w, h, cells: vec![Vec::new(); w * h], items: Vec::new(), stamp: Vec::new(), tick: Cell::new(0), free: Vec::new(), log: RefCell::new(Vec::new()) }+ }++ fn range(&self, bb: (f64, f64, f64, f64)) -> (usize, usize, usize, usize) {+ let cl = |v: f64, n: usize| -> usize { (v.max(0.0) as usize).min(n - 1) };+ (+ cl((bb.0 - self.x0) / self.cell, self.w),+ cl((bb.1 - self.y0) / self.cell, self.h),+ cl((bb.2 - self.x0) / self.cell, self.w),+ cl((bb.3 - self.y0) / self.cell, self.h),+ )+ }++ pub fn add(&mut self, shape: Shape, net: NetId, owner: Owner) -> u32 {+ let bb = shape.bbox();+ let fast = match &shape {+ Shape::Poly { pts, r } if pts.len() >= PolyIndex::MIN_POINTS => Some(Rc::new(PolyIndex::new(pts, *r))),+ _ => None,+ };+ self.log.borrow_mut().push((true, Obs { shape: shape.clone(), net, owner, bb, fast: fast.clone() }));+ let id = if let Some(i) = self.free.pop() {+ self.items[i as usize] = Some(Obs { shape, net, owner, bb, fast: fast.clone() });+ i+ } else {+ self.items.push(Some(Obs { shape, net, owner, bb, fast: fast.clone() }));+ self.stamp.push(Cell::new(0));+ (self.items.len() - 1) as u32+ };+ let (a, b, c, d) = self.range(bb);+ for j in b..=d {+ for i in a..=c {+ self.cells[j * self.w + i].push(id);+ }+ }+ id+ }++ pub fn remove(&mut self, id: u32) {+ let Some(o) = self.items[id as usize].take() else { return };+ self.log.borrow_mut().push((false, o.clone()));+ let (a, b, c, d) = self.range(o.bb);+ for j in b..=d {+ for i in a..=c {+ self.cells[j * self.w + i].retain(|&x| x != id);+ }+ }+ self.free.push(id);+ }++ /// Visit every item whose box meets `bb`. The visitor returns false to stop early.+ #[inline]+ pub fn visit(&self, bb: (f64, f64, f64, f64), mut f: impl FnMut(&Obs) -> bool) {+ let t = self.tick.get().wrapping_add(1);+ self.tick.set(t);+ let (a, b, c, d) = self.range(bb);+ for j in b..=d {+ for i in a..=c {+ for &id in &self.cells[j * self.w + i] {+ let s = &self.stamp[id as usize];+ if s.get() == t {+ continue;+ }+ s.set(t);+ let o = self.items[id as usize].as_ref().unwrap();+ if o.bb.0 > bb.2 || o.bb.2 < bb.0 || o.bb.1 > bb.3 || o.bb.3 < bb.1 {+ continue;+ }+ if !f(o) {+ return;+ }+ }+ }+ }+ }++ pub fn drain_log(&self) -> Vec<(bool, Obs)> {+ std::mem::take(&mut *self.log.borrow_mut())+ }++ pub fn iter(&self) -> impl Iterator<Item = &Obs> {+ self.items.iter().flatten()+ }+}++#[cfg(test)]+mod tests {+ use super::*;++ /// The index must agree with the exact distance wherever it matters, on a polygon with long+ /// edges of every slope and a notch, the shape a real pour has.+ #[test]+ fn indexed_distance_agrees_with_exact() {+ let mut pts = vec![V(0.0, 0.0), V(20.0, 3.0), V(23.0, 18.0), V(12.0, 9.0), V(9.0, 21.0), V(-2.0, 13.0)];+ // Enough points to be worth an index: subdivide every edge.+ let mut fine = Vec::new();+ for i in 0..pts.len() {+ let (a, b) = (pts[i], pts[(i + 1) % pts.len()]);+ for k in 0..8 {+ let t = k as f64 / 8.0;+ fine.push(V(a.0 + (b.0 - a.0) * t, a.1 + (b.1 - a.1) * t));+ }+ }+ pts = fine.iter().map(|p| V(p.0 * NM, p.1 * NM)).collect();+ let shape = Shape::Poly { pts: pts.clone(), r: 0.0 };+ let ix = PolyIndex::new(&pts, 0.0);+ let limit = 0.8 * NM;+ let mut x = 12345u64;+ let mut rnd = |m: f64| {+ x = x.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);+ (x >> 33) as f64 / (1u64 << 31) as f64 * m+ };+ for _ in 0..20000 {+ let a = V(rnd(28.0 * NM) - 3.0 * NM, rnd(28.0 * NM) - 3.0 * NM);+ let b = V(a.0 + rnd(6.0 * NM) - 3.0 * NM, a.1 + rnd(6.0 * NM) - 3.0 * NM);+ let exact = shape.dist_seg(a, b);+ let fast = ix.dist_within(a, b, limit);+ if exact < limit {+ assert!((exact - fast).abs() < 1.0, "{a:?} {b:?}: exact {exact}, indexed {fast}");+ } else {+ assert!(fast >= limit - 1.0, "{a:?} {b:?}: exact {exact}, indexed {fast}");+ }+ }+ }+}
src/main.rsadded+553@@ -0,0 +1,553 @@+//! adom-route: a whole-board PCB router built around the designer's routing standards.++mod board;+mod bus;+mod check;+mod classify;+mod clock;+mod compare;+mod conn;+mod current;+mod geom;+mod fixtures;+mod flow;+mod global;+mod grade;+mod index;+mod mask;+mod placement;+mod pour;+mod power;+mod post;+mod project;+mod quality;+mod render;+mod router;+mod rules;+mod spread;+mod search;+mod sx;+mod write;++use board::Board;+use clap::{Parser, Subcommand};+use rules::{Explicit, Rules};+use std::collections::BTreeMap;++/// The name this binary was built under (`adom-aiflow-routing`; `adom-route` is the same program).+const BIN: &str = env!("CARGO_BIN_NAME");++#[derive(Parser)]+#[command(name = BIN, version, about = "The routing stage of AI Flow: a whole-board PCB router and standards checker that tells the AI what to do next. A guide, never a gate.")]+struct Cli {+ /// The name of the AI conversation calling; it goes in the project's ledger+ #[arg(long, global = true)]+ ai_thread: Option<String>,+ /// Project folder (default: the nearest folder above the current one that has routing-flow/)+ #[arg(long, global = true)]+ project: Option<String>,+ #[command(subcommand)]+ cmd: Cmd,+}++#[derive(Subcommand)]+enum Cmd {+ /// Start the routing flow on a placed board: `init --board <B.kicad_pcb> [--spec <spec.json>]`+ Init {+ #[arg(long)]+ board: String,+ #[arg(long)]+ spec: Option<String>,+ },+ /// What to do now: the first step whose file is missing, and what the AI does in it (a suggestion)+ Next,+ /// Every step with its state+ Steps,+ /// The run ledger, one line per command+ Ledger,+ /// Route every open connection and write a new board file (the input is never changed)+ Route {+ /// The board to route (default in a project: the one `init` named)+ board: Option<String>,+ /// Where to write (default in a project: routing-flow/routed.kicad_pcb)+ #[arg(short, long)]+ out: Option<String>,+ /// Board spec JSON (clearance, track, wideNets, impedanceTargets, routePriority, ...)+ #[arg(long)]+ spec: Option<String>,+ #[arg(long)]+ json: bool,+ /// Clearance in mm. Overrides the project's Default class; the output project is updated to match+ #[arg(long)]+ clearance: Option<f64>,+ /// Track width in mm on open runs. Overrides the project; the output project is updated to match+ #[arg(long)]+ track: Option<f64>,+ /// Narrowest track in mm, for necks at fine-pitch parts. Overrides the project's minimum track+ #[arg(long)]+ neck: Option<f64>,+ /// Via diameter in mm. Overrides the project; the output project is updated to match+ #[arg(long)]+ via: Option<f64>,+ /// Via drill in mm. Overrides the project; the output project is updated to match+ #[arg(long)]+ drill: Option<f64>,+ /// Stop searching after this many seconds and write the best result so far. By default it+ /// scales with the board: at least 240 s, plus 2.6 s per connection on larger boards+ #[arg(long)]+ time_limit: Option<f64>,+ },+ /// Fast tests: route a few nets of each fixture's board alone and judge the result+ Fixtures {+ file: String,+ /// Run only the fixtures whose name contains this+ #[arg(long)]+ only: Option<String>,+ /// Keep the stripped and routed boards in this folder+ #[arg(long)]+ keep: Option<String>,+ /// Run exactly this one fixture in this process (what the command starts for each fixture)+ #[arg(long, hide = true)]+ one: Option<String>,+ },+ /// Grade a board against the routing standards+ Check {+ /// The board to grade (default in a project: the routed board, else the one `init` named)+ board: Option<String>,+ #[arg(long)]+ spec: Option<String>,+ #[arg(long)]+ json: bool,+ /// Print every finding, not only the summary+ #[arg(long)]+ all: bool,+ },+ /// Run the checker over the reference boards and compare with an expected-results file+ Grade {+ #[arg(default_value = "bench/grade.json")]+ file: String,+ #[arg(long)]+ json: bool,+ },+ /// Write a copy with all tracks and vias removed (to measure routing against a reference)+ Strip {+ board: String,+ #[arg(short, long)]+ out: String,+ },+ /// Draw the copper to an SVG, optionally a window x0,y0,x1,y1 in mm+ Draw {+ board: String,+ #[arg(short, long)]+ out: String,+ #[arg(long)]+ window: Option<String>,+ /// Draw zones' filled copper as saved, not their outlines+ #[arg(long)]+ fills: bool,+ /// Comma-separated net names to draw at full strength; the rest are dimmed+ #[arg(long)]+ highlight: Option<String>,+ /// Do not name the pads in a window+ #[arg(long)]+ no_labels: bool,+ },+ /// Net by net against a reference board: length, vias, layers, widths, bends, spacing+ Compare {+ board: String,+ reference: String,+ #[arg(long)]+ spec: Option<String>,+ #[arg(long)]+ json: bool,+ },+ /// Show how the board's nets sort into the routing hierarchy+ Plan {+ /// The board to read (default in a project: the one `init` named)+ board: Option<String>,+ #[arg(long)]+ spec: Option<String>,+ },+}++/// The one kind of error: the command could not run. Says what to try next. Everything else a+/// command finds (open connections, rule findings, a failed test) is reported under `OK:` with+/// hints, because the board, the AI and the human decide what to do with it, not this program.+fn fail(msg: impl std::fmt::Display, hint: &str) -> ! {+ println!("ERROR: {msg}");+ if !hint.is_empty() {+ println!("Hint: {hint}");+ }+ std::process::exit(1)+}++fn hint(text: impl std::fmt::Display) {+ println!("Hint: {text}");+}++/// The project the command runs in, if any: `--project`, else the nearest one above `near` or above+/// the current folder. Commands work the same without one; with one they keep their files and a+/// ledger line in `routing-flow/`.+fn find_project(cli_project: &Option<String>, near: Option<&str>) -> Option<flow::Project> {+ if let Some(p) = cli_project {+ return flow::Project::find(std::path::Path::new(p));+ }+ let from_board = near.and_then(|b| std::path::Path::new(b).parent().map(|d| if d.as_os_str().is_empty() { std::path::PathBuf::from(".") } else { d.to_path_buf() })).and_then(|d| flow::Project::find(&d));+ from_board.or_else(|| std::env::current_dir().ok().and_then(|d| flow::Project::find(&d)))+}++fn state_str(p: &Option<flow::Project>, key: &str) -> Option<String> {+ p.as_ref().and_then(|p| p.state[key].as_str().map(String::from)).filter(|s| !s.is_empty())+}++/// A positional board, or the project's; with neither the command cannot run.+fn board_or(given: Option<String>, p: &Option<flow::Project>, verb: &str) -> String {+ given.or_else(|| state_str(p, "board")).unwrap_or_else(|| fail(format!("no board to {verb}"), &format!("Pass one: `{BIN} {verb} <board.kicad_pcb>`, or start a project so every command knows it: `{BIN} init --board <board.kicad_pcb>`.")))+}++fn load(board: &str, spec: &Option<String>) -> (Board, Rules) {+ let (b, r, _) = load_notes(board, spec, Explicit::default());+ (b, r)+}++fn load_notes(board: &str, spec: &Option<String>, named: Explicit) -> (Board, Rules, Vec<String>) {+ let b = Board::read(board).unwrap_or_else(|e| fail(e, "Pass a .kicad_pcb file written by KiCad 9 or 10."));+ let mut r = match spec {+ Some(s) => Rules::load(s).unwrap_or_else(|e| fail(e, "Pass a JSON spec, or leave --spec off to use the standing rules.")),+ None => Rules::default(),+ };+ // What was named on the command line beats the spec, and both beat the project.+ if let Some(x) = named.clearance { r.clearance = x; r.floor = x; r.explicit.clearance = Some(x); }+ if let Some(x) = named.track { r.track = x; r.explicit.track = Some(x); }+ if let Some(x) = named.neck { r.neck = x; r.explicit.neck = Some(x); }+ if let Some(x) = named.via { r.via = x; r.explicit.via = Some(x); }+ if let Some(x) = named.drill { r.drill = x; r.explicit.drill = Some(x); }+ let mut notes = project::apply(board, &mut r);+ r.bind(&b);+ notes.extend(classify::find_pairs(&b, &mut r));+ (b, r, notes)+}++fn write_guard(input: &str, out: &str) {+ if let (Ok(a), Ok(b)) = (std::fs::canonicalize(input), std::fs::canonicalize(out)) {+ if a == b {+ fail("the output path is the input board, and the input is never changed", "Give -o a different file: a new revision beside the old one (4.6).");+ }+ }+ if input == out {+ fail("the output path is the input board, and the input is never changed", "Give -o a different file: a new revision beside the old one (4.6).");+ }+}++fn summary(json: &serde_json::Value) -> String {+ let s = &json["score"];+ format!(+ "{} errors, {} warnings. Open: {} nets ({} joins). Copper: {} mm ({} top, {} inner, {} bottom), {} vias, {} segments, {} 45-degree and {} 90-degree bends, tightest gap {} mm.",+ json["errors"], json["warnings"], s["netsOpen"], s["padsOpen"], s["lengthMm"], s["topMm"], s["innerMm"], s["bottomMm"], s["vias"], s["segments"], s["bends45"], s["bends90"], s["minGapMm"]+ )+}++fn main() {+ // Output cut short by the reader (`... | head`) is not a failure of the command: end quietly.+ let default_hook = std::panic::take_hook();+ std::panic::set_hook(Box::new(move |info| {+ if info.to_string().contains("Broken pipe") {+ std::process::exit(0);+ }+ default_hook(info);+ }));+ let cli = Cli::parse();+ let thread = cli.ai_thread.clone().unwrap_or_else(|| "unnamed".to_string());+ let cli_project = cli.project.clone();+ match cli.cmd {+ Cmd::Init { board, spec } => {+ let b = Board::read(&board).unwrap_or_else(|e| fail(e, "Pass a .kicad_pcb file written by KiCad 9 or 10."));+ let abs = |p: &str| std::fs::canonicalize(p).map(|x| x.display().to_string()).unwrap_or_else(|_| p.to_string());+ let dir = cli_project.clone().map(std::path::PathBuf::from).unwrap_or_else(|| std::path::Path::new(&abs(&board)).parent().map(|d| d.to_path_buf()).unwrap_or_else(|| std::path::PathBuf::from(".")));+ let pro = std::path::Path::new(&board).with_extension("kicad_pro");+ let state = serde_json::json!({+ "flow": "routing", "started": flow::now(), "board": abs(&board), "boardSha256": flow::sha256(std::path::Path::new(&board)),+ "spec": spec.as_deref().map(abs), "project": if pro.is_file() { serde_json::json!(abs(&pro.display().to_string())) } else { serde_json::Value::Null },+ "footprints": b.footprints.len(), "tracks": b.tracks.len(), "vias": b.vias.len()});+ let p = flow::Project::create(&dir, state).unwrap_or_else(|e| fail(format!("could not write {}: {e}", dir.join(flow::FLOW_DIR).display()), "Pass --project <a folder you can write to>."));+ p.log(&thread, "init", serde_json::json!({"board": abs(&board), "spec": spec}));+ println!("OK: routing flow started in {}: {} parts, {} tracks and {} vias already on the board.", p.flow_dir.display(), b.footprints.len(), b.tracks.len(), b.vias.len());+ if !pro.is_file() {+ hint(format!("No {} beside the board: rules come from KiCad's defaults (0.2 mm clearance and track, 0.5 mm to the edge). Put the project file there if the design has its own.", pro.display()));+ }+ if spec.is_none() {+ hint("No spec: supply rails have no currents, so they are drawn at the 0.40 mm floor. A spec with each rail's volts and amps lets the power stage size them; see the skill for its fields.");+ }+ if cli.ai_thread.is_none() {+ hint("Pass --ai-thread \"<your conversation's name>\" so the ledger says who ran what.");+ }+ hint(format!("Next: `{BIN} next`."));+ }+ Cmd::Next | Cmd::Steps | Cmd::Ledger => {+ let p = find_project(&cli_project, None).unwrap_or_else(|| fail("no routing-flow/ project here", &format!("Start one: `{BIN} init --board <board.kicad_pcb>`. Or use the verbs on their own: `{BIN} plan|route|check <board>` need no project.")));+ let steps = flow::steps();+ match cli.cmd {+ Cmd::Next => match p.current(&steps) {+ Some((i, s)) => {+ println!("OK: step {i} of {}: {}", steps.len(), s.name);+ hint(&s.todo);+ hint("This is the suggested order, not a gate: take another route when you have a better one, and say in the ledger or the review what you did and why.");+ }+ None => {+ println!("OK: every step has its file. The routed board is {}.", p.path("routed.kicad_pcb").display());+ hint("Show the human the board and the review before it goes on; what is still open is theirs to accept.");+ }+ },+ Cmd::Steps => {+ let cur = p.current(&steps).map(|(i, _)| i);+ println!("OK: {} steps in {}", steps.len(), p.flow_dir.display());+ for (i, s) in steps.iter().enumerate() {+ println!(" {} {:<8} {}", if p.is_done(s) { "done" } else if cur == Some(i + 1) { "next" } else { " " }, s.name, s.done.join(", "));+ }+ }+ _ => {+ let text = std::fs::read_to_string(p.path("run.jsonl")).unwrap_or_default();+ println!("OK: {} ledger lines in {}", text.lines().count(), p.path("run.jsonl").display());+ print!("{text}");+ }+ }+ }+ Cmd::Strip { board, out } => {+ write_guard(&board, &out);+ let b = Board::read(&board).unwrap_or_else(|e| fail(e, ""));+ std::fs::write(&out, write::strip(&b)).unwrap_or_else(|e| fail(e, ""));+ println!("OK: wrote {out} without its {} tracks and {} vias.", b.tracks.len(), b.vias.len());+ }+ Cmd::Draw { board, out, window, fills, highlight, no_labels } => {+ let b = Board::read(&board).unwrap_or_else(|e| fail(e, ""));+ let w = window.map(|w| {+ let v: Vec<f64> = w.split(',').filter_map(|x| x.parse().ok()).collect();+ if v.len() != 4 {+ fail("window must be x0,y0,x1,y1 in mm", "");+ }+ (v[0], v[1], v[2], v[3])+ });+ let view = render::View { fills, highlight: highlight.map(|h| h.split(',').map(String::from).collect()).unwrap_or_default(), labels: w.is_some() && !no_labels };+ std::fs::write(&out, render::svg_view(&b, w, &view)).unwrap_or_else(|e| fail(e, ""));+ println!("OK: wrote {out}.");+ }+ Cmd::Grade { file, json } => {+ let o = grade::run(&file).unwrap_or_else(|e| fail(e, "Pass a grade file such as bench/grade.json."));+ if json {+ println!("{}", serde_json::to_string_pretty(&o.json).unwrap());+ } else {+ for l in &o.lines {+ println!("{l}");+ }+ println!("OK: graded. {}", if o.ok { "Every board came out as the expected-results file says." } else { "The checker disagrees with the expected-results file on the lines marked FAIL above." });+ if !o.ok {+ hint("Either the checker changed or the boards did: read the FAIL lines, then fix the rule or update the expected file, whichever is wrong.");+ }+ }+ }+ Cmd::Plan { board, spec } => {+ let proj = find_project(&cli_project, board.as_deref());+ let board = board_or(board, &proj, "plan");+ let spec = spec.or_else(|| state_str(&proj, "spec"));+ let (b, r) = load(&board, &spec);+ let infos = classify::classify(&b, &r);+ let mut by: BTreeMap<classify::Level, Vec<&str>> = BTreeMap::new();+ for i in &infos {+ by.entry(i.level).or_default().push(b.net_name(i.net));+ }+ let plan = router::make_plan(&b, &r);+ let mut out: Vec<String> = Vec::new();+ out.push(format!("OK: {} nets; {} connections to make, {} pairs.", infos.len(), plan.tasks.len(), plan.pairs.len()));+ for (l, v) in by {+ out.push(format!(" {:<18} {}", l.name(), v.join(" ")));+ }+ let fine: Vec<&str> = b.footprints.iter().enumerate().filter(|(i, _)| classify::is_fine_pitch(&b, &r, *i)).map(|(_, f)| f.reference.as_str()).collect();+ out.push(format!(" fine pitch: {}", fine.join(" ")));+ // Why, per net: 4.2 says never to judge current from a name or width alone, so the+ // designer can read what each class was judged from.+ for i in &infos {+ out.push(format!(" net {} = {}{}: {}", b.net_name(i.net), i.level.name(), if i.switching { " (switch node)" } else { "" }, i.why));+ for t in &i.taps {+ out.push(format!(" Kelvin tap: {}", t.iter().map(|&p| b.pad_name(p)).collect::<Vec<_>>().join(" -> ")));+ }+ }+ let mut zs: BTreeMap<String, usize> = BTreeMap::new();+ for z in &b.zones {+ let names: Vec<&str> = z.layers.iter().map(|&l| b.layers[l].name.as_str()).collect();+ *zs.entry(format!("{} on {} keepout={} fills={}", b.net_name(z.net), names.join("+"), z.keepout_tracks, z.fills.len())).or_insert(0) += 1;+ }+ out.push(format!(" zones: {zs:?}"));+ for (p, n, w, g) in &plan.pairs {+ out.push(format!(" pair {} / {}: {:.4}/{:.3} mm", b.net_name(*p), b.net_name(*n), *w as f64 / geom::NM, *g as f64 / geom::NM));+ }+ println!("{}", out.join("\n"));+ // Supply nets with no current to size them by: said as a hint, since only the designer+ // (or a datasheet) knows the number.+ let unrated: Vec<&str> = infos.iter().filter(|i| i.level == classify::Level::Power && r.rail(b.net_name(i.net)).is_none()).map(|i| b.net_name(i.net)).collect();+ if !unrated.is_empty() {+ hint(format!("{} supply net(s) have no current in the spec and will be drawn at the 0.40 mm floor: {}. A rail entry with volts and amps lets the power stage size each one: a rail the board makes is rated by its regulator, a rail it only receives by the draw of the parts that take it (the prompt, their wiki pages and datasheets). When nothing says, ask the human for the expected draw.", unrated.len(), unrated.iter().take(12).cloned().collect::<Vec<_>>().join(", ")));+ }+ if let Some(p) = &proj {+ let _ = std::fs::write(p.path("plan.txt"), out.join("\n") + "\n");+ p.log(&thread, "plan", serde_json::json!({"board": board, "spec": spec, "nets": infos.len(), "connections": plan.tasks.len(), "pairs": plan.pairs.len(), "unratedRails": unrated.len()}));+ hint(format!("Saved as {}. Next: `{BIN} next`.", p.path("plan.txt").display()));+ }+ }+ Cmd::Compare { board, reference, spec, json } => {+ let (b, r) = load(&board, &spec);+ let (his, _) = load(&reference, &spec);+ let v = compare::report(&b, &his, &r, json);+ if json {+ println!("{}", serde_json::to_string_pretty(&v).unwrap());+ }+ }+ Cmd::Fixtures { file, only, keep, one } => {+ let one_given = one.is_some();+ let ok = match one {+ Some(name) => fixtures::run(&file, Some(&name), keep.as_deref()),+ None => fixtures::run_all(&file, only.as_deref(), keep.as_deref()),+ };+ // The hidden --one child tells its parent by exit code; the command itself only reports.+ if !ok && one_given {+ std::process::exit(1);+ }+ if !ok {+ hint("A FAIL line is a behaviour that changed: read its board in the --keep folder before deciding whether the router or the fixture is wrong.");+ }+ }+ Cmd::Check { board, spec, json, all } => {+ let proj = find_project(&cli_project, board.as_deref());+ let routed = proj.as_ref().map(|p| p.path("routed.kicad_pcb")).filter(|p| p.is_file()).map(|p| p.display().to_string());+ let board = board.or(routed).unwrap_or_else(|| board_or(None, &proj, "check"));+ let spec = spec.or_else(|| state_str(&proj, "spec"));+ let (b, r) = load(&board, &spec);+ let (f, s) = check::check(&b, &r);+ let j = check::to_json(&f, &s);+ let errors = j["errors"].as_u64().unwrap_or(0);+ if json {+ println!("{}", serde_json::to_string_pretty(&j).unwrap());+ } else {+ // The check ran, so it answers OK; what it found follows. A finding is a report with a+ // suggestion, never a verdict: the designer may have drawn that copper on purpose.+ println!("OK: checked {board}: {}", summary(&j));+ println!(" per rule: {}", j["perRule"]);+ println!(" widths: {}", j["score"]["widths"]);+ let show = if all { f.len() } else { 25 };+ for x in f.iter().take(show) {+ println!(" {:<5} {:<14} ({:.2}, {:.2}) {}", x.severity, x.rule, x.at.0, x.at.1, x.text);+ }+ if f.len() > show {+ println!(" ... {} more (--all shows them)", f.len() - show);+ }+ if errors != 0 {+ hint("Each finding names its rule and place; --all lists every one. Fix what is real and check again. A finding on copper the designer drew on purpose is theirs to keep: say so in the review.");+ }+ hint("KiCad's own DRC with zones refilled, against the board's project, is the judge of clearance and of what is really joined; run it on the same file.");+ }+ if let Some(p) = &proj {+ let _ = std::fs::write(p.path("check.json"), serde_json::to_string_pretty(&j).unwrap_or_default() + "\n");+ p.log(&thread, "check", serde_json::json!({"board": board, "errors": errors, "warnings": j["warnings"], "perRule": j["perRule"]}));+ if !json {+ hint(format!("Saved as {}. Next: `{BIN} next`.", p.path("check.json").display()));+ }+ }+ }+ Cmd::Route { board, out, spec, json, clearance, track, neck, via, drill, time_limit } => {+ let proj = find_project(&cli_project, board.as_deref());+ let board = board_or(board, &proj, "route");+ let spec = spec.or_else(|| state_str(&proj, "spec"));+ let out = out.or_else(|| proj.as_ref().map(|p| p.path("routed.kicad_pcb").display().to_string())).unwrap_or_else(|| fail("nowhere to write the routed board", &format!("Pass -o <routed.kicad_pcb>, or start a project (`{BIN} init --board {board}`) and it goes to routing-flow/routed.kicad_pcb.")));+ write_guard(&board, &out);+ let named = Explicit { clearance: clearance.map(geom::mm), track: track.map(geom::mm), neck: neck.map(geom::mm), via: via.map(geom::mm), drill: drill.map(geom::mm) };+ let (b, mut r, rule_notes) = load_notes(&board, &spec, named);+ // Copper already on the board narrower than the settings allow: the settings may be wrong+ // for this design, or those tracks are. That is the designer's to say. The router leaves+ // them as they are, lays new copper to the settings, and says so at the end.+ let floor = r.min.track.max(0);+ let narrow: Vec<&board::Track> = b.tracks.iter().filter(|t| t.width + 1000 < floor).collect();+ let narrow_hint = narrow.iter().min_by_key(|t| t.width).map(|n| {+ format!(+ "{} tracks already on the board are narrower than the {:.3} mm minimum (the narrowest {:.3} mm on {} at ({:.2}, {:.2})). They were left as they are and new copper follows the minimum. If the thin ones are right for this design, pass --neck {:.3} and route again; if not, they are for the designer to fix.",+ narrow.len(), geom::to_mm(floor), geom::to_mm(n.width), b.net_name(n.net), geom::to_mm(n.a.0), geom::to_mm(n.a.1), geom::to_mm(n.width)+ )+ });+ for n in &rule_notes {+ eprintln!("note: {n}");+ }+ let plan = router::make_plan(&b, &r);+ // Large boards need the time: benchmark C reached 278 of 302 at 240 s and 298 at 800 s.+ let joins = plan.tasks.len() + plan.pairs.len() * 2;+ // In work-clock seconds (clock.rs), which are calibrated to the densest benchmark board: a+ // small board does about twice the search work per wall second, so the old 2.6 s a join and+ // 240 s floor, kept as they were, halved every small board's budget (carrier-fc-rp2350a-mini+ // stopped at 62 of 74 with "ran out of budget", 2026-11-10). 5.5 and 505 give back the work+ // those wall-clock limits meant.+ r.time_limit = time_limit.unwrap_or((5.5 * joins as f64).max(505.0));+ let mut rt = router::Router::new(&b, r.clone());+ let o = rt.run(&plan);+ let routes: Vec<&router::Route> = {+ let mut v: Vec<&router::Route> = rt.routes.values().collect();+ v.sort_by_key(|x| x.id);+ v+ };+ let text = write::render(&b, &routes, &r);+ std::fs::write(&out, &text).unwrap_or_else(|e| fail(e, ""));+ let updated = project::write_for_output(&board, &out, &r.explicit).unwrap_or_else(|e| fail(e, ""));+ r.pad_rules = rt.rules.pad_rules.clone();+ if let Some(path) = project::write_dru(&board, &out, &r.pad_rules).unwrap_or_else(|e| fail(e, "")) {+ if !r.pad_rules.is_empty() {+ println!(" REVIEW: {} local pad rule(s) proposed in {path}", r.pad_rules.len());+ }+ }+ for u in &updated {+ println!(" project updated: {u}");+ }+ // Grade the written file from scratch; the checker shares no state with the router.+ let written = Board::parse(text).unwrap_or_else(|e| fail(e, "The written board did not parse back; this is a bug in the writer."));+ let (f, s) = check::check(&written, &r);+ let j = check::to_json(&f, &s);+ let failed: Vec<String> = o.failed.iter().map(|t| format!("{} ({}): {} to {}", b.net_name(t.net), t.level.name(), b.pad_name(t.a), b.pad_name(t.b))).collect();+ if json {+ let out_json = serde_json::json!({"out": out, "seconds": o.seconds, "failed": failed, "rips": rt.rips, "searches": rt.searches, "nodes": rt.nodes, "notes": rt.notes, "check": j});+ println!("{}", serde_json::to_string_pretty(&out_json).unwrap());+ } else {+ let tasks = plan.tasks.len() + plan.pairs.len() * 2;+ // Route's own outcome: did every connection get made? The written board's grade is+ // reported on the next line, and `check` is what passes or fails a board.+ println!("OK: wrote {out}: {} of {} connections routed in {:.1} s ({} rip-ups).", tasks.saturating_sub(o.failed.len()), tasks, o.seconds, rt.rips);+ println!(" check: {}", summary(&j));+ println!(" per rule: {}", j["perRule"]);+ println!(" widths: {}", j["score"]["widths"]);+ for x in &failed {+ println!(" unrouted: {x}");+ }+ for n in &rt.notes {+ println!(" note: {n}");+ }+ for l in &rt.log {+ println!(" {l}");+ }+ println!(" {}", rt.world.guide.borrow().summary());+ for x in f.iter().filter(|x| x.severity == "error").take(12) {+ println!(" {:<5} {:<14} ({:.2}, {:.2}) {}", x.severity, x.rule, x.at.0, x.at.1, x.text);+ }+ if !o.failed.is_empty() {+ hint(format!("{} connection(s) are open; each has its reason in the notes above. Ideas, yours to weigh: move the part whose pad is walled in, give a supply rail its current in the spec so it is planned first, allow more work with --time-limit, or route those few by hand.", o.failed.len()));+ }+ if !r.pad_rules.is_empty() {+ hint("The local pad rules marked REVIEW let a track pass a pad closer than its class asks, where no track fits otherwise. They are proposals for the human: keep the .kicad_dru beside the board only if they agree.");+ }+ if let Some(h) = &narrow_hint {+ hint(h);+ }+ }+ if let Some(p) = &proj {+ let tasks = plan.tasks.len() + plan.pairs.len() * 2;+ p.log(&thread, "route", serde_json::json!({"board": board, "out": out, "connections": tasks, "open": o.failed.len(), "workSeconds": o.seconds, "checkErrors": j["errors"], "localRules": r.pad_rules.len()}));+ if !json {+ hint(format!("Look at it before trusting the numbers: `{BIN} draw {out} -o routed.svg`. Next: `{BIN} next`."));+ }+ }+ }+ }+}
src/mask.rsadded+197@@ -0,0 +1,197 @@+//! Solder mask bridges, as KiCad's DRC reports them. Worked out from its output on the designer's boards+//! and the routed PiProbe, not from a guess:+//!+//! copper of net A bridges the opening of a pad of net B on the same outer layer when the edge+//! to edge distance from that copper to the pad's copper is under+//! pad mask margin + solder_mask_to_copper_clearance.+//!+//! The margin is the pad's own, else the footprint's, else the board's pad_to_mask_clearance. The+//! evidence: on the routed PiProbe (margin 0.102, project 0.1, limit 0.202) a track at 0.171 and a+//! teardrop fill at 0.20002 from a pad are reported; on his v1.3 the same teardrop's fill at 0.2066+//! from the same pad is not; on the benchmark B a via 0.1 from pad J1.B7 is reported, the only one on+//! the board. So a route at the project clearance (0.17) can still bridge a 0.102 pad. Tenting does+//! not change this: the via's own mask does not cover what the pad's opening exposes.+//!+//! Not modelled: openings drawn as F.Mask or B.Mask graphics, and teardrops KiCad has not generated+//! yet. A route with no teardrops in the file gets them at refill, and KiCad's DRC can then report+//! a teardrop that this cannot see in advance.++use crate::board::*;+use crate::geom::*;+use crate::index::*;+use crate::rules::Rules;++pub struct MaskMap {+ /// Added to each pad's limit: a hair under for judging, KiCad's polygon error over for laying.+ slack: f64,+ front: Grid,+ back: Grid,+ last: usize,+ /// Per pad: the limit for foreign copper, margin + mask-to-copper.+ limit: Vec<f64>,+ reach: f64,+}++impl MaskMap {+ pub fn new(board: &Board, rules: &Rules) -> MaskMap {+ let extra = rules.min.mask_to_copper as f64;+ let bb = if board.edges.is_empty() {+ let mut b = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);+ for p in &board.pads {+ let s = p.shape.bbox();+ b = (b.0.min(s.0), b.1.min(s.1), b.2.max(s.2), b.3.max(s.3));+ }+ b+ } else {+ board.bbox+ };+ let mut m = MaskMap { slack: -500.0, front: Grid::new(bb, 1.0 * NM), back: Grid::new(bb, 1.0 * NM), last: board.layers.len() - 1, limit: Vec::with_capacity(board.pads.len()), reach: 0.0 };+ for (i, p) in board.pads.iter().enumerate() {+ let limit = (p.mask_margin + extra).max(0.0);+ m.limit.push(limit);+ // A hole with no copper has an opening too, but nothing of its own to bridge to.+ if p.npth || p.layers.is_empty() {+ continue;+ }+ m.reach = m.reach.max(limit);+ if p.mask.0 && p.layers.contains(&0) {+ m.front.add(p.shape.clone(), p.net, Owner::Pad(i as u32));+ }+ if p.mask.1 && p.layers.contains(&m.last) {+ m.back.add(p.shape.clone(), p.net, Owner::Pad(i as u32));+ }+ }+ m+ }++ /// For laying copper rather than judging it: KiCad tests mask openings as polygons, whose arcs+ /// may sit up to the project's max_error (0.005 mm) off the true curve, so new copper keeps that+ /// much more away. adom/rp2350a-ads1220: two vias placed 0.1899 mm from a rounded pad corner+ /// against a 0.19 mm limit were exact by the arc and bridged by KiCad's polygon.+ pub fn guarded(mut self) -> MaskMap {+ self.slack = 6_000.0;+ self+ }++ /// How much farther than the limit copper being laid keeps from a curve (0 when judging).+ pub fn guard(&self) -> f64 {+ self.slack.max(0.0)+ }++ /// The limit for foreign copper around a pad: route guide lines hug at this distance.+ pub fn limit_of(&self, pad: usize) -> f64 {+ self.limit.get(pad).copied().unwrap_or(0.0)+ }++ fn grid(&self, layer: usize) -> Option<&Grid> {+ if layer == 0 {+ Some(&self.front)+ } else if layer == self.last {+ Some(&self.back)+ } else {+ None+ }+ }++ fn first_hit(&self, net: NetId, also: NetId, layer: usize, bb: (f64, f64, f64, f64), dist: impl Fn(&Shape) -> f64) -> Option<(usize, f64)> {+ self.first_hit_slack(net, also, layer, bb, dist, -500.0)+ }++ fn first_hit_slack(&self, net: NetId, also: NetId, layer: usize, bb: (f64, f64, f64, f64), dist: impl Fn(&Shape) -> f64, slack: f64) -> Option<(usize, f64)> {+ let g = self.grid(layer)?;+ let r = self.reach;+ let mut hit: Option<(usize, f64)> = None;+ let r = r + slack.max(0.0);+ g.visit((bb.0 - r, bb.1 - r, bb.2 + r, bb.3 + r), |o| {+ if o.net == net || (also != NO_NET && o.net == also) {+ return true;+ }+ if let Owner::Pad(i) = o.owner {+ let d = dist(&o.shape);+ if d < self.limit[i as usize] + slack && hit.map(|h| d < h.1).unwrap_or(true) {+ hit = Some((i as usize, d));+ }+ }+ true+ });+ hit+ }++ pub fn seg_hit(&self, net: NetId, layer: usize, a: V, b: V, half: f64) -> Option<(usize, f64)> {+ self.seg_hit_also(net, NO_NET, layer, a, b, half)+ }++ /// Whether the nearest part of a pad's outline to the segment a-b is a curve the segment passes+ /// at an angle. Only there can KiCad's polygon stand off the true outline: where the segment runs+ /// parallel to a straight side (a track leaving a fine-pitch row between its neighbours) the+ /// polygon and the outline are the same line.+ fn curved_near(shape: &Shape, a: V, b: V, half: f64, d: f64) -> bool {+ let dir = sub(b, a);+ let len = hypot(dir);+ let parallel = |e: V| len > 0.0 && hypot(e) > 0.0 && ((dir.0 * e.1 - dir.1 * e.0) / (len * hypot(e))).abs() < 0.02;+ match shape {+ Shape::Circle { .. } => true,+ Shape::Capsule { a: ca, b: cb, r } => !parallel(sub(*cb, *ca)) && (pt_seg(*ca, a, b).min(pt_seg(*cb, a, b)) - r - half) <= d + 200.0,+ Shape::Poly { pts, r } => {+ // A rounded rectangle is stored as its outline with short facets round each corner+ // (board.rs `rect_pts`), radius 0: a corner vertex is one with a short facet beside it.+ let n = pts.len();+ let short = 0.06 * NM;+ (0..n).any(|i| {+ let v = pts[i];+ if pt_seg(v, a, b) - r - half > d + 200.0 {+ return false;+ }+ let (e1, e2) = (sub(v, pts[(i + n - 1) % n]), sub(pts[(i + 1) % n], v));+ if *r > 0.0 {+ return !parallel(e1) && !parallel(e2);+ }+ let (s1, s2) = (hypot(e1) < short, hypot(e2) < short);+ (s1 || s2) && !((!s1 && parallel(e1)) || (!s2 && parallel(e2)))+ })+ }+ Shape::Multi(parts) => parts.iter().any(|p| Self::curved_near(p, a, b, half, d)),+ }+ }++ /// `seg_hit` for copper shared by a pair: the partner's own pads are not another net's. Starting+ /// between its two pads, every first move of a coupled run met the partner's opening otherwise.+ pub fn seg_hit_also(&self, net: NetId, also: NetId, layer: usize, a: V, b: V, half: f64) -> Option<(usize, f64)> {+ let bb = (a.0.min(b.0) - half, a.1.min(b.1) - half, a.0.max(b.0) + half, a.1.max(b.1) + half);+ if self.slack <= 0.0 {+ return self.first_hit(net, also, layer, bb, |s| (s.dist_seg(a, b) - half).max(0.0));+ }+ // Laying: the guard counts only past a curve (see `curved_near`); a straight side keeps the+ // exact limit. The distance handed back is lifted by the guard where it does not apply, so+ // the one comparison in `first_hit_slack` serves both.+ let slack = self.slack;+ self.first_hit_slack(net, also, layer, bb, |s| {+ let d = (s.dist_seg(a, b) - half).max(0.0);+ if Self::curved_near(s, a, b, half, d) { d } else { d + slack + 500.0 }+ }, slack)+ }++ /// A through via at `at`, radius `r`: tested against the openings on both outer layers.+ pub fn via_hit(&self, net: NetId, at: V, r: f64) -> Option<(usize, f64)> {+ let bb = (at.0 - r, at.1 - r, at.0 + r, at.1 + r);+ let d = |s: &Shape| (s.dist_pt(at) - r).max(0.0);+ // Only vias take the laying guard: a via and a rounded pad corner are both arcs KiCad turns+ // into polygons. A track's sides are straight and exact, and 6 um more on those shut the+ // escapes of a 0.4 mm pitch part (rp2350a-ads1220 lost nine joins to it).+ self.first_hit_slack(net, NO_NET, 0, bb, d, self.slack).or_else(|| self.first_hit_slack(net, NO_NET, self.last, bb, d, self.slack))+ }++ /// Copper KiCad hasn't drawn yet, predicted as a polygon: `slack` allows for the prediction.+ pub fn poly_near(&self, net: NetId, layer: usize, pts: &[V], slack: f64) -> Option<(usize, f64)> {+ let poly = Shape::Poly { pts: pts.to_vec(), r: 0.0 };+ let bb = poly.bbox();+ self.first_hit(net, NO_NET, layer, (bb.0 - slack, bb.1 - slack, bb.2 + slack, bb.3 + slack), |s| (poly.dist_shape(s) - slack).max(0.0))+ }++ /// Filled copper given as a polygon, such as a teardrop.+ pub fn poly_hit(&self, net: NetId, layer: usize, pts: &[V]) -> Option<(usize, f64)> {+ let poly = Shape::Poly { pts: pts.to_vec(), r: 0.0 };+ let bb = poly.bbox();+ self.first_hit(net, NO_NET, layer, bb, |s| poly.dist_shape(s))+ }+}
src/placement.rsadded+232@@ -0,0 +1,232 @@+//! The placement rules routing depends on, measured on the copper pads (4.1). They grade the layouts+//! The designer rejected: a router can't rescue a crystal or a decoupling cap in the wrong place, and his+//! rule (R1, 4.1) is that a layout which looks fine by distances alone is not fine.++use crate::board::*;+use crate::check::Finding;+use crate::classify::*;+use crate::geom::*;+use crate::rules::Rules;++fn at_mm(p: P) -> (f64, f64) {+ (to_mm(p.0), to_mm(p.1))+}++fn d_center(board: &Board, a: usize, b: usize) -> f64 {+ hypot(sub(board.pads[a].center.into(), board.pads[b].center.into()))+}++fn d_edge(board: &Board, a: usize, b: usize) -> f64 {+ board.pads[a].shape.dist_shape(&board.pads[b].shape)+}++/// An IC on this board. A molecule plugged in as a part (benchmark B's U2, Molecule_RP2350A_v1)+/// carries its own decoupling on its own board, so its contacts are not supply pins here.+fn is_ic(board: &Board, fp: usize) -> bool {+ let f = &board.footprints[fp];+ matches!(ref_prefix(&f.reference), "U" | "IC") && f.pads.len() > 4 && !f.lib.to_ascii_lowercase().contains("molecule")+}++/// Distance of `p` from the line a-b, and how far along it (0 at a, 1 at b).+fn along(p: V, a: V, b: V) -> (f64, f64) {+ let ab = sub(b, a);+ let l2 = dot(ab, ab).max(1.0);+ let t = dot(sub(p, a), ab) / l2;+ let q = V(a.0 + ab.0 * t, a.1 + ab.1 * t);+ (hypot(sub(p, q)), t)+}++/// Crystal laid out as a circuit (4.1): the XOUT series resistor inline at the MCU pin, each load+/// cap's signal pad on the line from the pin to the crystal pad. The crystal's distance from the+/// MCU is not the measure; the line is.+pub fn crystal_rules(board: &Board, f: &mut Vec<Finding>) {+ for (fi, x) in board.footprints.iter().enumerate() {+ if !is_crystal(board, fi) {+ continue;+ }+ for &xp in &x.pads {+ let net = board.pads[xp].net;+ let name = board.net_name(net);+ if net == NO_NET || name.starts_with("unconnected-") || looks_ground(name) {+ continue;+ }+ let on_net: Vec<usize> = (0..board.pads.len()).filter(|&i| board.pads[i].net == net && i != xp).collect();+ // The pin is on this net, or one series resistor away.+ let mut pin = on_net.iter().copied().find(|&i| is_ic(board, board.pads[i].fp));+ let mut series: Option<(usize, usize)> = None;+ if pin.is_none() {+ for &i in &on_net {+ let fp = &board.footprints[board.pads[i].fp];+ if ref_prefix(&fp.reference) != "R" || fp.pads.len() != 2 {+ continue;+ }+ let Some(&other) = fp.pads.iter().find(|&&q| q != i) else { continue };+ let on = board.pads[other].net;+ if on == NO_NET {+ continue;+ }+ if let Some(u) = (0..board.pads.len()).find(|&q| board.pads[q].net == on && is_ic(board, board.pads[q].fp)) {+ pin = Some(u);+ series = Some((other, i));+ break;+ }+ }+ }+ let Some(pin) = pin else { continue };+ let (pv, xv): (V, V) = (board.pads[pin].center.into(), board.pads[xp].center.into());+ if let Some((near, far)) = series {+ let straight = d_center(board, pin, xp);+ let path = d_center(board, pin, near) + d_center(board, near, far) + d_center(board, far, xp);+ let off = along(board.pads[near].center.into(), pv, xv).0.max(along(board.pads[far].center.into(), pv, xv).0);+ let gap = d_edge(board, pin, near);+ if path > 1.10 * straight || off > 1.0 * NM || gap > 2.5 * NM {+ let r = &board.footprints[board.pads[near].fp].reference;+ f.push(Finding { rule: "xtal-series-line", severity: "error", at: at_mm(board.pads[near].center), text: format!("{r} on {name}: path {:.2} mm against {:.2} mm straight (limit 1.10 x), {:.2} mm off the line (limit 1.0), {:.2} mm from {} (limit 2.5) (4.1)", path / NM, straight / NM, off / NM, gap / NM, board.pad_name(pin)) });+ }+ }+ for &i in &on_net {+ let fp = &board.footprints[board.pads[i].fp];+ if ref_prefix(&fp.reference) != "C" || fp.pads.len() != 2 {+ continue;+ }+ let (off, t) = along(board.pads[i].center.into(), pv, xv);+ // The pad's edge may touch the line, so the 0.4 mm is measured from the pad's copper.+ let shape_off = (off - board.pads[i].size.0.min(board.pads[i].size.1) / 2.0).max(0.0);+ if shape_off > 0.4 * NM || !(0.0..=1.0).contains(&t) {+ f.push(Finding { rule: "xtal-load-cap-line", severity: "error", at: at_mm(board.pads[i].center), text: format!("{} on {name}: {:.2} mm off the line from {} to {} (limit 0.4), {:.2} of the way along (limit 0 to 1) (4.1)", board.pad_name(i), shape_off / NM, board.pad_name(pin), board.pad_name(xp), t) });+ }+ }+ }+ }+}++/// A net the spec says is filled under a part (4.7: the supply rings) has a fill there. Net names+/// are compared without case: the spec says +3v3 and his v1.3 says +3V3 (R1).+pub fn inner_fill_rule(board: &Board, rules: &Rules, f: &mut Vec<Finding>) {+ for (r, n) in &rules.inner_fills {+ let Some(fp) = board.footprints.iter().find(|x| &x.reference == r) else { continue };+ let mut bb = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);+ for &p in &fp.pads {+ let s = board.pads[p].shape.bbox();+ bb = (bb.0.min(s.0), bb.1.min(s.1), bb.2.max(s.2), bb.3.max(s.3));+ }+ let under = board.zones.iter().filter(|z| board.net_name(z.net).eq_ignore_ascii_case(n)).any(|z| {+ z.outline.iter().any(|v| v.0 >= bb.0 && v.0 <= bb.2 && v.1 >= bb.1 && v.1 <= bb.3)+ });+ if !under {+ f.push(Finding { rule: "inner-tie-no-fill", severity: "error", at: at_mm(fp.at), text: format!("{r}: the spec fills {n} under the part and no zone of that net is there (4.7)") });+ }+ }+}++/// Each supply pin has its own cap at the pin (4.1; 4.7: a tie never replaces it). No distance limit:+/// his document gives none, and a limit read off the reference boards is what P23 and R1 warn about (3.5 mm sat+/// between v1.3's 3.05 and the rejected 3.66). 4.1 says "on the same side", and that alone grades+/// the fixture set correctly: v1.3's far caps are on their pin's side, the rejected board's C8 and C9+/// are across the package. Distances go in the score, for reading. Pins next to each+/// other on the package count as one (v1.3's 43/44, 48/49; benchmark C's U9.1-3), and no cap serves two+/// groups (P25: C16 serving pins 45 and 50). A supply pin is what the schematic says it is: an+/// Benchmark C U5 feedback pin on +5V and an INA228 sense input on +3V3_EXT are not.+/// Whether a cap's pad sits on the side of the package its pin is on (4.1: "at its own supply pin,+/// on the same side"). The side is the package edge nearest the pin; the cap is on it when it lies+/// beyond the pin's inner end on that side's outward direction.+pub fn same_side(board: &Board, pin: usize, cap: usize) -> bool {+ let fp = &board.footprints[board.pads[pin].fp];+ let mut bb = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);+ for &q in &fp.pads {+ let s = board.pads[q].shape.bbox();+ bb = (bb.0.min(s.0), bb.1.min(s.1), bb.2.max(s.2), bb.3.max(s.3));+ }+ let b = board.pads[pin].shape.bbox();+ let c = board.pads[pin].shape.center();+ // Distance from the pin to each package edge, and that edge's outward direction.+ let edges = [(c.0 - bb.0, -1.0, 0.0, b.2), (bb.2 - c.0, 1.0, 0.0, b.0), (c.1 - bb.1, 0.0, -1.0, b.3), (bb.3 - c.1, 0.0, 1.0, b.1)];+ // A pin within its own length of a corner belongs to both sides.+ let reach = (b.2 - b.0).max(b.3 - b.1);+ let near = edges.iter().map(|e| e.0).fold(f64::INFINITY, f64::min) + reach;+ let cc = board.pads[cap].shape.center();+ edges.iter().filter(|e| e.0 <= near).any(|e| {+ let inner = if e.1 != 0.0 { (cc.0 - e.3) * e.1 } else { (cc.1 - e.3) * e.2 };+ inner >= 0.0+ })+}++/// A supply pin by what the schematic says: typed power_in, or named VCC or VDD whatever its type+/// (v1.3's buffers have VCC typed power_out, a symbol quirk). A feedback or sense input on a rail is+/// not one (benchmark C U5.9 FB, U11.10 Vin+).+pub fn supply_typed(p: &Pad) -> bool {+ p.pintype.is_empty() || p.pintype.starts_with("power_in") || p.pinfunction.starts_with("VCC") || p.pinfunction.starts_with("VDD")+}++/// A supply pin of an IC.+pub fn is_supply_pin(board: &Board, i: usize) -> bool {+ let p = &board.pads[i];+ let name = board.net_name(p.net);+ is_ic(board, p.fp) && p.net != NO_NET && !p.layers.is_empty() && looks_power(name) && supply_typed(p)+}++pub fn decoupling_rules(board: &Board, _rules: &Rules, f: &mut Vec<Finding>, farthest: &mut Vec<(f64, String)>) {+ let is_supply = |i: usize| {+ let p = &board.pads[i];+ let name = board.net_name(p.net);+ p.net != NO_NET && !p.layers.is_empty() && looks_power(name) && supply_typed(p)+ };+ let mut by_net: std::collections::BTreeMap<NetId, Vec<Vec<usize>>> = Default::default();+ for (fi, u) in board.footprints.iter().enumerate() {+ if !is_ic(board, fi) || u.dnp {+ continue;+ }+ let mut pins: Vec<usize> = u.pads.iter().copied().filter(|&p| is_supply(p)).collect();+ pins.sort_by_key(|&p| board.pads[p].number.parse::<i64>().unwrap_or(i64::MAX));+ let mut groups: Vec<Vec<usize>> = Vec::new();+ for pin in pins {+ let joins = groups.last().and_then(|g| g.last()).map(|&q| {+ let adjacent = board.pads[pin].number.parse::<i64>().ok().zip(board.pads[q].number.parse::<i64>().ok()).map(|(a, b)| a - b <= 1).unwrap_or(false);+ board.pads[q].net == board.pads[pin].net && adjacent && d_edge(board, q, pin) <= 0.5 * NM+ });+ if joins == Some(true) {+ groups.last_mut().unwrap().push(pin);+ } else {+ groups.push(vec![pin]);+ }+ }+ for g in groups {+ by_net.entry(board.pads[g[0]].net).or_default().push(g);+ }+ }+ for (net, groups) in by_net {+ let caps: Vec<usize> = (0..board.pads.len()).filter(|&i| board.pads[i].net == net && ref_prefix(&board.footprints[board.pads[i].fp].reference) == "C" && !board.footprints[board.pads[i].fp].dnp).collect();+ if caps.is_empty() {+ continue;+ }+ // Every (group, cap) by distance, nearest first; each takes the closest still free.+ let mut options: Vec<(f64, usize, usize)> = Vec::new();+ for (gi, g) in groups.iter().enumerate() {+ for &c in &caps {+ let d = g.iter().map(|&p| d_edge(board, p, c)).fold(f64::INFINITY, f64::min);+ options.push((d, gi, c));+ }+ }+ options.sort_by(|a, b| a.0.total_cmp(&b.0));+ let mut own: Vec<Option<(usize, f64)>> = vec![None; groups.len()];+ let mut taken: Vec<usize> = Vec::new();+ for (d, gi, c) in options {+ if own[gi].is_none() && !taken.contains(&c) {+ own[gi] = Some((c, d));+ taken.push(c);+ }+ }+ for (gi, g) in groups.iter().enumerate() {+ let pin = g[0];+ let name = board.net_name(net);+ match own[gi] {+ Some((c, _)) if g.iter().any(|&q| same_side(board, q, c)) => {}+ Some((c, d)) => f.push(Finding { rule: "decoupling-pin", severity: "error", at: at_mm(board.pads[pin].center), text: format!("{} ({name}): its own cap {} is {:.2} mm away on another side of the package (4.1: at its own pin, on the same side)", board.pad_name(pin), board.pad_name(c), d / NM) }),+ None => f.push(Finding { rule: "decoupling-pin", severity: "error", at: at_mm(board.pads[pin].center), text: format!("{} ({name}): no cap of its own; each one already serves another pin (4.1)", board.pad_name(pin)) }),+ }+ if let Some((_, d)) = own[gi] {+ farthest.push((d / NM, board.pad_name(pin)));+ }+ }+ }+}
src/post.rsadded+1657@@ -0,0 +1,1657 @@+//! What makes copper look routed by a person, after the search has found it: a rail's topology+//! (bus on one layer, stubs to pads, taps with a legal shape), and the whole-board optimisation pass+//! (Part 3 step 8) that re-routes each connection once its neighbours are final.+//!+//! The optimisation pass follows the method of Freerouting's BatchOptimizer and TraceTightener and of+//! KiCadRoutingTools' cleanup: remove one connection, search again against the final obstacles, keep+//! the new one only if it is better. Written fresh from that description; no code is taken from them.++use crate::board::*;+use crate::classify::*;+use crate::conn::{touches, Piece, Uf};+use crate::geom::*;+use crate::index::*;+use crate::router::*;+use crate::search::*;+use std::collections::{BTreeMap, HashSet};+use crate::clock::Instant;++/// One connected piece of a net: what a tap may land on.+#[derive(Default, Debug)]+pub struct Comp {+ pub pads: Vec<usize>,+ pub segs: Vec<(P, P, usize, i64)>,+ pub vias: Vec<P>,+ pub zones: Vec<usize>,+}++/// How far a via stands off the track it branches from: 4.4's 0.6 to 0.85 mm, lower end.+const VIA_BRANCH: f64 = 0.6 * NM;+const TIGHT_BRANCH: f64 = 0.15 * NM;+/// What a branch off a track costs against reaching the next pad, in vias. A weight: 4.2 prefers a+/// chain and branching off a pad, and gives no number.+const BRANCH_VIAS: f64 = 0.5;+/// Same-net copper this close and parallel is a doubled run, the distance the reviewer's measure uses.+const DOUBLED_REACH: f64 = 0.4;+/// Cost per mm of a doubled run, over its length, so the optimiser takes it out when it can.+const DOUBLED: f64 = 3.0;++/// A rail's load from which one via is not enough. His PiProbe rails (0.177 A, 0.1 A) have none doubled,+/// and his Hardware Calculator's via model puts a 0.3/0.6 via well above that. A working number until+/// rails are sized from current, which needs his chart source.+use crate::current::VIA_PLATING_UM;++impl<'a> Router<'a> {+ /// The layer a rail's bus runs on. With a solid plane inside, the bottom layer is free for it;+ /// without one the bottom is the return path, so the rail stays on top (2.2, 4.3).+ pub(crate) fn bus_of(&self, task: &Task) -> Option<usize> {+ if task.zone != Some(RAIL_TAP) {+ return None;+ }+ Some(self.bus_layer())+ }++ pub(crate) fn bus_layer(&self) -> usize {+ let has_plane = !self.board.plane_nets().is_empty();+ if has_plane { *self.world.route_layers.last().unwrap() } else { self.world.route_layers[0] }+ }++ /// The net's copper split into connected pieces, from what is on the board right now.+ pub(crate) fn components(&self, net: NetId) -> Vec<Comp> {+ self.components_with(net, true)+ }++ /// `fills` false leaves the net's pours out: pieces joined by tracks, vias and pads alone. For+ /// mending a pour that does not in fact join the pads inside its outline.+ pub(crate) fn components_with(&self, net: NetId, fills: bool) -> Vec<Comp> {+ let skip: HashSet<&str> = self.rules.skip_refs.iter().map(|s| s.as_str()).collect();+ let mut pieces: Vec<Piece> = Vec::new();+ let mut comp_item: Vec<(u8, usize)> = Vec::new();+ for (i, p) in self.board.pads.iter().enumerate() {+ let f = &self.board.footprints[p.fp];+ if p.net == net && !p.layers.is_empty() && !skip.contains(f.reference.as_str()) {+ pieces.push(Piece::Pad(i));+ comp_item.push((0, i));+ }+ }+ let mut segs: Vec<(P, P, usize, i64)> = Vec::new();+ let mut vias: Vec<P> = Vec::new();+ for t in self.board.tracks.iter().filter(|t| t.net == net) {+ segs.push((t.a, t.b, t.layer, t.width));+ }+ for v in self.board.vias.iter().filter(|v| v.net == net) {+ vias.push(v.at);+ }+ for r in self.routes.values().filter(|r| r.task.net == net) {+ for s in &r.segs {+ segs.push((s.a, s.b, s.layer, s.width));+ }+ vias.extend(r.vias.iter().copied());+ }+ for (i, s) in segs.iter().enumerate() {+ pieces.push(Piece::Seg { layer: s.2, a: s.0.into(), b: s.1.into(), r: s.3 as f64 / 2.0 });+ comp_item.push((1, i));+ }+ let vr = self.rules.via as f64 / 2.0;+ for (i, v) in vias.iter().enumerate() {+ pieces.push(Piece::Via { at: (*v).into(), r: vr });+ comp_item.push((2, i));+ }+ for (z, (zn, layer, poly)) in self.world.zones.iter().enumerate() {+ if *zn == net && fills {+ pieces.push(Piece::Fill { layer: *layer, pts: poly.clone() });+ comp_item.push((3, z));+ }+ }+ let mut uf = Uf::new(pieces.len());+ for i in 0..pieces.len() {+ for j in i + 1..pieces.len() {+ if uf.find(i as u32) != uf.find(j as u32) && touches(&pieces[i], &pieces[j], self.board) {+ uf.union(i as u32, j as u32);+ }+ }+ }+ let mut by: BTreeMap<u32, Comp> = BTreeMap::new();+ for i in 0..pieces.len() {+ let c = by.entry(uf.find(i as u32)).or_default();+ let (kind, k) = comp_item[i];+ match kind {+ 0 => c.pads.push(k),+ 1 => c.segs.push(segs[k]),+ 2 => c.vias.push(vias[k]),+ _ => c.zones.push(k),+ }+ }+ by.into_values().collect()+ }++ pub(crate) fn comp_terminals(&self, c: &Comp, stand_off: f64, out: &mut Vec<Terminal>) {+ // A Kelvin tap is dedicated copper (4.2): nothing else on the net may land on it.+ let tap_pads: HashSet<usize> = self.routes.values().filter(|r| r.task.level == Level::Kelvin).flat_map(|r| [r.task.a, r.task.b]).collect();+ let tap_segs: HashSet<(P, P)> = self.routes.values().filter(|r| r.task.level == Level::Kelvin).flat_map(|r| r.segs.iter().map(|s| (s.a, s.b))).collect();+ let amp_pin = |p: usize| tap_pads.contains(&p) && !crate::classify::is_passive2(self.board, self.board.pads[p].fp);+ for &p in &c.pads {+ if !amp_pin(p) {+ out.push(self.pad_terminal(p));+ }+ }+ for &z in &c.zones {+ let (_, layer, poly) = &self.world.zones[z];+ out.push(Terminal::Zone { layer: *layer, poly: poly.clone() });+ }+ for s in &c.segs {+ if s.0 != s.1 && !tap_segs.contains(&(s.0, s.1)) {+ out.push(Terminal::Track { a: s.0, b: s.1, layer: s.2, wide: s.3 >= self.rules.power_floor, stand_off });+ }+ }+ // A via joins from the stub side only. On the bus layer the tap lands on the track, where its+ // shape is checked: landing on the via could fold back acutely or put the via inside a run.+ // Nor where a stub already ends on it: that stub is the place to join, angle checked.+ let bus = self.bus_layer();+ for v in &c.vias {+ let layers: Vec<usize> = self.world.route_layers.iter().copied().filter(|&l| l != bus && !c.segs.iter().any(|s| s.2 == l && (s.0 == *v || s.1 == *v))).collect();+ if !layers.is_empty() {+ out.push(Terminal::Point { p: *v, layers });+ }+ }+ }++ /// What a rail task may land on. A pin's stub (`a != b`, not main) lands on its cap's own pad: cap+ /// first (4.2). The trunk lands on anything joined to pad `b`. Otherwise any of the net's copper+ /// not yet joined to pad `a`. Empty when there is nothing left to join.+ pub(crate) fn rail_targets(&self, task: &Task, stand_off: f64) -> Vec<Terminal> {+ let comps = self.components(task.net);+ let own = comps.iter().position(|c| c.pads.contains(&task.a));+ let mut out = Vec::new();+ if task.a != task.b {+ match comps.iter().position(|c| c.pads.contains(&task.b)) {+ Some(i) if Some(i) != own => {+ if task.main {+ self.comp_terminals(&comps[i], stand_off, &mut out);+ } else {+ out.push(self.pad_terminal(task.b));+ }+ }+ _ => {}+ }+ return out;+ }+ // A planned rail's tap lands only on its own part's pads and on the copper of the parts+ // above it in the plan's tree (power.rs): the branch currents the widths were worked out+ // from are then the currents in the copper. If none of that is laid yet it lands as below.+ // A planned rail grows from its source: only a piece that already holds a source pad is+ // landed on. (Two parts not yet joined to the source could otherwise join each other and+ // stay apart from it: benchmark C's R46 and C30 on +5V_OUT did, 2026-11-10.)+ let live: Vec<usize> = match (self.power.pads.contains_key(&task.a), self.power.sources.get(&task.net)) {+ (true, Some(src)) => (0..comps.len()).filter(|&i| Some(i) != own && comps[i].pads.iter().any(|p| src.contains(p))).collect(),+ _ => Vec::new(),+ };+ if let Some(pp) = self.power.pads.get(&task.a).filter(|pp| pp.anywhere && !live.is_empty()) {+ let _ = pp;+ for &i in &live {+ self.comp_terminals(&comps[i], stand_off, &mut out);+ }+ return out;+ }+ if let Some(pp) = self.power.pads.get(&task.a).filter(|pp| !pp.anywhere) {+ let segs: HashSet<(P, P, usize)> = self.routes.values().filter(|r| r.task.net == task.net && pp.allowed.contains(&r.task.a)).flat_map(|r| r.segs.iter().map(|s| (s.a, s.b, s.layer))).collect();+ let vias: HashSet<P> = self.routes.values().filter(|r| r.task.net == task.net && pp.allowed.contains(&r.task.a)).flat_map(|r| r.vias.iter().copied()).collect();+ for (i, c) in comps.iter().enumerate() {+ if Some(i) != own && (live.is_empty() || live.contains(&i)) {+ let only = Comp { pads: c.pads.iter().copied().filter(|x| pp.allowed.contains(x)).collect(), segs: c.segs.iter().copied().filter(|s| segs.contains(&(s.0, s.1, s.2)) || segs.contains(&(s.1, s.0, s.2))).collect(), vias: c.vias.iter().copied().filter(|v| vias.contains(v)).collect(), zones: c.zones.clone() };+ self.comp_terminals(&only, stand_off, &mut out);+ }+ }+ if !out.is_empty() {+ return out;+ }+ }+ // Every task joins its piece to one other, whichever is nearest, fills included: a fill is+ // the natural hub. There is one task per piece, so the net ends up whole either way.+ for (i, c) in comps.iter().enumerate() {+ if Some(i) != own {+ self.comp_terminals(c, stand_off, &mut out);+ }+ }+ out+ }++ /// Route one rail task: from the pad, down its stub to the bus layer, along the bus to the+ /// target. Widths step down only when there is no room, and `rail_notes` reports any that did.+ /// True if joined, including when there was nothing left to join.+ pub(crate) fn try_rail(&mut self, task: &Task) -> bool {+ if self.rail_targets(task, 0.0).is_empty() {+ return true;+ }+ self.rail_route_guarded(task).is_some()+ }++ /// `rail_route` behind its two guards; every caller goes through here (the first pass, the rail+ /// repair and repair's `route_hard`, which used to call the search directly and so ran it without+ /// either guard: on adom/esc-g431 that was where one tap got its 565 searches).+ pub(crate) fn rail_route_guarded(&mut self, task: &Task) -> Option<u32> {+ // A tap that found no way is not searched again until something near it, or its own net's+ // copper, has changed: the whole ladder of widths and margins would only fail the same way.+ let key = (task.a, task.b);+ let stamp = self.rail_stamp(task);+ if self.rail_walled.get(&key) == Some(&stamp) {+ return None;+ }+ // And never more than a few times in one run, whatever changed round it: repair lifts and+ // re-lays its neighbours over and over, each time a new state, each time the same ladder.+ let tries = self.rail_tries.entry(key).or_insert(0);+ if *tries >= if self.deep { RAIL_TRIES + 3 } else { RAIL_TRIES } {+ return None;+ }+ *tries += 1;+ let made = self.rail_route(task);+ if made.is_some() {+ self.rail_walled.remove(&key);+ } else {+ self.rail_walled.insert(key, stamp);+ }+ made+ }++ // (RAIL_TRIES is defined at the top of router.rs.)+ /// What a rail tap's search depends on, as one number: the routes within 6 mm of its pad and+ /// every route of its own net, by id (a route laid again has a new id).+ fn rail_stamp(&self, task: &Task) -> u64 {+ let c = self.board.pads[task.a].center;+ let r = mm(6.0);+ let near = |p: P| (p.0 - c.0).abs() <= r && (p.1 - c.1).abs() <= r;+ let mut ids: Vec<(u32, usize, usize)> = self.routes.values().filter(|x| x.task.net == task.net || x.segs.iter().any(|s| near(s.a) || near(s.b)) || x.vias.iter().any(|v| near(*v))).map(|x| (x.id, x.segs.len(), x.vias.len())).collect();+ ids.sort_unstable();+ let mut h: u64 = 0xcbf29ce484222325 ^ (self.deep as u64) ^ ((self.rules.node_limit as u64) << 8);+ for (id, a, b) in ids {+ for v in [id as u64, a as u64, b as u64] {+ h = (h ^ v).wrapping_mul(0x100000001b3);+ }+ }+ h+ }++ /// The ways a rail task can be made: from its own pads to the other copper, or from the other+ /// pads to its own copper. The second matters when its own piece already holds a long bus or a+ /// fill: that copper can't be a place to start from, so a route from the pad alone would cross the+ /// board to reach something a stub from the far side joins in 3 mm (piprobe_v13: +3V3 across U1).+ fn rail_ways(&self, task: &Task, stand_off: f64) -> Vec<(Vec<Terminal>, Vec<Terminal>)> {+ let targets = self.rail_targets(task, stand_off);+ if targets.is_empty() {+ return Vec::new();+ }+ let mut ways = vec![(vec![self.pad_terminal(task.a)], targets)];+ // A pin's stub to its own cap goes pin to cap and nowhere else (4.2).+ if task.a != task.b && !task.main {+ return ways;+ }+ let comps = self.components(task.net);+ let Some(own) = comps.iter().position(|c| c.pads.contains(&task.a)) else { return ways };+ let is_trunk = task.a != task.b;+ let others: Vec<usize> = comps.iter().enumerate().filter(|(i, c)| *i != own && (!is_trunk || c.pads.contains(&task.b))).flat_map(|(_, c)| self.feed_pads(c)).collect();+ if !others.is_empty() {+ let mut back = Vec::new();+ self.comp_terminals(&comps[own], stand_off, &mut back);+ ways.push((others.iter().map(|&p| self.pad_terminal(p)).collect(), back));+ }+ ways+ }++ /// The pads a piece of rail is fed from: its caps if it has any (cap first, 4.2), else all of them.+ fn feed_pads(&self, c: &Comp) -> Vec<usize> {+ let caps: Vec<usize> = c.pads.iter().copied().filter(|&p| is_passive2(self.board, self.board.pads[p].fp) && ref_prefix(&self.board.footprints[self.board.pads[p].fp].reference) == "C").collect();+ if caps.is_empty() { c.pads.clone() } else { caps }+ }++ /// The route itself, with its id. The task stored is the way it was really made, so that a later+ /// re-search starts from the pad this route leaves.+ pub(crate) fn rail_route(&mut self, task: &Task) -> Option<u32> {+ let bus = self.bus_layer();+ let (w, stub) = self.widths_for(task);+ let (neck, c, floor) = (self.rules.neck, self.rules.clearance_for(task.net, task.net), self.rules.power_floor);+ // The margin also sets the node budget (search.rs pf_limit). A rail's targets usually span the+ // board already, so the small budgets come first and only the last search may be expensive.+ let tries = [+ (w, stub, 2, 5.0 * NM, VIA_BRANCH),+ (w, stub, 2, 5.0 * NM, 0.0),+ (w, neck, 2, 5.0 * NM, 0.0),+ (w.min(floor), neck, 3, 5.0 * NM, 0.0),+ (stub, neck, 4, 5.0 * NM, 0.0),+ (neck, neck, 4, 40.0 * NM, 0.0),+ // Last resort, and his own bus has one: a via on the track itself (4.4 prefers not).+ (neck, neck, 4, 40.0 * NM, -1.0),+ ];+ // (A probe of the last rung right after the first failure was tried on 2026-11-10 and taken+ // out: on a flight controller board the probe ran out of nodes instead of closing, so it proved+ // nothing and only added work, 124 -> 121 joins.)+ for (width, nk, vias, margin, off) in tries {+ if self.over_time() {+ return None;+ }+ let pf = Profile { width, neck: nk, clearance: c, target_gap: c, max_vias: vias, margin, layer_bias: Some(bus) };+ let mut best: Option<Path> = None;+ for (sources, targets) in self.rail_ways(task, off) {+ let q = Query { net: task.net, also: None, sources: &sources, targets: &targets, profile: pf, rules: &self.rules, soft: None, via_in: None, history: Some(&self.history), free: &[], arrive: None };+ self.searches += 1;+ let t0 = Instant::now();+ let found = search(&self.world, &q);+ if std::env::var_os("ADOM_ROUTE_TRACE").is_some() {+ eprintln!(" rail {} w={:.2} stub={:.2} vias={} margin={:.1} off={:.1}: {} sources, {} targets, {} in {:.2}s", self.board.pad_name(task.a), to_mm(width), to_mm(nk), vias, margin / NM, off / NM, sources.len(), targets.len(), if let Some(f) = &found { format!("found cost {:.1} nodes {}", f.cost / NM, f.nodes) } else { "none".to_string() }, t0.elapsed().as_secs_f64());+ }+ if let Some(path) = found {+ self.nodes += path.nodes as u64;+ if best.as_ref().map(|b| path.cost < b.cost).unwrap_or(true) {+ best = Some(path);+ }+ }+ }+ if let Some(path) = best {+ let made = self.task_as_made(task, &path);+ let pts = self.finish_path_on(&path, task.net, NO_NET, &|l| if l == bus { width } else { nk });+ return Some(self.commit_rail(&made, &pts, width, nk));+ }+ }+ None+ }++ /// `task` as this path really made it: from the pad it starts at.+ fn task_as_made(&self, task: &Task, path: &Path) -> Task {+ let start = path.pts.first().map(|x| x.0);+ let pad = self.board.pads.iter().position(|p| p.net == task.net && Some(snap(p.center)) == start);+ match pad {+ Some(p) if p != task.a && task.a == task.b => Task { a: p, b: p, ..task.clone() },+ _ => task.clone(),+ }+ }++ /// A rail's copper: the bus-layer part at the width it was searched at wherever that is legal,+ /// the rest at the stub width. A piece inside the net's own fill stays: the fill in the file is the+ /// old one, and on benchmark C a via left to rely on it came out of KiCad's refill stranded.+ pub(crate) fn commit_rail(&mut self, task: &Task, pts: &[(P, usize)], width: i64, neck: i64) -> u32 {+ let id = self.fresh_id();+ let bus = self.bus_layer();+ let rated = width > neck && self.rules.rail(self.board.net_name(task.net)).is_some();+ let (mut segs, mut vias) = (Vec::new(), Vec::new());+ for w in pts.windows(2) {+ let ((a, la), (b, lb)) = (w[0], w[1]);+ if la != lb {+ if a == b {+ vias.push(a);+ }+ continue;+ }+ if a == b {+ continue;+ }+ // An approved current: full width on any layer, narrowed only over the stretch that+ // lacks the room (the taper into a small pad), and there to the widest that fits. Drawn+ // whole-segment and bus-layer-only, benchmark C's +5V to U10 ran 33.6 mm at 0.20 mm on B.Cu.+ if rated {+ for (p0, p1, wd) in self.taper(a, b, la, width, neck, task.net) {+ segs.push(Seg { a: p0, b: p1, layer: la, width: wd });+ }+ continue;+ }+ let mut use_w = neck.min(width);+ if la == bus {+ for cand in [width, self.rules.power_floor.min(width)] {+ if self.seg_clear(a, b, la, cand, task.net, NO_NET) {+ use_w = cand;+ break;+ }+ }+ }+ segs.push(Seg { a, b, layer: la, width: use_w });+ }+ merge_runs(&mut segs);+ self.place(id, task.clone(), segs, vias);+ if task.a == task.b && self.power.pads.get(&task.a).map(|p| !p.anywhere).unwrap_or(false) {+ self.rail_laid.insert((task.a, task.b));+ }+ id+ }++ /// The widest width from `lo` (taken as clear) up to `hi` at which this stretch is clear, to 10 um.+ pub(crate) fn widest_clear(&self, a: P, b: P, layer: usize, lo: i64, hi: i64, net: NetId) -> i64 {+ if hi <= lo || self.seg_clear(a, b, layer, hi, net, NO_NET) {+ return hi.max(lo);+ }+ let (mut lo, mut hi) = (lo, hi);+ while hi - lo > 10_000 {+ let mid = ((lo + hi) / 2 / 2_000) * 2_000;+ if mid <= lo || mid >= hi {+ break;+ }+ if self.seg_clear(a, b, layer, mid, net, NO_NET) {+ lo = mid;+ } else {+ hi = mid;+ }+ }+ lo+ }++ /// One straight run of a rated rail cut into stretches, each at the widest width that is clear+ /// there: the full width wherever it fits, and across a stretch that lacks the room the widest+ /// of a few steps down to the neck. Stretches are found in 0.1 mm steps along the run.+ pub(crate) fn taper(&self, a: P, b: P, layer: usize, width: i64, neck: i64, net: NetId) -> Vec<(P, P, i64)> {+ if self.seg_clear(a, b, layer, width, net, NO_NET) {+ return vec![(a, b, width)];+ }+ let len = (a.0 - b.0).abs().max((a.1 - b.1).abs());+ let d = ((b.0 - a.0).signum(), (b.1 - a.1).signum());+ let at = |t: i64| -> P { if t >= len { b } else { (a.0 + d.0 * t, a.1 + d.1 * t) } };+ let step = 100_000i64;+ let n = ((len + step - 1) / step).max(1);+ let mut steps: Vec<i64> = vec![width];+ let mut wd = width;+ while wd / 2 > neck {+ wd = (wd / 2 / 10_000) * 10_000;+ steps.push(wd);+ }+ steps.push(neck.min(width));+ // The widest step each 0.1 mm piece can take.+ let fit: Vec<i64> = (0..n).map(|k| {+ let (p0, p1) = (at(k * step), at((k + 1) * step));+ steps.iter().copied().find(|&s| self.seg_clear(p0, p1, layer, s, net, NO_NET)).unwrap_or(neck.min(width))+ }).collect();+ // Pieces of one width join into one stretch; a stretch shorter than it is wide takes its+ // narrower neighbour's width, so the run doesn't flicker between widths.+ let mut runs: Vec<(i64, i64, i64)> = Vec::new();+ for (k, &f) in fit.iter().enumerate() {+ match runs.last_mut() {+ Some(r) if r.2 == f => r.1 = k as i64 + 1,+ _ => runs.push((k as i64, k as i64 + 1, f)),+ }+ }+ for i in 0..runs.len() {+ let short = (runs[i].1 - runs[i].0) * step < runs[i].2;+ if short {+ let lower = [i.checked_sub(1), Some(i + 1)].into_iter().flatten().filter_map(|j| runs.get(j).map(|r| r.2)).filter(|&w| w < runs[i].2).max();+ if let Some(w) = lower {+ runs[i].2 = w;+ }+ }+ }+ let mut out: Vec<(P, P, i64)> = Vec::new();+ for (k0, k1, wd) in runs {+ let (p0, p1) = (at(k0 * step), at(k1 * step));+ if p0 == p1 {+ continue;+ }+ // Between the step that fits and the one above it, the widest that fits this stretch.+ let wd = if wd < width { self.widest_clear(p0, p1, layer, wd, steps.iter().copied().filter(|&s| s > wd).min().unwrap_or(width), net) } else { wd };+ match out.last_mut() {+ Some(l) if l.2 == wd => l.1 = p1,+ _ => out.push((p0, p1, wd)),+ }+ }+ out+ }++ /// Cut a straight run where it crosses the edge of a fine-pitch escape. Each piece says whether+ /// it lies inside. `active` false: the whole run, outside.+ pub(crate) fn split_at_escape(&self, a: P, b: P, active: bool) -> Vec<(P, P, bool)> {+ if !active {+ return vec![(a, b, false)];+ }+ let len = (a.0 - b.0).abs().max((a.1 - b.1).abs());+ let d = ((b.0 - a.0).signum(), (b.1 - a.1).signum());+ let at = |t: i64| -> P { (a.0 + d.0 * t, a.1 + d.1 * t) };+ let inside = |t: i64| self.world.in_escape(at(t).into());+ let step = (0.05 * NM) as i64;+ let mut out = Vec::new();+ let (mut from, mut state) = (0i64, inside(0));+ let mut t = step;+ while t < len {+ if inside(t) != state {+ // Narrow the crossing down to a micron, on an even coordinate, so the run stays exact.+ let (mut lo, mut hi) = (t - step, t);+ while hi - lo > 2_000 {+ let m = ((lo + hi) / 2) & !1;+ if inside(m) == state { lo = m } else { hi = m }+ }+ out.push((at(from), at(hi), state));+ from = hi;+ state = !state;+ }+ t += step;+ }+ out.push((at(from), b, state));+ out+ }++ /// Does this connection land on whatever copper its net has, rather than one pad? On a signal net+ /// of three or more pads, a pair's side pads included. A crystal's line stays pad to pad: it must+ /// run through the load cap's pad (4.2), and a two-pad net has nothing else to land on.+ pub(crate) fn joins_copper(&self, task: &Task) -> bool {+ matches!(task.level, Level::Data | Level::Other | Level::Pair) && self.net_info.get(&task.net).map(|(_, p)| p.len() >= 3).unwrap_or(false)+ }++ /// Everything joined to pad `b`: a route coming in lands on passing copper instead of returning+ /// beside it. A branch is a Y (a signal track is never `wide`) with its via off the track (4.2, 4.4).+ pub(crate) fn join_targets(&self, task: &Task) -> Vec<Terminal> {+ let comps = self.components(task.net);+ let mut out = Vec::new();+ match comps.iter().find(|c| c.pads.contains(&task.b)) {+ // 4.4 asks for the branch "where there is room". Dropping off a pad inside a big pour there+ // is none: his own via at J1 stands about 0.2 mm off the run it joins.+ Some(c) => self.comp_terminals(c, if self.pour_exit_pads.contains(&task.a) || self.pour_exit_pads.contains(&task.b) { TIGHT_BRANCH } else { VIA_BRANCH }, &mut out),+ None => out.push(self.pad_terminal(task.b)),+ }+ out+ }++ /// The first search of `profiles` to find a way, with two weighings in the search's own costs. A+ /// no-via route that costs more than two vias over the straight line isn't taken yet: a via route+ /// is looked for and the cheaper kept ("don't hop to the bottom layer where the top works", but+ /// "always weigh bends against distance", 4.2). And a route landing on a track rather than the pad+ /// it was sent to has to save more than a branch costs (4.2: prefer a chain, branch off a pad).+ pub(crate) fn weigh(&mut self, task: &Task, sources: &[Terminal], targets: &[Terminal], profiles: &[Profile], rules: &crate::rules::Rules) -> Option<(Path, Profile)> {+ let straight = if task.a != task.b { hypot(sub(self.board.pads[task.a].center.into(), self.board.pads[task.b].center.into())) } else { 0.0 };+ let mut held: Option<(Path, Profile)> = None;+ for &pf in profiles {+ self.searches += 1;+ let q = Query { net: task.net, also: None, sources, targets, profile: pf, rules, soft: None, via_in: None, history: Some(&self.history), free: &[], arrive: None };+ let Some(mut path) = search(&self.world, &q) else { continue };+ self.nodes += path.nodes as u64;+ if (self.pour_strict.contains(&task.net) || (self.pour_exit.contains(&task.net) && !self.pour_relax)) && self.pour_run(task.net, &path.pts) > self.pour_limit {+ continue;+ }+ // A long run through a pour its pad happens to sit in splits the pour: look for a via+ // route first, and fall back on this one only if none turns up.+ if pf.max_vias == 0 && self.pour_run(task.net, &path.pts) > crate::router::POUR_RUN {+ if held.as_ref().map(|h: &(Path, Profile)| path.cost < h.0.cost).unwrap_or(true) {+ held = Some((path, pf));+ }+ continue;+ }+ let on_pad = task.a == task.b || task.zone.is_some() || path.pts.last().map(|x| x.0 == snap(self.board.pads[task.b].center)).unwrap_or(true);+ if !on_pad && targets.len() > 1 {+ let pad = [self.pad_terminal(task.b)];+ let q = Query { net: task.net, also: None, sources, targets: &pad, profile: pf, rules, soft: None, via_in: None, history: Some(&self.history), free: &[], arrive: None };+ self.searches += 1;+ if let Some(p2) = search(&self.world, &q) {+ if p2.cost <= path.cost + rules.cost_via * BRANCH_VIAS {+ path = p2;+ }+ }+ }+ if pf.max_vias == 0 && task.a != task.b && path.cost > straight + 2.0 * rules.cost_via {+ if held.as_ref().map(|h| path.cost < h.0.cost).unwrap_or(true) {+ held = Some((path, pf));+ }+ continue;+ }+ // A held run through a pour is only the fallback for when nothing else turns up: being+ // shorter than this way is exactly why it was tempting.+ return match held.take() {+ Some(h) if h.0.cost < path.cost && self.pour_run(task.net, &h.0.pts) <= crate::router::POUR_RUN => Some(h),+ _ => Some((path, pf)),+ };+ }+ held+ }++ /// After a pass, a ripped trunk can leave taps hanging and a failed stub may have been joined+ /// another way. Work from the copper: join every piece still apart, fills included, and report+ /// only what is really open.+ pub(crate) fn repair_rails(&mut self, plan: &Plan, round: Vec<Task>) -> Vec<Task> {+ let nets: Vec<NetId> = {+ let mut v: Vec<NetId> = plan.tasks.iter().filter(|t| t.zone == Some(RAIL_TAP)).map(|t| t.net).collect();+ v.sort_unstable();+ v.dedup();+ v+ };+ let mut failed: Vec<Task> = round.into_iter().filter(|t| !nets.contains(&t.net)).collect();+ for net in nets {+ let mut gave_up: Vec<usize> = Vec::new();+ for _ in 0..64 {+ // Join the smallest piece first; the biggest is the rail.+ let comps = self.components(net);+ if comps.iter().filter(|c| counts_as_piece(c)).count() < 2 {+ break;+ }+ let next = comps.iter().filter(|c| !c.pads.is_empty() && !c.pads.iter().any(|p| gave_up.contains(p)) && counts_as_piece(c)).min_by_key(|c| c.segs.len() + c.pads.len());+ let Some(pad) = next.map(|c| c.pads[0]) else { break };+ let task = Task { net, level: Level::Power, a: pad, b: pad, main: true, zone: Some(RAIL_TAP) };+ if !self.try_rail(&task) {+ gave_up.push(pad);+ failed.push(task);+ }+ }+ }+ failed+ }++ /// The last guard before the board is written: no route may touch copper of another net. An open+ /// join is a fault the designer sees and fixes; a short is one he may not. Every search already+ /// keeps clearance, so this finds only what a special case let through (a coupled pair treats its+ /// partner as friendly: on a large Ethernet switch board one track crossed a pad of its partner).+ /// The route laid later is the one taken off, and the note says so.+ pub(crate) fn drop_shorts(&mut self) {+ let mut ids: Vec<u32> = self.routes.keys().copied().collect();+ ids.sort_unstable();+ let mut gone: Vec<u32> = Vec::new();+ for (i, &id) in ids.iter().enumerate() {+ let r = &self.routes[&id];+ let net = r.task.net;+ let (vd, _) = self.rules.via_for(net);+ let vr = vd as f64 / 2.0;+ let mut with: Option<String> = None;+ // Pads of other nets under this route's tracks or vias.+ 'pads: for p in self.board.pads.iter().filter(|p| p.net != net && p.net != NO_NET && !p.layers.is_empty()) {+ for s in r.segs.iter().filter(|s| p.layers.contains(&s.layer)) {+ if p.shape.dist_seg(s.a.into(), s.b.into()) - s.width as f64 / 2.0 < -1_000.0 {+ with = Some(format!("pad {} ({})", self.board.pad_name(self.board.pads.iter().position(|q| std::ptr::eq(q, p)).unwrap()), self.board.net_name(p.net)));+ break 'pads;+ }+ }+ for v in &r.vias {+ if p.shape.dist_pt((*v).into()) - vr < -1_000.0 {+ with = Some(format!("pad {} ({})", self.board.pad_name(self.board.pads.iter().position(|q| std::ptr::eq(q, p)).unwrap()), self.board.net_name(p.net)));+ break 'pads;+ }+ }+ }+ // Tracks of other nets laid before it (so the later of two is the one taken off).+ if with.is_none() {+ 'routes: for &oid in &ids[..i] {+ if gone.contains(&oid) {+ continue;+ }+ let o = &self.routes[&oid];+ if o.task.net == net {+ continue;+ }+ for s in &r.segs {+ for t in o.segs.iter().filter(|t| t.layer == s.layer) {+ if seg_seg(s.a.into(), s.b.into(), t.a.into(), t.b.into()) - (s.width + t.width) as f64 / 2.0 < -1_000.0 {+ with = Some(format!("a track of {}", self.board.net_name(o.task.net)));+ break 'routes;+ }+ }+ }+ }+ }+ if let Some(w) = with {+ self.notes.push(format!("for the designer: {} {} to {} was taken off again: its copper touched {w}, a short. The join is left open.", self.board.net_name(net), self.board.pad_name(r.task.a), self.board.pad_name(r.task.b)));+ gone.push(id);+ }+ }+ for id in gone {+ self.unplace(id);+ }+ }++ /// A route that starts or ends lying right on its own net's copper is that copper twice: cut it+ /// back to where it leaves (4.2: fewest segments, branch rather than run alongside). This is exact+ /// geometry, not a search, so it costs nothing and moves nothing else.+ pub(crate) fn trim_overlaps(&mut self) {+ let mut trimmed = 0;+ let mut ids: Vec<u32> = self.routes.keys().copied().collect();+ ids.sort_unstable();+ for id in ids {+ let Some(r) = self.routes.get(&id) else { continue };+ if r.segs.is_empty() || matches!(r.task.level, Level::Pair | Level::Kelvin) {+ continue;+ }+ let net = r.task.net;+ let others: Vec<Seg> = self.routes.values().filter(|o| o.id != id && o.task.net == net).flat_map(|o| o.segs.iter().cloned()).collect();+ let mut segs = r.segs.clone();+ let before = segs.len();+ let mut cut = false;+ for _ in 0..2 {+ // Off the front, then the same off the back.+ while let Some(first) = segs.first().cloned() {+ match overlap_end(&first, &others) {+ Some(None) => {+ segs.remove(0);+ cut = true;+ }+ Some(Some(e)) => {+ segs[0].a = e;+ cut = true;+ break;+ }+ None => break,+ }+ }+ segs.reverse();+ for s in &mut segs {+ std::mem::swap(&mut s.a, &mut s.b);+ }+ }+ if !cut || (segs.len() == before && segs == r.segs) {+ continue;+ }+ let (task, vias) = (r.task.clone(), r.vias.clone());+ let pieces = self.open_pieces(net);+ let saved = self.unplace(id).unwrap();+ self.place(id, task, segs, vias);+ if self.open_pieces(net) > pieces {+ self.unplace(id);+ self.place(saved.id, saved.task, saved.segs, saved.vias);+ } else {+ trimmed += 1;+ }+ }+ if trimmed > 0 {+ self.log.push(format!("trimmed {trimmed} routes that lay on their own net's copper"));+ }+ }++ /// One task for each fill that nothing feeds yet: a piece of copper that big is on the board+ /// whether it is joined or not, so it has to be joined (4.3). A fill sliver too small for a via+ /// can't be fed: that is for KiCad's refill to remove, and it is reported.+ pub(crate) fn unfed_fills(&self) -> Vec<Task> {+ let mut out = Vec::new();+ let mut nets: Vec<NetId> = self.net_info.iter().filter(|(_, (l, _))| *l == Level::Power).map(|(n, _)| *n).collect();+ nets.sort_unstable();+ for net in nets {+ let comps = self.components(net);+ if comps.iter().filter(|c| counts_as_piece(c)).count() < 2 {+ continue;+ }+ for c in comps.iter().filter(|c| c.pads.is_empty() && !c.zones.is_empty()) {+ if !c.zones.iter().any(|&z| self.fill_area(z) >= self.via_area(net)) {+ continue;+ }+ let z = c.zones[0];+ let at = poly_center(&self.world.zones[z].2);+ let pad = comps.iter().flat_map(|o| o.pads.iter().copied()).min_by(|&x, &y| hypot(sub(self.board.pads[x].center.into(), at)).total_cmp(&hypot(sub(self.board.pads[y].center.into(), at))));+ if let Some(pad) = pad {+ out.push(Task { net, level: Level::Power, a: pad, b: pad, main: true, zone: Some(RAIL_TAP) });+ }+ }+ }+ out+ }++ fn fill_area(&self, z: usize) -> f64 {+ let p = &self.world.zones[z].2;+ let n = p.len();+ ((0..n).map(|i| p[i].0 * p[(i + 1) % n].1 - p[(i + 1) % n].0 * p[i].1).sum::<f64>() / 2.0).abs()+ }++ fn via_area(&self, net: NetId) -> f64 {+ let r = self.rules.via_for(net).0 as f64 / 2.0;+ std::f64::consts::PI * r * r+ }++ /// 4.2: rate each rail track by the current actually through it and widen what falls short. Only+ /// the finished copper says which way a branch went: a pin's tap can end up carrying another part's+ /// current when that part joined the rail through it (benchmark C: U10's 1.24 A through a cap's tap).+ /// Widening happens in place where it clears everything; what can't is left for the check to name.+ /// Rails rated from the copper as laid, while the board is still empty: called once the heavy+ /// rails are down and before any signal. Which tap ends up feeding which part is only known now+ /// (a part joins the nearest copper of its net), and now every piece still has room to take the+ /// width its real current needs. Left for the end, benchmark C's U10 branch had none (2026-11-06).+ pub(crate) fn rate_rails_now(&mut self) {+ if self.rules.rails.is_empty() {+ return;+ }+ self.split_rails_at_taps();+ let (mut widened, _) = self.size_in_place(true);+ for _ in 0..12 {+ let (changed, _) = self.widen_in_place();+ widened += changed;+ if changed == 0 {+ break;+ }+ }+ if widened > 0 {+ self.log.push(format!("rails: {widened} track pieces set to their real current (wider or narrower) right after the rails were laid"));+ }+ }++ pub(crate) fn rate_rails_last(&mut self) {+ if self.rules.rails.is_empty() {+ return;+ }+ let mut widened = 0usize;+ for _ in 0..6 {+ let (changed, _) = self.widen_in_place();+ widened += changed;+ if changed == 0 {+ break;+ }+ }+ if widened > 0 {+ self.log.push(format!("rails: {widened} track pieces widened in the last sizing, after the other closing passes"));+ }+ }++ pub(crate) fn widen_rails(&mut self) {+ if self.rules.rails.is_empty() {+ return;+ }+ self.split_rails_at_taps();+ let (mut widened, _) = self.size_in_place(true);+ let mut stuck: Vec<(u32, i64)> = Vec::new();+ for _ in 0..12 {+ let (changed, left) = self.widen_in_place();+ widened += changed;+ stuck = left;+ if changed == 0 {+ break;+ }+ }+ // No room beside it: route it again at the width it needs, moving what is in the way. Nothing+ // stands unless every join it touched is still made and no other connection was given up.+ let mut moved = 0usize;+ if !stuck.is_empty() {+ self.grant_time(45.0);+ stuck.sort_by_key(|x| std::cmp::Reverse(x.1));+ for (id, need) in stuck.clone() {+ if self.over_time() {+ break;+ }+ let Some(old) = self.routes.get(&id) else { continue };+ let task = old.task.clone();+ let open = self.open_pieces(task.net);+ let saved = self.unplace(id).unwrap();+ self.rail_override.insert((task.a, task.b), need);+ let ok = self.move_for_width(&task, open);+ self.rail_override.remove(&(task.a, task.b));+ if ok {+ moved += 1;+ } else {+ self.place(saved.id, saved.task, saved.segs, saved.vias);+ }+ }+ let (changed, left) = self.widen_in_place();+ widened += changed;+ stuck = left;+ }+ self.log.push(format!("rails: widened {widened} track pieces to the current through them, re-routed {moved} wider; {} still have no room", stuck.len()));+ }++ /// One pass: rate the finished copper, widen what fits where it lies. Returns how many pieces+ /// changed, and the routes still short with the width each needs.+ fn widen_in_place(&mut self) -> (usize, Vec<(u32, i64)>) {+ self.size_in_place(false)+ }++ /// Cuts each rated rail's tracks where its own copper joins them part-way along: another track's+ /// end, a via, a pad of the net. Current is rated per track, and a trunk with a tap in its middle+ /// carries the rail's current on one side of the tap only: uncut, benchmark B's +3v3 ran at+ /// 0.82 mm for 28 mm past the MCU module to an EEPROM that draws 3 mA (the designer, 2026-11-09).+ pub(crate) fn split_rails_at_taps(&mut self) {+ let nets: Vec<NetId> = { let mut v: Vec<NetId> = self.world.rail_nets.iter().copied().collect(); v.sort_unstable(); v };+ for net in nets {+ // (point, layer or MAX for every layer, how near the track's centre line counts as on it)+ let mut joins: Vec<(P, usize, f64)> = Vec::new();+ for r in self.routes.values().filter(|r| r.task.net == net) {+ for s in &r.segs {+ joins.push((s.a, s.layer, 2_000.0));+ joins.push((s.b, s.layer, 2_000.0));+ }+ joins.extend(r.vias.iter().map(|&v| (v, usize::MAX, 2_000.0)));+ }+ for pd in self.board.pads.iter().filter(|pd| pd.net == net) {+ for &l in &pd.layers {+ joins.push((pd.center, l, 0.0));+ }+ }+ let mut ids: Vec<u32> = self.routes.values().filter(|r| r.task.net == net).map(|r| r.id).collect();+ ids.sort_unstable();+ for id in ids {+ let segs = self.routes[&id].segs.clone();+ let (mut next, mut cut) = (Vec::new(), false);+ for s in &segs {+ let (a, b): (V, V) = (s.a.into(), s.b.into());+ let len = hypot(sub(a, b));+ let d = ((s.b.0 - s.a.0).signum(), (s.b.1 - s.a.1).signum());+ let unit = if d.0 != 0 && d.1 != 0 { SQRT2 } else { 1.0 };+ let mut at: Vec<i64> = joins.iter().filter(|(_, l, _)| *l == s.layer || *l == usize::MAX).filter_map(|(q, _, tol)| {+ let v: V = (*q).into();+ let along = hypot(sub(v, a));+ // A pad counts when its centre lies under the track; other copper when it ends on the line.+ let reach = if *tol == 0.0 { s.width as f64 / 2.0 } else { *tol };+ (pt_seg(v, a, b) <= reach && along > 100_000.0 && len - along > 100_000.0).then_some(((along / unit).round() as i64 / 2) * 2)+ }).collect();+ at.sort_unstable();+ at.dedup();+ if at.is_empty() {+ next.push(s.clone());+ continue;+ }+ cut = true;+ let mut from = s.a;+ for t in at {+ let q = (s.a.0 + d.0 * t, s.a.1 + d.1 * t);+ if q != from && q != s.b {+ next.push(Seg { a: from, b: q, layer: s.layer, width: s.width });+ from = q;+ }+ }+ next.push(Seg { a: from, b: s.b, layer: s.layer, width: s.width });+ }+ if cut {+ let saved = self.unplace(id).unwrap();+ self.place(saved.id, saved.task, next, saved.vias);+ }+ }+ }+ }++ /// `widen_in_place`, and with `trim` also the other way: a track drawn more than twice as wide+ /// as its real current needs comes down to that need (never under the net's plain track width).+ /// The drop limit is then met by the widening passes that follow, from the real currents.+ fn size_in_place(&mut self, trim: bool) -> (usize, Vec<(u32, i64)>) {+ // In id order: a hash map's order changes on every start of the program (clock.rs).+ let mut routes: Vec<&Route> = self.routes.values().collect();+ routes.sort_by_key(|r| r.id);+ let text = crate::write::render(self.board, &routes, &self.rules);+ let Ok(written) = Board::parse(text) else { return (0, Vec::new()) };+ let mut rules = self.rules.clone();+ rules.bind(&written);+ let mut want: Vec<(String, P, P, usize, i64)> = Vec::new();+ let mut slim: Vec<(String, P, P, usize, i64)> = Vec::new();+ for (name, rail) in &rules.rails {+ let Some(net) = written.net_ids.iter().find(|(k, _)| k.eq_ignore_ascii_case(name)).map(|(_, v)| *v) else { continue };+ for x in crate::current::rate_rail(&written, &rules, net, rail) {+ let t = &written.tracks[x.track];+ if t.width + 1000 < x.need {+ want.push((written.net_name(net).to_string(), t.a, t.b, t.layer, x.need));+ } else if trim && !x.by_drop {+ let class = rules.track_for(net);+ let floor = if class > mm(0.3) { rules.track } else { class };+ let target = x.need.max(floor);+ if target * 2 < t.width {+ slim.push((written.net_name(net).to_string(), t.a, t.b, t.layer, target));+ }+ }+ }+ }+ let near = |p: P, q: P| (p.0 - q.0).abs() <= 2_000 && (p.1 - q.1).abs() <= 2_000;+ let mut changed = 0usize;+ let mut stuck: Vec<(u32, i64)> = Vec::new();+ let mut ids: Vec<u32> = self.routes.keys().copied().collect();+ ids.sort_unstable();+ for id in ids {+ let net = self.routes[&id].task.net;+ let name = self.board.net_name(net).to_string();+ let mine: Vec<&(String, P, P, usize, i64)> = want.iter().filter(|w| w.0 == name).collect();+ let thin: Vec<&(String, P, P, usize, i64)> = slim.iter().filter(|w| w.0 == name).collect();+ if mine.is_empty() && thin.is_empty() {+ continue;+ }+ let segs = self.routes[&id].segs.clone();+ let mut next = segs.clone();+ let mut any = false;+ let mut short = 0i64;+ for (k, s) in segs.iter().enumerate() {+ if let Some(w) = thin.iter().find(|w| w.3 == s.layer && ((near(w.1, s.a) && near(w.2, s.b)) || (near(w.1, s.b) && near(w.2, s.a)))) {+ if w.4 < s.width {+ next[k].width = w.4;+ any = true;+ changed += 1;+ }+ continue;+ }+ let Some(w) = mine.iter().find(|w| w.3 == s.layer && ((near(w.1, s.a) && near(w.2, s.b)) || (near(w.1, s.b) && near(w.2, s.a)))) else { continue };+ if self.seg_clear(s.a, s.b, s.layer, w.4, net, NO_NET) {+ next[k].width = w.4;+ any = true;+ changed += 1;+ } else {+ // Not the whole need here: the widest this stretch can take, so the stretches+ // with room make up the rest on the next rating (the drop is shared along the path).+ let got = self.widest_clear(s.a, s.b, s.layer, s.width, w.4, net);+ if got >= s.width + 20_000 {+ next[k].width = got;+ any = true;+ changed += 1;+ }+ short = short.max(w.4);+ }+ }+ if any {+ // A later re-route of this connection keeps the width: its own pad's draw would+ // size it thin again (widths_for), which is how the thin tap came about.+ let widest = next.iter().map(|s| s.width).max().unwrap_or(0);+ let saved = self.unplace(id).unwrap();+ self.rail_override.insert((saved.task.a, saved.task.b), widest);+ self.place(saved.id, saved.task, next, saved.vias);+ }+ if short > 0 {+ stuck.push((id, short));+ }+ }+ (changed, stuck)+ }++ /// How many vias a power layer change gets (reference approval 2026-11-09: area match, IPC-2152): enough+ /// that their barrels' copper is at least the copper of the trace coming in. IPC-2152 p.26, as+ /// quoted by Brooks and Adam ("Vias Are Cooler Than We Think", Signal Integrity Journal 2019):+ /// "The cross-sectional area of a via should have at least the same cross-sectional area as the+ /// conductor ... If the via has less cross-sectional area than the conductor, then multiple vias+ /// can be used". A barrel's copper is pi x (drill + plating) x plating (Brooks and Graves,+ /// "Current Carrying Capacity of Vias", 2003), plating 18 um: JLCPCB's capability table,+ /// "Average Hole Plating Thickness 18 um". A 0.3 mm via matches 0.51 mm of 1 oz trace, so his+ /// Benchmark C rails get 3 (+5V at 1.34 mm), 2 (+5V_OUT at 0.70 mm) and 1 (0.23 to 0.27 mm). The+ /// trace's width already comes from its current, so this follows current too.+ pub(crate) fn vias_for_width(&self, net: NetId, width: i64) -> usize {+ let t = VIA_PLATING_UM / 1000.0;+ let d = to_mm(self.rules.via_for(net).1);+ let barrel = std::f64::consts::PI * (d + t) * t;+ let trace = to_mm(width) * self.rules.copper_um / 1000.0;+ ((trace / barrel) - 1e-6).ceil().max(1.0) as usize+ }++ /// Adds the vias a power layer change still lacks (see `vias_for_width`), on nets with an approved+ /// current. Each goes beside the first, joined to it on both layers at the trace's width where+ /// that fits (no wider than the via), never in line with the trace (no via inside a trace) and+ /// never in a fine-pitch escape. Copper is final by now, so this takes only room nothing else+ /// wanted; where the room isn't there the rail note says how many are missing.+ pub(crate) fn second_vias(&mut self) {+ let (mut added, mut asked, mut short) = (0usize, 0usize, 0usize);+ let bus = self.bus_layer();+ let top = self.world.route_layers[0];+ let mut ids: Vec<u32> = self.routes.values().filter(|r| r.task.level == Level::Power && self.rules.rail(self.board.net_name(r.task.net)).is_some() && !r.vias.is_empty()).map(|r| r.id).collect();+ ids.sort_unstable();+ for id in ids {+ let (task, vias, segs) = {+ let r = &self.routes[&id];+ (r.task.clone(), r.vias.clone(), r.segs.clone())+ };+ let (via_dia, via_drill) = self.rules.via_for(task.net);+ // Centre to centre: the holes' rule (2.2), and no closer than the vias touching.+ let apart = via_dia.max(via_drill + self.rules.hole_to_hole());+ let (_, stub) = self.widths_for(&task);+ for v in vias {+ let width = segs.iter().filter(|s| s.a == v || s.b == v).map(|s| s.width).max().unwrap_or(stub);+ let need = self.vias_for_width(task.net, width);+ if need < 2 {+ continue;+ }+ asked += 1;+ // What is already there: this net's vias within reach of this one, itself included.+ let near = |p: P| hypot(sub(p.into(), v.into())) <= apart as f64 * 2.3;+ let mut have: Vec<P> = self.routes.values().filter(|r| r.task.net == task.net).flat_map(|r| r.vias.iter().copied()).filter(|&p| near(p)).collect();+ have.sort_unstable();+ have.dedup();+ if have.len() >= need || self.world.in_escape(v.into()) {+ if have.len() < need {+ short += 1;+ }+ continue;+ }+ let mut in_line: Vec<u8> = Vec::new();+ for s in &segs {+ if s.a == v || s.b == v {+ if let Some(d) = dir_of(s.a, s.b) {+ in_line.push(d);+ in_line.push((d + 4) % 8);+ }+ }+ }+ let link = width.min(via_dia).max(stub);+ for d in 0..8usize {+ if have.len() >= need {+ break;+ }+ if in_line.contains(&(d as u8)) {+ continue;+ }+ let (dx, dy) = DIRS[d];+ let t = if dx != 0 && dy != 0 { ((apart as f64 / SQRT2).ceil() as i64) + 2 } else { apart };+ let w = snap((v.0 + dx * t, v.1 + dy * t));+ if have.iter().any(|&h| h != v && hypot(sub(h.into(), w.into())) < apart as f64 - 1.0) || dir_of(v, w).is_none() {+ continue;+ }+ let q = Query { net: task.net, also: None, sources: &[], targets: &[], profile: Profile { width: 0, neck: 0, clearance: self.rules.clearance_for(task.net, task.net), target_gap: 0, max_vias: 0, margin: 0.0, layer_bias: None }, rules: &self.rules, soft: None, via_in: None, history: None, free: &[], arrive: None };+ let (mut b, mut c) = (Vec::new(), 0.0);+ if !self.world.via_ok(w, &q, None, &mut b, &mut c) {+ continue;+ }+ let fits = |me: &Self, wd: i64| me.world.route_layers.iter().all(|&l| (l != bus && l != top) || me.seg_clear(v, w, l, wd, task.net, NO_NET));+ let Some(wd) = [link, stub].into_iter().find(|&wd| fits(self, wd)) else { continue };+ let mut joins = vec![Seg { a: v, b: w, layer: top, width: wd }];+ if bus != top {+ joins.push(Seg { a: v, b: w, layer: bus, width: wd });+ }+ let nid = self.fresh_id();+ self.place(nid, task.clone(), joins, vec![w]);+ have.push(w);+ added += 1;+ }+ if have.len() < need {+ short += 1;+ }+ }+ }+ if asked > 0 {+ self.log.push(format!("vias (4.4, IPC-2152 area match, {VIA_PLATING_UM} um plating): {added} added at {asked} power layer changes that need more than one; {short} still have less copper than their trace"));+ }+ }++ /// The record 4.2 asks for on each power net: what was drawn. Currents are not known here, so+ /// no margin is claimed.+ pub(crate) fn rail_notes(&mut self) {+ // Each rail's worst drop, from the copper as drawn.+ let mut drop_of: std::collections::HashMap<String, String> = Default::default();+ if !self.rules.rails.is_empty() {+ // In id order: a hash map's order changes on every start of the program (clock.rs).+ let mut routes: Vec<&Route> = self.routes.values().collect();+ routes.sort_by_key(|r| r.id);+ if let Ok(written) = Board::parse(crate::write::render(self.board, &routes, &self.rules)) {+ let mut rules = self.rules.clone();+ rules.bind(&written);+ for (name, rail) in &rules.rails {+ let Some(net) = written.net_ids.iter().find(|(k, _)| k.eq_ignore_ascii_case(name)).map(|(_, v)| *v) else { continue };+ let text = match (rail.volts, crate::current::rail_drops(&written, &rules, net, rail).into_iter().max_by(|x, y| x.volts.total_cmp(&y.volts))) {+ (None, _) => "No rail voltage in the spec, so the drop is not judged.".to_string(),+ (Some(v), Some(d)) => format!("Worst copper drop {:.1} mV to {} at {} A, {:.2}% of {v} V (limit {:.2}%).", d.volts * 1000.0, d.load, d.amps, d.volts / v * 100.0, crate::current::DROP_LIMIT * 100.0),+ (Some(_), None) => "No load reached through tracks, so no drop to report.".to_string(),+ };+ drop_of.insert(name.to_ascii_lowercase(), text);+ }+ }+ }+ self.notes.push(format!("DEFAULT: weights and lengths that are not from the design document, read off the reference boards or chosen: a wide stretch beside a neck is at least {:.1} mm, a branch costs {BRANCH_VIAS} via against reaching the next pad, a rail's stub layer costs {OFF_BUS} per mm against its bus. Via and bend weights are rules.rs's defaults", MIN_WIDE_RUN / NM));+ let mut per: BTreeMap<NetId, (BTreeMap<i64, f64>, usize)> = BTreeMap::new();+ for r in self.routes.values().filter(|r| r.task.level == Level::Power) {+ let e = per.entry(r.task.net).or_default();+ for s in &r.segs {+ *e.0.entry(s.width).or_insert(0.0) += hypot(sub(s.a.into(), s.b.into())) / NM;+ }+ e.1 += r.vias.len();+ }+ let mut notes = Vec::new();+ for (net, (w, v)) in per {+ let text: Vec<String> = w.iter().rev().map(|(k, l)| format!("{:.2} mm: {:.1} mm", to_mm(*k), l)).collect();+ let name = self.board.net_name(net);+ let (main, _) = self.widths_for(&Task { net, level: Level::Power, a: 0, b: 0, main: true, zone: Some(RAIL_TAP) });+ let spec = self.rules.wide.keys().any(|k| k.eq_ignore_ascii_case(name));+ let from = if spec {+ format!("spec wideNets main {:.2} mm", to_mm(main))+ } else if main > self.rules.power_floor {+ format!("net class main {:.2} mm", to_mm(main))+ } else {+ format!("no spec width, so 4.2's {:.2} mm floor", to_mm(main))+ };+ // 4.2 asks for IPC-2152's conservative chart. It isn't computed here: the only published+ // formula at hand, the fit in his Hardware Calculator (hwcalc calcs.js:223, from smps.us),+ // gives 1.13 mm for 4.2's own example of 3 A at 20 C where 4.2 says about 2.3 mm, and+ // 0.008 mm for 0.177 A where his PiProbe spec says about 0.06 mm. So it isn't his chart.+ let load = self.rules.loads.iter().find(|(k, _)| k.eq_ignore_ascii_case(name)).map(|(_, a)| *a);+ // An approved rail: 4.2's record, chart, current, rise, required and drawn width, margin.+ if let Some(rail) = self.rules.rail(name) {+ let rise = self.rules.max_rise.unwrap_or(20.0);+ let need = self.rules.rail_width(rail.amps, true);+ let widest = w.keys().next_back().copied().unwrap_or(0);+ // A rail whose pour joins its source to its heavy loads carries the current in the pour:+ // its tracks are taps, and comparing the widest of them with the rail's width misleads+ // (benchmark C +3V3_EXT read "margin 0.87 x" with its 0.55 A in the pour, 2026-11-09).+ if self.pour_fed_net(name) {+ notes.push(format!("{name}: conservative chart, {} A at {rise} C rise, {:.2} mm as a track; carried by its pour from source to loads, tracks are taps: drawn {}, {v} vias. {}", rail.amps, to_mm(need), text.join(", "), drop_of.get(&name.to_ascii_lowercase()).cloned().unwrap_or_default()));+ } else {+ notes.push(format!("{name}: conservative chart, {} A at {rise} C rise, requires {:.2} mm; drawn {}, {v} vias; margin {:.2} x on the widest. {}", rail.amps, to_mm(need), text.join(", "), widest as f64 / need.max(1) as f64, drop_of.get(&name.to_ascii_lowercase()).cloned().unwrap_or_default()));+ }+ continue;+ }+ let current = match (load, self.rules.max_rise) {+ (Some(a), Some(r)) => format!(" Load {a} A at {r} C rise per the spec: required width not computed, IPC-2152's conservative chart isn't sourced here, so confirm the margin."),+ (Some(a), None) => format!(" Load {a} A per the spec, no maxRiseC: required width not computed."),+ _ => " No load in the spec: size it with IPC-2152 from the rail's current.".to_string(),+ };+ let unfed = self.components(net).iter().filter(|c| c.pads.is_empty() && !c.zones.is_empty()).map(|c| c.zones.len()).sum::<usize>();+ let fills = if unfed > 0 { format!(" {unfed} fill pieces have no feed.") } else { String::new() };+ notes.push(format!("{name}: {from}. Drawn {}, {} vias, bus on {}.{current}{fills}", text.join(", "), v, self.board.layers[self.bus_layer()].name));+ }+ self.notes.extend(notes);+ }++ /// What one connection costs on the final board, in the search's own terms, so it can be weighed+ /// against what a new search finds: length, bends, vias, spacing below target (4.2).+ pub(crate) fn route_cost(&self, r: &Route) -> f64 {+ let rules = &self.rules;+ let bus = self.bus_of(&r.task);+ let mut cost = r.vias.len() as f64 * rules.cost_via;+ // A long run through a pour its pad sits in can split that pour on refill: tidying must not+ // trade a via for one because it is shorter (benchmark B: /USB2_CC1 through /USB2_VBUS).+ let run = self.seg_pour_run(r);+ if run > crate::router::POUR_RUN {+ cost += 2.0 * rules.cost_via + run;+ }+ for (i, s) in r.segs.iter().enumerate() {+ let len = hypot(sub(s.a.into(), s.b.into()));+ cost += len * match bus {+ Some(l) if l == s.layer => 1.0,+ Some(_) => OFF_BUS,+ None if s.layer == 0 => 1.0,+ None => rules.cost_bottom,+ };+ if i > 0 {+ let p = &r.segs[i - 1];+ if p.layer == s.layer && p.b == s.a {+ if let (Some(d1), Some(d2)) = (dir_of(p.a, p.b), dir_of(s.a, s.b)) {+ let t = (d2 as i32 - d1 as i32).rem_euclid(8);+ cost += match t.min(8 - t) {+ 0 => 0.0,+ 1 => rules.cost_bend45,+ _ => rules.cost_bend90,+ };+ }+ }+ }+ cost += self.doubled_along(r, i) * DOUBLED;+ // Spacing below 2W outside an escape, and thin copper on an open run.+ let m = V((s.a.0 + s.b.0) as f64 / 2.0, (s.a.1 + s.b.1) as f64 / 2.0);+ if !self.world.in_escape(m) {+ let half = s.width as f64 / 2.0;+ let soft = (s.width * 2).max(rules.clearance) as f64;+ let (a, b): (V, V) = (s.a.into(), s.b.into());+ let reach = half + soft;+ let bb = (a.0.min(b.0) - reach, a.1.min(b.1) - reach, a.0.max(b.0) + reach, a.1.max(b.1) + reach);+ let mut gap = f64::INFINITY;+ self.world.layers[s.layer].visit(bb, |o| {+ if o.net != r.task.net && !matches!(o.owner, Owner::Zone(_)) {+ gap = gap.min(o.shape.dist_seg(a, b) - half);+ }+ true+ });+ let hard = rules.clearance as f64;+ if gap < soft {+ cost += len * rules.cost_tight * (1.0 - (gap - hard).max(0.0) / (soft - hard).max(1.0));+ }+ }+ }+ cost+ }++ /// How much of this segment runs beside copper of its own net: another route's, or a part of its+ /// own further along. Zero is what a person draws; the rest is a doubled run or a fold-back.+ pub(crate) fn doubled_along(&self, r: &Route, i: usize) -> f64 {+ let s = &r.segs[i];+ let mut total = 0.0;+ for (j, o) in r.segs.iter().enumerate() {+ if j > i + 1 && o.layer == s.layer {+ total += parallel_overlap(s, o);+ }+ }+ for other in self.routes.values().filter(|o| o.id != r.id && o.task.net == r.task.net) {+ for o in other.segs.iter().filter(|o| o.layer == s.layer) {+ total += parallel_overlap(s, o);+ }+ }+ total+ }++ /// How apart a net is, for a guard: pad islands first, then its connected pieces including fills.+ /// Pieces alone miss a move that swaps a bridge for a landing on an unfed fill: same count, net+ /// split. Islands alone miss a move that orphans a ring (4.3).+ pub(crate) fn open_pieces(&self, net: NetId) -> usize {+ self.islands(net).len() * 1000 + self.components(net).iter().filter(|c| counts_as_piece(c)).count()+ }++ /// Part 3 step 8: with everything placed, take each connection off and search again. A route that+ /// had to dodge copper since moved straightens; a detour forced by routing order goes. The worst+ /// connections go first, so a short budget is spent where it shows. A connection still far over+ /// its straight line then gets the stronger move: take its way from whoever holds it. Never lets+ /// a net come apart.+ pub(crate) fn optimize(&mut self) {+ if std::env::var_os("ADOM_ROUTE_NO_OPTIMISE").is_some() {+ return;+ }+ let t0 = Instant::now();+ let deadline = self.rules.deadline;+ let out_of_time = |t: &Self| deadline.map(|d| Instant::now() > d).unwrap_or(t.over_time());+ let (before_len, before_vias) = self.copper_totals();+ let (mut tried, mut kept, mut swapped) = (0usize, 0usize, 0usize);+ // Half the time for plain re-searches, so the stronger move below always gets some.+ let halfway = Instant::now() + deadline.map(|d| d.saturating_duration_since(Instant::now())).unwrap_or_default() / 2;+ // Small budgets first, so a large board gets through every connection before any gets more.+ let limit = self.rules.node_limit;+ let mut budget = 100_000usize.min(limit);+ for _ in 0..4 {+ // A connection already within one bend of its straight line has nothing to give.+ let small = self.rules.cost_bend45;+ // Doubled copper first: it is what he'd reject on sight, and the cheapest to take out.+ // A bus row stays as the first pass laid it, beside its neighbours: a re-search one line+ // at a time prices length, not keeping company, and took benchmark B's bus apart.+ let mut work: Vec<(bool, f64, u32)> = self.routes.values().filter(|r| !self.in_bus_row(&r.task) && !matches!(r.task.level, Level::Pair | Level::Kelvin) && !(r.task.a == r.task.b && r.task.zone.is_none()) && !r.segs.is_empty()).map(|r| ((0..r.segs.len()).all(|i| self.doubled_along(r, i) == 0.0), self.excess(r), r.id)).filter(|x| x.1 > small).collect();+ work.sort_by(|x, y| x.0.cmp(&y.0).then(y.1.total_cmp(&x.1)).then(x.2.cmp(&y.2)));+ let work: Vec<(f64, u32)> = work.into_iter().map(|x| (x.1, x.2)).collect();+ self.rules.node_limit = budget;+ budget = (budget * 4).min(limit);+ let mut kept_now = 0;+ for (_, id) in work {+ if out_of_time(self) || Instant::now() > halfway {+ break;+ }+ if !self.routes.contains_key(&id) {+ continue;+ }+ tried += 1;+ if self.reroute(id, false) {+ kept += 1;+ kept_now += 1;+ }+ }+ // The stronger move, only for what a plain search couldn't fix.+ let mut heavy: Vec<(f64, u32)> = self.routes.values().filter(|r| !self.in_bus_row(&r.task) && !matches!(r.task.level, Level::Pair | Level::Kelvin | Level::PlaneFanout) && !r.segs.is_empty()).map(|r| (self.excess(r), r.id)).filter(|x| x.0 > self.rules.cost_via).collect();+ heavy.sort_by(|x, y| y.0.total_cmp(&x.0).then(x.1.cmp(&y.1)));+ for (_, id) in heavy {+ if out_of_time(self) {+ break;+ }+ if !self.routes.contains_key(&id) {+ continue;+ }+ if self.reroute(id, true) {+ swapped += 1;+ kept_now += 1;+ } else if self.trace_on() {+ eprintln!(" no swap for route {id}");+ }+ }+ if kept_now == 0 || out_of_time(self) {+ break;+ }+ }+ self.rules.node_limit = limit;+ *self.world.follow.borrow_mut() = None;+ let (after_len, after_vias) = self.copper_totals();+ self.log.push(format!("optimise: {tried} connections tried, {kept} improved, {swapped} moved a neighbour; {:.1} -> {:.1} mm, {before_vias} -> {after_vias} vias, {:.1} s", before_len / NM, after_len / NM, t0.elapsed().as_secs_f64()));+ }++ fn trace_on(&self) -> bool {+ std::env::var_os("ADOM_ROUTE_TRACE").is_some()+ }++ /// How far a connection's cost is over the straight line between what it joins.+ fn excess(&self, r: &Route) -> f64 {+ let straight = if r.task.a != r.task.b { hypot(sub(self.board.pads[r.task.a].center.into(), self.board.pads[r.task.b].center.into())) } else { 0.0 };+ self.route_cost(r) - straight+ }++ /// Lift one connection and find it a better way. On a net of three or more pads it may come from+ /// either side: of the two pieces it joined, whichever way doesn't have to run back along its own+ /// copper. `displace`: the way is held by someone else, so move them.+ fn reroute(&mut self, id: u32, displace: bool) -> bool {+ let Some(old) = self.routes.get(&id) else { return false };+ let task = old.task.clone();+ self.set_follow(&task);+ let old_cost = self.route_cost(old);+ let pieces = self.open_pieces(task.net);+ let saved = self.unplace(id).unwrap();+ if displace {+ if self.swap_for(&saved, old_cost, pieces) {+ return true;+ }+ self.place(saved.id, saved.task, saved.segs, saved.vias);+ return false;+ }+ let mut ways: Vec<(Task, Vec<Terminal>, Vec<Terminal>)> = Vec::new();+ if task.zone == Some(RAIL_TAP) {+ for (sources, targets) in self.rail_ways(&task, VIA_BRANCH) {+ ways.push((task.clone(), sources, targets));+ }+ } else {+ ways.push((task.clone(), if self.joins_copper(&task) { self.piece_pads(&task) } else { vec![self.pad_terminal(task.a)] }, self.targets_of(&task)));+ if task.a != task.b && self.joins_copper(&task) {+ let back = Task { a: task.b, b: task.a, ..task.clone() };+ let (sources, targets) = (self.piece_pads(&back), self.targets_of(&back));+ ways.push((back, sources, targets));+ }+ }+ let bus = self.bus_of(&task);+ let mut best: Option<(f64, Task, Vec<Seg>, Vec<P>)> = None;+ for (way, sources, targets) in ways {+ if targets.is_empty() {+ continue;+ }+ for pf in self.opt_profiles(&way) {+ let rules = self.rules.clone();+ let one = [pf];+ let Some((path, pf)) = self.weigh(&way, &sources, &targets, &one, &rules) else { continue };+ let way = if way.zone == Some(RAIL_TAP) { self.task_as_made(&way, &path) } else { way.clone() };+ let pts = match bus {+ Some(l) => self.finish_path_on(&path, way.net, NO_NET, &|x| if x == l { pf.width } else { pf.neck }),+ None => self.finish_path(&path, way.net, NO_NET, pf.neck),+ };+ let nid = self.commit(&way, &pts, pf.width, pf.neck, NO_NET);+ let c = self.route_cost(self.routes.get(&nid).unwrap());+ let ok = c + 0.05 * NM < old_cost && best.as_ref().map(|b| c < b.0).unwrap_or(true) && self.open_pieces(way.net) <= pieces;+ let r = self.unplace(nid).unwrap();+ if ok {+ best = Some((c, way.clone(), r.segs, r.vias));+ }+ }+ }+ match best {+ Some((_, way, segs, vias)) => {+ self.place(id, way, segs, vias);+ true+ }+ None => {+ self.place(saved.id, saved.task, saved.segs, saved.vias);+ false+ }+ }+ }++ /// All the pads already joined to pad `a`, as places to start from.+ fn piece_pads(&self, task: &Task) -> Vec<Terminal> {+ let comps = self.components(task.net);+ match comps.iter().find(|c| c.pads.contains(&task.a)) {+ Some(c) => c.pads.iter().map(|&p| self.pad_terminal(p)).collect(),+ None => vec![self.pad_terminal(task.a)],+ }+ }++ /// The searches worth trying for a connection on the finished board: the same widths the task+ /// would get, with and without a via. Spacing is held at the target; dense spots keep what they have.+ fn opt_profiles(&self, task: &Task) -> Vec<Profile> {+ let (w, n) = self.widths_for(task);+ let c = self.rules.clearance_for(task.net, task.net);+ let tgt = match task.level {+ Level::Crystal | Level::Kelvin => c,+ _ => (w * 2).max(c),+ };+ let bias = self.bus_of(task);+ let mk = |width: i64, target_gap: i64, max_vias: u32, margin: f64| Profile { width, neck: n, clearance: c, target_gap, max_vias, margin, layer_bias: bias };+ if bias.is_some() {+ return vec![mk(w, c, 2, 3.0 * NM), mk(w, c, 2, 8.0 * NM)];+ }+ let mut out = vec![mk(w, tgt, 0, 5.0 * NM)];+ if !matches!(task.level, Level::Crystal | Level::Kelvin | Level::Pair) {+ out.push(mk(w, tgt, 2, 5.0 * NM));+ }+ out+ }++ fn copper_totals(&self) -> (f64, usize) {+ let mut len = 0.0;+ let mut vias = 0;+ for r in self.routes.values() {+ vias += r.vias.len();+ for s in &r.segs {+ len += hypot(sub(s.a.into(), s.b.into()));+ }+ }+ (len, vias)+ }+}++/// How far a pin's stub to its cap may run: past this a stub on the pad layer costs more than going+/// down to the bus and back up, in the search's own weights (2 vias + d against OFF_BUS d, for+/// OFF_BUS 3). Nothing else sets it, so changing a weight moves it with the search.+pub fn cap_reach(rules: &crate::rules::Rules) -> f64 {+ 2.0 * rules.cost_via / (OFF_BUS - 1.0)+}++fn counts_as_piece(c: &Comp) -> bool {+ !c.pads.is_empty() || !c.zones.is_empty()+}++fn poly_center(p: &[V]) -> V {+ let n = p.len().max(1) as f64;+ V(p.iter().map(|x| x.0).sum::<f64>() / n, p.iter().map(|x| x.1).sum::<f64>() / n)+}++/// Overlap along their length if two segments run parallel within a track's reach of each other,+/// centre to centre; else zero. Collinear retrace counts too.+fn parallel_overlap(s: &Seg, o: &Seg) -> f64 {+ parallel_within(s, o, DOUBLED_REACH * NM)+}++fn parallel_within(s: &Seg, o: &Seg, reach: f64) -> f64 {+ let (a, b): (V, V) = (s.a.into(), s.b.into());+ let (c, d): (V, V) = (o.a.into(), o.b.into());+ let l = hypot(sub(b, a));+ let m = hypot(sub(d, c));+ if l < 1.0 || m < 1.0 {+ return 0.0;+ }+ let u = V((b.0 - a.0) / l, (b.1 - a.1) / l);+ let v = V((d.0 - c.0) / m, (d.1 - c.1) / m);+ if (u.0 * v.1 - u.1 * v.0).abs() > 0.02 {+ return 0.0;+ }+ let off = ((c.0 - a.0) * -u.1 + (c.1 - a.1) * u.0).abs();+ if off > reach {+ return 0.0;+ }+ let t0 = (c.0 - a.0) * u.0 + (c.1 - a.1) * u.1;+ let t1 = (d.0 - a.0) * u.0 + (d.1 - a.1) * u.1;+ (l.min(t0.max(t1)) - t0.min(t1).max(0.0)).max(0.0)+}++/// Keep width steps to what they buy. A wide stretch between necks shorter than this goes at the neck.+/// A default read off the reference boards, not a rule from his document: his wide sections beside a neck run a+/// median of 3.7 mm on benchmark C (lower quartile 1.3) and 21 mm on benchmark B; on piprobe_v13 he+/// doesn't step signals at all.+const MIN_WIDE_RUN: f64 = 3.0 * NM;++/// One width along a route except where a neck really has to be. Runs are in path order; a via doesn't+/// break a stretch.+pub(crate) fn smooth_widths(segs: &mut [Seg], width: i64, neck: i64) {+ if width <= neck || segs.iter().all(|s| s.width == width) {+ return;+ }+ let mut i = 0;+ while i < segs.len() {+ if segs[i].width != width {+ i += 1;+ continue;+ }+ let mut j = i;+ let mut len = 0.0;+ while j < segs.len() && segs[j].width == width {+ len += hypot(sub(segs[j].a.into(), segs[j].b.into()));+ j += 1;+ }+ if len < MIN_WIDE_RUN {+ for s in &mut segs[i..j] {+ s.width = neck;+ }+ }+ i = j;+ }+}++/// If this segment starts lying along one of `others`: `Some(None)` when all of it does, else+/// `Some(Some(p))` with `p` where it comes off. `None` when it doesn't.+fn overlap_end(s: &Seg, others: &[Seg]) -> Option<Option<P>> {+ let d = dir_of(s.a, s.b)?;+ for o in others.iter().filter(|o| o.layer == s.layer) {+ let Some(od) = dir_of(o.a, o.b) else { continue };+ if od % 4 != d % 4 || pt_seg(s.a.into(), o.a.into(), o.b.into()) > 2.0 {+ continue;+ }+ if pt_seg(s.b.into(), o.a.into(), o.b.into()) <= 2.0 {+ return Some(None);+ }+ for e in [o.a, o.b] {+ // The end of the other track that lies ahead on this one.+ if e != s.a && pt_seg(e.into(), s.a.into(), s.b.into()) <= 2.0 {+ return Some(Some(e));+ }+ }+ }+ None+}++/// How long two nets' copper runs parallel at this centre distance (a pair's pitch).+pub(crate) fn coupled_len(p: &[Seg], n: &[Seg], pitch: f64) -> f64 {+ let mut total = 0.0;+ for s in p {+ for o in n.iter().filter(|o| o.layer == s.layer) {+ total += parallel_within(s, o, pitch);+ }+ }+ total+}++/// Join straight pieces of one width back into one segment.+pub(crate) fn merge_runs(segs: &mut Vec<Seg>) {+ let mut merged: Vec<Seg> = Vec::new();+ for s in segs.drain(..) {+ if let Some(l) = merged.last_mut() {+ if l.layer == s.layer && l.width == s.width && l.b == s.a && dir_of(l.a, l.b) == dir_of(s.a, s.b) {+ l.b = s.b;+ continue;+ }+ }+ merged.push(s);+ }+ *segs = merged;+}++#[cfg(test)]+mod tests {+ use super::*;++ fn seg(a: P, b: P, width: i64) -> Seg {+ Seg { a, b, layer: 0, width }+ }++ #[test]+ fn a_short_wide_stretch_between_necks_goes_at_the_neck() {+ let (w, n) = (mm(0.2), mm(0.15));+ let mut v = vec![seg((0, 0), (mm(1.0), 0), n), seg((mm(1.0), 0), (mm(2.0), 0), w), seg((mm(2.0), 0), (mm(3.0), 0), n)];+ smooth_widths(&mut v, w, n);+ assert!(v.iter().all(|s| s.width == n));+ // A long one is worth its two steps, and a route with no neck stays as it is however short.+ let mut v = vec![seg((0, 0), (mm(1.0), 0), n), seg((mm(1.0), 0), (mm(5.0), 0), w)];+ smooth_widths(&mut v, w, n);+ assert_eq!(v[1].width, w);+ let mut v = vec![seg((0, 0), (mm(1.0), 0), w)];+ smooth_widths(&mut v, w, n);+ assert_eq!(v[0].width, w);+ }++ #[test]+ fn a_retrace_is_found_and_only_on_the_same_line() {+ let other = [seg((0, 0), (mm(2.0), 0), mm(0.2))];+ // All of it lies on the other track.+ assert_eq!(overlap_end(&seg((mm(0.5), 0), (mm(1.5), 0), mm(0.2)), &other), Some(None));+ // Runs off the end at 2 mm.+ assert_eq!(overlap_end(&seg((mm(1.0), 0), (mm(3.0), 0), mm(0.2)), &other), Some(Some((mm(2.0), 0))));+ // Leaves at an angle, or beside it: not a retrace.+ assert_eq!(overlap_end(&seg((mm(1.0), 0), (mm(2.0), mm(1.0)), mm(0.2)), &other), None);+ assert_eq!(overlap_end(&seg((mm(1.0), mm(0.1)), (mm(2.0), mm(0.1)), mm(0.2)), &other), None);+ }++ #[test]+ fn doubled_copper_is_measured_side_by_side() {+ let a = seg((0, 0), (mm(2.0), 0), mm(0.15));+ assert!((parallel_overlap(&a, &seg((mm(1.0), mm(0.158)), (mm(3.0), mm(0.158)), mm(0.15))) - mm(1.0) as f64).abs() < 2.0);+ assert_eq!(parallel_overlap(&a, &seg((0, mm(1.0)), (mm(2.0), mm(1.0)), mm(0.15))), 0.0);+ assert_eq!(parallel_overlap(&a, &seg((0, 0), (0, mm(2.0)), mm(0.15))), 0.0);+ }++ #[test]+ fn straight_pieces_of_one_width_merge() {+ let s = |a: P, b: P, w: i64| Seg { a, b, layer: 0, width: w };+ let mut v = vec![s((0, 0), (10, 0), 5), s((10, 0), (20, 0), 5), s((20, 0), (30, 0), 3), s((30, 0), (30, 10), 3)];+ merge_runs(&mut v);+ assert_eq!(v.len(), 3);+ assert_eq!((v[0].a, v[0].b), ((0, 0), (20, 0)));+ }+}
src/pour.rsadded+467@@ -0,0 +1,467 @@+//! What a pour becomes when KiCad refills it. The router and the rest of the checker read a zone as+//! the shape saved in the file, one piece of copper; a refill cuts it around every track of another+//! net, and an island with something on it stays as a separate piece (benchmark B: KiCad's DRC+//! reported /USB2_VBUS unconnected to itself while every join here read closed).+//!+//! The model: per net and layer, rasterise the zones and close each cell within clearance plus half+//! `min_thickness` of foreign copper. What stays open is where the centre of a `min_thickness` brush+//! can reach, so a neck too thin to survive the fill's own erosion reads as cut. Copper of the net+//! joins whatever open cells lie within reach, and islands nothing touches are dropped, as KiCad's+//! island removal drops them. KiCad's DRC stays the judge; this exists to see splits while routing.++use crate::board::*;+use crate::conn::{touches, Piece, Uf};+use crate::geom::*;+use crate::index::Owner;+use crate::rules::Rules;+use crate::search::World;+use std::collections::{BTreeMap, BTreeSet};++/// Grid pitch. Half a cell of slack on the clearance, so a lane exactly one brush wide stays open.+const STEP: f64 = 0.05 * NM;+const SLACK: f64 = STEP * 0.75;++pub struct Split {+ pub net: NetId,+ pub layer: usize,+ pub at: V,+ /// Pieces beyond what plain connectivity already counts as open.+ pub extra: usize,+ /// Nets whose copper, gone, would rejoin the pieces.+ pub cutters: Vec<NetId>,+}++struct Raster {+ layer: usize,+ x0: f64,+ y0: f64,+ w: usize,+ h: usize,+ inside: Vec<bool>,+ blocked: Vec<u16>,+ /// The cells each foreign net closes, once per obstacle.+ by_net: BTreeMap<NetId, Vec<u32>>,+ /// Item index and the cells it reaches.+ reach: Vec<(usize, Vec<u32>)>,+}++impl Raster {+ fn at(&self, i: usize) -> V {+ V(self.x0 + (i % self.w) as f64 * STEP, self.y0 + (i / self.w) as f64 * STEP)+ }++ /// The cell nearest a point, if the point lies on the raster.+ fn index(&self, p: V) -> Option<usize> {+ let (cx, cy) = (((p.0 - self.x0) / STEP).round(), ((p.1 - self.y0) / STEP).round());+ (cx >= 0.0 && cy >= 0.0 && (cx as usize) < self.w && (cy as usize) < self.h).then(|| cy as usize * self.w + cx as usize)+ }++ fn fill_poly(&mut self, pts: &[V]) {+ let n = pts.len();+ for row in 0..self.h {+ let y = self.y0 + row as f64 * STEP;+ let mut xs: Vec<f64> = Vec::new();+ for k in 0..n {+ let (a, b) = (pts[k], pts[(k + 1) % n]);+ if (a.1 <= y) != (b.1 <= y) {+ xs.push(a.0 + (y - a.1) / (b.1 - a.1) * (b.0 - a.0));+ }+ }+ xs.sort_by(|p, q| p.total_cmp(q));+ for c in xs.chunks(2) {+ if c.len() < 2 {+ continue;+ }+ let first = ((c[0] - self.x0) / STEP).ceil().max(0.0) as usize;+ let last = ((c[1] - self.x0) / STEP).floor().min(self.w as f64 - 1.0);+ if last < 0.0 {+ continue;+ }+ for col in first..=last as usize {+ self.inside[row * self.w + col] = true;+ }+ }+ }+ }++ /// Cells of the bounding box `bb` grown by `need`, in order.+ fn cells(&self, bb: (f64, f64, f64, f64), need: f64) -> Vec<usize> {+ let span = |lo: f64, hi: f64, o: f64, n: usize| -> Option<(usize, usize)> {+ let a = ((lo - need - o) / STEP).floor().max(0.0) as usize;+ let b = ((hi + need - o) / STEP).ceil();+ if b < 0.0 || a >= n {+ return None;+ }+ Some((a, (b as usize).min(n - 1)))+ };+ let (Some((c0, c1)), Some((r0, r1))) = (span(bb.0, bb.2, self.x0, self.w), span(bb.1, bb.3, self.y0, self.h)) else { return Vec::new() };+ let mut out = Vec::with_capacity((c1 - c0 + 1) * (r1 - r0 + 1));+ for r in r0..=r1 {+ for c in c0..=c1 {+ out.push(r * self.w + c);+ }+ }+ out+ }++ fn close(&mut self, shape: &Shape, need: f64, net: NetId) {+ let mut hit = Vec::new();+ for i in self.cells(shape.bbox(), need) {+ if self.inside[i] && shape.dist_pt(self.at(i)) < need {+ hit.push(i as u32);+ }+ }+ for &i in &hit {+ self.blocked[i as usize] = self.blocked[i as usize].saturating_add(1);+ }+ self.by_net.entry(net).or_default().extend(hit);+ }++ /// Labels the open cells, ignoring what `without` closes. 0: closed.+ fn label(&self, without: Option<NetId>) -> (Vec<u32>, u32) {+ let mut blocked = self.blocked.clone();+ if let Some(cells) = without.and_then(|n| self.by_net.get(&n)) {+ for &i in cells {+ blocked[i as usize] -= 1;+ }+ }+ let mut lab = vec![0u32; self.inside.len()];+ let mut next = 0;+ let mut stack = Vec::new();+ for s in 0..lab.len() {+ if lab[s] != 0 || !self.inside[s] || blocked[s] != 0 {+ continue;+ }+ next += 1;+ lab[s] = next;+ stack.push(s);+ while let Some(i) = stack.pop() {+ let (c, r) = (i % self.w, i / self.w);+ let mut go = |j: usize, stack: &mut Vec<usize>| {+ if lab[j] == 0 && self.inside[j] && blocked[j] == 0 {+ lab[j] = next;+ stack.push(j);+ }+ };+ if c > 0 { go(i - 1, &mut stack); }+ if c + 1 < self.w { go(i + 1, &mut stack); }+ if r > 0 { go(i - self.w, &mut stack); }+ if r + 1 < self.h { go(i + self.w, &mut stack); }+ }+ }+ (lab, next)+ }+}++/// How many pieces the net is in after a refill: anything holding a pad or a kept island.+/// Returns the count and, for the most pieces' smallest kept island, where it lies.+fn pieces(rasters: &[Raster], base: &Uf, is_pad: &[bool], without: Option<NetId>) -> (usize, Option<(usize, V)>) {+ let n = is_pad.len();+ let labelled: Vec<(Vec<u32>, u32)> = rasters.iter().map(|r| r.label(without)).collect();+ let total: u32 = labelled.iter().map(|l| l.1).sum();+ // Items first, then every island of every layer.+ let mut uf = Uf::new(n + total as usize);+ for i in 0..n {+ let root = base.clone().find(i as u32);+ uf.union(i as u32, root);+ }+ let mut kept: BTreeSet<u32> = BTreeSet::new();+ let mut offset = n as u32;+ let mut where_is: Vec<(usize, usize)> = Vec::new();+ for (ri, (r, (lab, count))) in rasters.iter().zip(&labelled).enumerate() {+ for (item, cells) in &r.reach {+ for &c in cells {+ let l = lab[c as usize];+ if l != 0 {+ let id = offset + l - 1;+ uf.union(*item as u32, id);+ kept.insert(id);+ }+ }+ }+ for _ in 0..*count {+ where_is.push((ri, 0));+ }+ offset += count;+ }+ let mut roots: BTreeSet<u32> = BTreeSet::new();+ for (i, pad) in is_pad.iter().enumerate() {+ if *pad {+ roots.insert(uf.find(i as u32));+ }+ }+ for &id in &kept {+ roots.insert(uf.find(id));+ }+ // A cell of the smallest kept island, for the report.+ let mut smallest: Option<(usize, usize, usize)> = None;+ let mut offset = n as u32;+ for (ri, (lab, count)) in labelled.iter().enumerate() {+ let mut sizes = vec![0usize; *count as usize];+ let mut first = vec![0usize; *count as usize];+ for (i, &l) in lab.iter().enumerate() {+ if l != 0 {+ if sizes[l as usize - 1] == 0 {+ first[l as usize - 1] = i;+ }+ sizes[l as usize - 1] += 1;+ }+ }+ for k in 0..*count as usize {+ if kept.contains(&(offset + k as u32)) && smallest.map(|s| sizes[k] < s.0).unwrap_or(true) {+ smallest = Some((sizes[k], ri, first[k]));+ }+ }+ offset += count;+ }+ (roots.len(), smallest.map(|(_, ri, i)| (rasters[ri].layer, rasters[ri].at(i))))+}++/// The kept islands of a net's pours that a refill leaves apart from the net's largest piece, each+/// with cell centres to try a stitching via at (spread over the island, the most central first).+/// A kept island is one the net's own copper touches: KiCad keeps it, and reports it unconnected.+pub fn loose_islands(board: &Board, rules: &Rules, world: &World, net: NetId) -> Vec<(usize, Vec<V>, Vec<usize>)> {+ let (rasters, base, is_pad, _) = model(board, rules, world, net);+ let n = is_pad.len();+ let labelled: Vec<(Vec<u32>, u32)> = rasters.iter().map(|r| r.label(None)).collect();+ let total: u32 = labelled.iter().map(|l| l.1).sum();+ let mut uf = Uf::new(n + total as usize);+ for i in 0..n {+ let root = base.clone().find(i as u32);+ uf.union(i as u32, root);+ }+ let mut kept: BTreeSet<u32> = BTreeSet::new();+ let mut offset = n as u32;+ let mut starts: Vec<u32> = Vec::new();+ // The net's pads, in the order the model lists its items (pads first), and the pads on each island.+ let pad_ids: Vec<usize> = board.pads.iter().enumerate().filter(|(_, p)| p.net == net && !p.layers.is_empty()).map(|(i, _)| i).collect();+ let mut pads_on: BTreeMap<u32, BTreeSet<usize>> = BTreeMap::new();+ for (r, (lab, count)) in rasters.iter().zip(&labelled) {+ starts.push(offset);+ for (item, cells) in &r.reach {+ for &c in cells {+ let l = lab[c as usize];+ if l != 0 {+ uf.union(*item as u32, offset + l - 1);+ kept.insert(offset + l - 1);+ if let Some(&p) = pad_ids.get(*item) {+ pads_on.entry(offset + l - 1).or_default().insert(p);+ }+ }+ }+ }+ offset += count;+ }+ // Cells per connected piece of the net; the largest is the net's body.+ let mut size: BTreeMap<u32, usize> = BTreeMap::new();+ let mut cells_of: BTreeMap<u32, Vec<(usize, usize)>> = BTreeMap::new();+ for (ri, (lab, _)) in labelled.iter().enumerate() {+ for (i, &l) in lab.iter().enumerate() {+ if l != 0 && kept.contains(&(starts[ri] + l - 1)) {+ *size.entry(uf.find(starts[ri] + l - 1)).or_insert(0) += 1;+ cells_of.entry(starts[ri] + l - 1).or_default().push((ri, i));+ }+ }+ }+ let Some((&body, _)) = size.iter().max_by_key(|(k, v)| (**v, std::cmp::Reverse(**k))) else {+ // No pad reaches any piece of the pour (every thermal spoke is starved, or the fill is cut away+ // round each pad): the pour joins nothing, so every group of pads that copper does not join+ // to the largest group is loose. On an I/O carrier board: small pours over pin-header pads+ // left 16 joins open in KiCad, and this returned nothing to mend.+ let mut groups: BTreeMap<u32, Vec<usize>> = BTreeMap::new();+ for (k, &pad) in pad_ids.iter().enumerate() {+ groups.entry(uf.find(k as u32)).or_default().push(pad);+ }+ let big = groups.iter().max_by_key(|(k, v)| (v.len(), std::cmp::Reverse(**k))).map(|(k, _)| *k);+ return groups.into_iter().filter(|(k, _)| Some(*k) != big).map(|(_, pads)| (board.pads[pads[0]].layers.first().copied().unwrap_or(0), Vec::new(), pads)).collect();+ };+ let mut out = Vec::new();+ // Pads of the net that no spoke or copper joins to the body and that sit on no island of their+ // own: each is a piece by itself (cells empty), to be given a via beside it.+ let on_island: BTreeSet<usize> = pads_on.values().flat_map(|s| s.iter().copied()).collect();+ for (k, &pad) in pad_ids.iter().enumerate() {+ if uf.find(k as u32) != body && !on_island.contains(&pad) {+ let layer = board.pads[pad].layers.first().copied().unwrap_or(0);+ out.push((layer, Vec::new(), vec![pad]));+ }+ }+ for (id, cells) in cells_of {+ if uf.find(id) == body || cells.is_empty() {+ continue;+ }+ let r = &rasters[cells[0].0];+ let pts: Vec<V> = cells.iter().map(|&(_, i)| r.at(i)).collect();+ let c = V(pts.iter().map(|p| p.0).sum::<f64>() / pts.len() as f64, pts.iter().map(|p| p.1).sum::<f64>() / pts.len() as f64);+ let mut pts = pts;+ pts.sort_by(|a, b| hypot(sub(*a, c)).total_cmp(&hypot(sub(*b, c))));+ let step = (pts.len() / 60).max(1);+ out.push((r.layer, pts.into_iter().step_by(step).collect(), pads_on.get(&id).map(|p| p.iter().copied().collect()).unwrap_or_default()));+ }+ out+}++pub fn splits(board: &Board, rules: &Rules, world: &World) -> Vec<Split> {+ let nets: BTreeSet<NetId> = board.zones.iter().filter(|z| z.net != NO_NET && !z.keepout_tracks && z.outline.len() >= 3).map(|z| z.net).collect();+ let mut out = Vec::new();+ for net in nets {+ if let Some(s) = split_of(board, rules, world, net) {+ out.push(s);+ }+ }+ out+}++/// The refill model of one net: its pours rasterised per layer and cut by foreign copper, the net's+/// own items joined without any fill, and how many pieces the net is in without its pours.+fn model(board: &Board, rules: &Rules, world: &World, net: NetId) -> (Vec<Raster>, Uf, Vec<bool>, usize) {+ // Items of this net, and what joins them without any fill.+ let mut items: Vec<Piece> = Vec::new();+ let mut shapes: Vec<(Option<usize>, Shape, bool)> = Vec::new();+ for (i, p) in board.pads.iter().enumerate().filter(|(_, p)| p.net == net && !p.layers.is_empty()) {+ items.push(Piece::Pad(i));+ shapes.push((None, p.shape.clone(), true));+ }+ for t in board.tracks.iter().filter(|t| t.net == net) {+ items.push(Piece::Seg { layer: t.layer, a: t.a.into(), b: t.b.into(), r: t.width as f64 / 2.0 });+ shapes.push((Some(t.layer), Shape::Capsule { a: t.a.into(), b: t.b.into(), r: t.width as f64 / 2.0 }, false));+ }+ for v in board.vias.iter().filter(|v| v.net == net) {+ items.push(Piece::Via { at: v.at.into(), r: v.size as f64 / 2.0 });+ shapes.push((None, Shape::Circle { c: v.at.into(), r: v.size as f64 / 2.0 }, false));+ }+ let is_pad: Vec<bool> = items.iter().map(|p| matches!(p, Piece::Pad(_))).collect();+ let mut base = Uf::new(items.len());+ for i in 0..items.len() {+ for j in i + 1..items.len() {+ if base.find(i as u32) != base.find(j as u32) && touches(&items[i], &items[j], board) {+ base.union(i as u32, j as u32);+ }+ }+ }+ // Without any fill the net is in this many pieces; plain open joins are counted elsewhere,+ // so compare with what the saved shapes join.+ let plain = crate::conn::net_groups(board, net, &[]).len().max(1);++ let layers: BTreeSet<usize> = board.zones.iter().filter(|z| z.net == net && !z.keepout_tracks).flat_map(|z| z.layers.iter().copied()).collect();+ let mut rasters = Vec::new();+ for layer in layers {+ let own: Vec<&Zone> = board.zones.iter().filter(|z| z.net == net && !z.keepout_tracks && z.outline.len() >= 3 && z.layers.contains(&layer)).collect();+ if own.is_empty() {+ continue;+ }+ let bb = own.iter().flat_map(|z| z.outline.iter()).fold((f64::MAX, f64::MAX, f64::MIN, f64::MIN), |m, v| (m.0.min(v.0), m.1.min(v.1), m.2.max(v.0), m.3.max(v.1)));+ let (x0, y0) = ((bb.0 / STEP).floor() * STEP, (bb.1 / STEP).floor() * STEP);+ let (w, h) = (((bb.2 - x0) / STEP).ceil() as usize + 1, ((bb.3 - y0) / STEP).ceil() as usize + 1);+ let mut r = Raster { layer, x0, y0, w, h, inside: vec![false; w * h], blocked: vec![0; w * h], by_net: BTreeMap::new(), reach: Vec::new() };+ for z in &own {+ r.fill_poly(&z.outline);+ }+ let thick = own.iter().map(|z| z.min_thickness).fold(0.0, f64::max);+ let brush = thick / 2.0 - SLACK;+ let zone_clr = own.iter().map(|z| z.clearance).fold(0.0, f64::max);+ let top = own.iter().map(|z| z.priority).max().unwrap_or(0);+ let window = (bb.0, bb.1, bb.2, bb.3);+ let mut found: Vec<(Shape, f64, NetId)> = Vec::new();+ world.layers[layer].visit(window, |o| {+ if o.net != net && matches!(o.owner, Owner::Pad(_) | Owner::Conn(_) | Owner::Fixed) {+ let local = if let Owner::Pad(i) = o.owner { world.pad_clearance[i as usize] } else { 0.0 };+ found.push((o.shape.clone(), zone_clr.max(rules.clearance_for(net, o.net) as f64).max(local) + brush, o.net));+ }+ true+ });+ world.holes.visit(window, |o| {+ if o.net != net && !matches!(o.shape, Shape::Poly { .. }) {+ found.push((o.shape.clone(), rules.hole_clearance() as f64 + brush, o.net));+ }+ true+ });+ world.edge.visit(window, |o| {+ found.push((o.shape.clone(), rules.edge as f64 + brush, NO_NET));+ true+ });+ for (s, need, n) in &found {+ r.close(s, *need, *n);+ }+ // Keep-outs, and zones of other nets that fill first. Their outlines stand in for their+ // fills: fills of higher priority sit inside them.+ for z in board.zones.iter().filter(|z| z.layers.contains(&layer) && z.outline.len() >= 3) {+ let above = z.net != net && z.net != NO_NET && !z.keepout_tracks && z.priority > top;+ if !(above || z.keepout_pour) {+ continue;+ }+ let extra = if z.keepout_pour { brush } else { zone_clr.max(rules.clearance_for(net, z.net) as f64) + brush };+ r.close(&Shape::Poly { pts: z.outline.clone(), r: 0.0 }, extra, z.net);+ }+ // What of the net's own copper reaches which cells.+ let joins = own.iter().any(|z| z.joins_pads);+ let touch = thick / 2.0 + SLACK;+ let mut reach = Vec::new();+ for (k, (only, shape, pad)) in shapes.iter().enumerate() {+ if only.map(|l| l != layer).unwrap_or(false) || (*pad && !joins) {+ continue;+ }+ if let Piece::Pad(i) = items[k] {+ if !board.pads[i].layers.contains(&layer) {+ continue;+ }+ }+ // A pad joins a pour with thermal reliefs only through its spokes: four of them along the+ // pad's axes, each across the relief gap. A spoke stands where its far end is open copper+ // and nothing closes the way to it. KiCad wants two (min_resolved_spokes); with one the+ // pad is joined but DRC reports it starved, with none it is not joined at all. Read as+ // touching, pads that tracks had boxed in counted as joined: a motor-controller board had+ // five such ground pads open in KiCad while every join read closed here (2026-11-10).+ if let Piece::Pad(i) = items[k] {+ let pd = &board.pads[i];+ let relief = own.iter().find_map(|z| z.thermal.filter(|_| !z.thermal_tht_only || pd.drill.is_some()));+ let relief = match pd.zone_connect {+ Some(2) => None,+ Some(0) => { continue; }+ Some(3) if pd.drill.is_none() => None,+ _ => relief,+ };+ if let Some((gap, _width)) = relief {+ let c: V = pd.center.into();+ let mut cells: Vec<u32> = Vec::new();+ for q in 0..4 {+ let a = (pd.angle + 90.0 * q as f64).to_radians();+ let u = V(a.cos(), -a.sin());+ let half = if q % 2 == 0 { pd.size.0 } else { pd.size.1 } / 2.0;+ let far = half + gap + thick / 2.0 + 2.0 * STEP;+ let steps = ((far - half) / STEP).ceil() as usize;+ let open = |d: f64| -> Option<usize> { r.index(V(c.0 + u.0 * d, c.1 + u.1 * d)).filter(|&i| r.inside[i] && r.blocked[i] == 0) };+ if (0..=steps).all(|s| open(half + (far - half) * s as f64 / steps.max(1) as f64).is_some()) {+ if let Some(i) = open(far) {+ cells.push(i as u32);+ }+ }+ }+ reach.push((k, cells));+ continue;+ }+ }+ let cells: Vec<u32> = r.cells(shape.bbox(), touch).into_iter().filter(|&i| r.inside[i] && shape.dist_pt(r.at(i)) <= touch).map(|i| i as u32).collect();+ reach.push((k, cells));+ }+ r.reach = reach;+ rasters.push(r);+ }+ (rasters, base, is_pad, plain)+}++fn split_of(board: &Board, rules: &Rules, world: &World, net: NetId) -> Option<Split> {+ let (rasters, base, is_pad, plain) = model(board, rules, world, net);+ let (now, smallest) = pieces(&rasters, &base, &is_pad, None);+ if now <= plain {+ return None;+ }+ let (layer, at) = smallest.unwrap_or((0, V(0.0, 0.0)));+ let foreign: BTreeSet<NetId> = rasters.iter().flat_map(|r| r.by_net.keys().copied()).filter(|&n| n != NO_NET).collect();+ let mut cutters = Vec::new();+ for f in foreign {+ if pieces(&rasters, &base, &is_pad, Some(f)).0 < now {+ cutters.push(f);+ }+ }+ Some(Split { net, layer, at, extra: now - plain, cutters })+}
src/power.rsadded+177@@ -0,0 +1,177 @@+//! The power stage's plan: each rated rail worked out as a whole before any copper is laid.+//!+//! A rail's widths depend on the whole rail (where the source is, which loads hang off which+//! branch), so they cannot be found one connection at a time and patched after. Per rail:+//!+//! 1. A tree from the source. Loads join heaviest first, each to the nearest part already in the+//! tree; parts that draw nothing (capacitors, test points, pull-ups) join last, each to the+//! part nearest it, which is its own IC.+//! 2. Each branch's current: its part's draw and everything hanging off it, never over the rail's.+//! 3. Each branch's width: the larger of the chart's width for that current and its share of the+//! drop limit. Along a load's path the limit is shared so the copper used is least, which is+//! width in proportion to the square root of the current. A pad narrower than the branch costs+//! a short neck, and that neck's drop comes out of the limit first.+//! 4. What each part's tap may land on: its own part's pads and the copper of the parts above it+//! in the tree. That is what makes the planned currents true of the copper as laid.+//!+//! Rails fed by their pour are not planned: there every pad draws its own from the pour.++use crate::board::*;+use crate::current::{DROP_LIMIT, RHO_CU};+use crate::geom::*;+use crate::rules::Rules;+use std::collections::{HashMap, HashSet};++pub struct PadPlan {+ pub width: i64,+ pub amps: f64,+ /// Pads this pad's tap may land on (or on copper routed from them).+ pub allowed: HashSet<usize>,+ /// A part drawing under a hundredth of its rail (a pull-up, a capacitor, a test point) may land+ /// on any of the rail's copper: what it adds to a branch is nothing next to that branch's own.+ pub anywhere: bool,+}++#[derive(Default)]+pub struct PowerPlan {+ pub pads: HashMap<usize, PadPlan>,+ /// Per rail, the pads to tap, in the order to lay them: the tree from the source outward.+ pub order: Vec<(NetId, Vec<usize>)>,+ /// Per rail, its source parts' pads: a tap lands only on copper already joined to one of them,+ /// so the rail grows from its source and two unjoined parts never end up joined to each other.+ pub sources: HashMap<NetId, HashSet<usize>>,+ pub notes: Vec<String>,+}++/// The share of the drop limit the plan spends. The rest is for what the plan can't see: copper+/// runs longer than the straight distance it is planned on, and vias on the way add their own drop.+const PLAN_SHARE: f64 = 0.9;++fn oct(p: P, q: P) -> f64 {+ let (dx, dy) = ((p.0 - q.0).abs() as f64, (p.1 - q.1).abs() as f64);+ dx.max(dy) + (std::f64::consts::SQRT_2 - 1.0) * dx.min(dy)+}++pub fn plan(board: &Board, rules: &Rules, pour_fed: &HashSet<NetId>) -> PowerPlan {+ let mut out = PowerPlan::default();+ for (name, rail) in &rules.rails {+ let Some(net) = board.net_ids.iter().find(|(k, _)| k.eq_ignore_ascii_case(name)).map(|(_, v)| *v) else { continue };+ if pour_fed.contains(&net) {+ continue;+ }+ // Parts on the rail, in footprint order (the same on every run).+ let mut parts: Vec<(usize, Vec<usize>)> = Vec::new();+ for (i, p) in board.pads.iter().enumerate().filter(|(_, p)| p.net == net && !p.layers.is_empty()) {+ match parts.iter_mut().find(|(fp, _)| *fp == p.fp) {+ Some((_, pads)) => pads.push(i),+ None => parts.push((p.fp, vec![i])),+ }+ }+ let n = parts.len();+ let refname = |k: usize| board.footprints[parts[k].0].reference.as_str();+ let is_source = |k: usize| rail.sources.iter().any(|s| s == refname(k));+ if !(0..n).any(is_source) {+ continue;+ }+ let load = |k: usize| if is_source(k) { 0.0 } else { rail.loads.get(refname(k)).copied().unwrap_or(0.0) };+ let nearest = |a: usize, b: usize| -> (f64, usize, usize) {+ let mut best = (f64::INFINITY, parts[a].1[0], parts[b].1[0]);+ for &x in &parts[a].1 {+ for &y in &parts[b].1 {+ let d = oct(board.pads[x].center, board.pads[y].center);+ if d < best.0 {+ best = (d, x, y);+ }+ }+ }+ best+ };+ let mut joined: Vec<usize> = (0..n).filter(|&k| is_source(k)).collect();+ let mut parent: Vec<Option<usize>> = vec![None; n];+ let mut edge: Vec<(f64, usize)> = vec![(0.0, 0); n]; // (length, the part's pad nearest its parent)+ let mut order: Vec<usize> = Vec::new();+ let mut join = |k: usize, joined: &mut Vec<usize>, parent: &mut Vec<Option<usize>>, edge: &mut Vec<(f64, usize)>, order: &mut Vec<usize>| {+ let (d, mine, par) = joined.iter().map(|&j| { let (d, x, _) = nearest(k, j); (d, x, j) }).min_by(|a, b| a.0.total_cmp(&b.0).then(a.2.cmp(&b.2))).unwrap();+ parent[k] = Some(par);+ edge[k] = (d, mine);+ joined.push(k);+ order.push(k);+ };+ let mut loads: Vec<usize> = (0..n).filter(|&k| !is_source(k) && load(k) > 0.0).collect();+ loads.sort_by(|&a, &b| load(b).total_cmp(&load(a)).then(refname(a).cmp(refname(b))));+ for k in loads.clone() {+ join(k, &mut joined, &mut parent, &mut edge, &mut order);+ }+ let mut idle: Vec<usize> = (0..n).filter(|&k| !is_source(k) && load(k) <= 0.0).collect();+ while !idle.is_empty() {+ let (pos, _) = idle.iter().enumerate().map(|(pos, &k)| (pos, joined.iter().map(|&j| nearest(k, j).0).fold(f64::INFINITY, f64::min))).min_by(|a, b| a.1.total_cmp(&b.1).then(a.0.cmp(&b.0))).unwrap();+ let k = idle.remove(pos);+ join(k, &mut joined, &mut parent, &mut edge, &mut order);+ }+ let mut amps: Vec<f64> = (0..n).map(load).collect();+ for &k in order.iter().rev() {+ if let Some(p) = parent[k] {+ amps[p] += amps[k];+ }+ }+ for a in amps.iter_mut() {+ *a = a.min(rail.amps);+ }+ let class = rules.track_for(net);+ let plain = if class > mm(0.3) { rules.track } else { class };+ let mut width: Vec<i64> = (0..n).map(|k| rules.rail_width(amps[k], false).max(plain)).collect();+ // The drop limit, shared along each load's path.+ if let Some(volts) = rail.volts {+ let t_cu = rules.copper_um * 1e-6;+ let clearance = rules.clearance_for(net, net) as f64;+ for &l in &loads {+ let mut path: Vec<usize> = Vec::new();+ let mut k = l;+ while let Some(p) = parent[k] {+ path.push(k);+ k = p;+ }+ let share: f64 = path.iter().map(|&k| edge[k].0 * 1e-9 * amps[k].sqrt()).sum();+ let budget = volts * DROP_LIMIT * PLAN_SHARE;+ let need = |b: f64, k: usize| amps[k].sqrt() * share * RHO_CU / (t_cu * b);+ // The last stretch into a pad narrower than the branch runs at the pad's width.+ let pad = &board.pads[edge[l].1];+ let pad_w = pad.size.0.min(pad.size.1) * 1e-9;+ let first = need(budget, l);+ // A pad inside its own rail's pour needs no neck: the pour carries the current to+ // the pin, and the branch ends on the pour (reference benchmark C does this at U10).+ let c: V = pad.center.into();+ let in_pour = board.zones.iter().any(|z| z.net == net && !z.keepout_tracks && (in_poly(c, &z.outline) || z.fills.iter().any(|(_, f)| in_poly(c, f))));+ let neck = if in_pour { 0.0 } else if first > pad_w && pad_w > 0.0 { amps[l] * RHO_CU * (first / 2.0 + clearance * 1e-9 + 0.3e-3) / (pad_w * t_cu) } else { 0.0 };+ let left = (budget - neck).max(0.3 * budget);+ for &k in &path {+ let w = mm((need(left, k) * 1000.0 * 100.0).ceil() / 100.0);+ width[k] = width[k].max(w);+ }+ }+ }+ // What each part may land on: its own pads and those of every part above it.+ let mut taps: Vec<usize> = Vec::new();+ let mut line: Vec<String> = Vec::new();+ for &k in &order {+ let mut allowed: HashSet<usize> = parts[k].1.iter().copied().collect();+ let mut up = parent[k];+ while let Some(p) = up {+ allowed.extend(parts[p].1.iter().copied());+ up = parent[p];+ }+ for &pad in &parts[k].1 {+ out.pads.insert(pad, PadPlan { width: width[k], amps: amps[k], allowed: allowed.clone(), anywhere: amps[k] < 0.01 * rail.amps });+ }+ taps.push(edge[k].1);+ taps.extend(parts[k].1.iter().copied().filter(|&p| p != edge[k].1));+ if amps[k] > 0.0 {+ line.push(format!("{} {:.3} A over {:.1} mm from {} at {:.2} mm", refname(k), amps[k], edge[k].0 / NM, refname(parent[k].unwrap()), to_mm(width[k])));+ }+ }+ out.notes.push(format!("power plan {name}: {}; {} parts with no load of their own at {:.2} mm", line.join("; "), order.iter().filter(|&&k| amps[k] <= 0.0).count(), to_mm(plain)));+ out.sources.insert(net, (0..n).filter(|&k| is_source(k)).flat_map(|k| parts[k].1.iter().copied()).collect());+ out.order.push((net, taps));+ }+ out+}
src/project.rsadded+274@@ -0,0 +1,274 @@+//! The board's own KiCad project rules (.kicad_pro and .kicad_dru beside the .kicad_pcb). KiCad's DRC+//! judges the board by these, so the router must meet them too: a rule that lives only in the+//! router's spec is how a "clean" route fails DRC (AI_Mistakes_Log R5).+//!+//! Honoured from .kicad_pro: net_settings classes (clearance, track_width, via_diameter, via_drill,+//! diff_pair_width, diff_pair_gap, priority), netclass_patterns (wildcards), netclass_assignments;+//! design_settings rules min_clearance, min_track_width, min_hole_clearance, min_hole_to_hole,+//! min_copper_edge_clearance, min_via_diameter, min_through_hole_diameter, min_via_annular_width,+//! solder_mask_to_copper_clearance; rule_severities. Not honoured: regular-expression patterns.+//!+//! Honoured from .kicad_dru: only `min` values of track_width, clearance, hole_clearance,+//! hole_to_hole, edge_clearance, via_diameter, hole_size and annular_width in rules with no+//! condition and no layer, and clearance in rules conditioned exactly on `A.NetClass == 'X'`. They+//! only ever raise a limit: an unconditional 0.09 mm clearance would lower KiCad's own answer but+//! does not lower the designer's 0.15 (2.2). Rules lowering one net's clearance at one pad (`PadRule`) are+//! honoured down to that same minimum. Every other rule is listed in a note, so nothing is silently+//! dropped.++use crate::geom::*;+use crate::rules::{NetClass, PadRule, Rules};+use crate::sx;+use serde_json::Value;++/// Applies the project's rules on top of `r`. Where the project is stricter than the standing rule+/// the project wins, and the difference is reported so it can be fixed in the board setup.+pub fn apply(board_path: &str, r: &mut Rules) -> Vec<String> {+ let base = std::path::Path::new(board_path);+ let pro = base.with_extension("kicad_pro");+ let dru = base.with_extension("kicad_dru");+ apply_files(&pro, &dru, r)+}++/// What KiCad applies to a board with no project file beside it.+const DEFAULT_PROJECT: &str = r#"{"board":{"design_settings":{"rules":{"min_clearance":0.0,"min_track_width":0.2,"min_copper_edge_clearance":0.5,"min_hole_clearance":0.25,"min_hole_to_hole":0.25}}},+"net_settings":{"classes":[{"name":"Default","clearance":0.2,"track_width":0.2,"via_diameter":0.6,"via_drill":0.3,"diff_pair_width":0.2,"diff_pair_gap":0.25}]}}"#;++pub fn apply_files(pro: &std::path::Path, dru: &std::path::Path, r: &mut Rules) -> Vec<String> {+ let mut notes = Vec::new();+ let standing = r.clearance;+ r.floor = standing;+ let text = match std::fs::read_to_string(pro) {+ Ok(t) => t,+ Err(_) => {+ // KiCad judges a board that has no project by its own defaults, so the router works to+ // the same ones. Read off KiCad 10's DRC on wiki boards without a project (2026-11-10):+ // "netclass clearance 0.2", "board setup constraints min width 0.2", "edge clearance+ // 0.5", "hole clearance 0.25". Routing those boards at the standing 0.15 mm neck and+ // 0.3 mm edge drew track_width and copper_edge_clearance errors on seven of them.+ notes.push(format!("no project file at {}: using KiCad's defaults for a board without one (0.2 mm clearance and minimum track, 0.5 mm to the board edge, 0.25 mm to holes)", pro.display()));+ DEFAULT_PROJECT.to_string()+ }+ };+ let Ok(v) = serde_json::from_str::<Value>(&text) else {+ notes.push(format!("{} did not parse: using the standing rules", pro.display()));+ return notes;+ };+ read_classes(&v, r, &mut notes);+ // A setting the user named replaces the Default class's value; other classes keep theirs, since+ // those are the designer's deliberate exceptions (a 0.5 mm high-voltage class stays 0.5 mm).+ let ex = r.explicit;+ for c in r.classes.iter_mut().filter(|c| c.name == "Default") {+ if ex.clearance.is_some() { c.clearance = ex.clearance; }+ if ex.track.is_some() { c.track = ex.track; }+ if ex.via.is_some() { c.via_dia = ex.via; }+ if ex.drill.is_some() { c.via_drill = ex.drill; }+ }+ let strictest = r.classes.iter().filter_map(|c| c.clearance).max().unwrap_or(0);+ if strictest > standing {+ // The router asks clearance_for per pair of nets; this number is the loosest class, used as+ // an optimistic floor by the coarse map, so a stricter class can never wrongly close a lane.+ let loosest = r.classes.iter().filter_map(|c| c.clearance).min().unwrap_or(strictest);+ r.clearance = loosest.max(standing);+ notes.push(format!("project net classes require {:.3} to {:.3} mm clearance by net pair, with a {:.3} mm standing minimum; each pair is judged by its classes, and `clearance` holds the loosest, {:.3} mm, for the coarse map", to_mm(loosest), to_mm(strictest), to_mm(standing), to_mm(r.clearance)));+ } else if strictest > 0 && strictest < standing {+ notes.push(format!("project net classes allow {:.3} mm clearance, below the {:.3} mm standing minimum; routing at the standard", to_mm(strictest), to_mm(standing)));+ }+ if let Some(rules) = v.pointer("/board/design_settings/rules") {+ let val = |k: &str| rules.get(k).and_then(|x| x.as_f64()).map(mm);+ if let Some(x) = val("min_copper_edge_clearance") {+ r.edge = r.edge.max(x);+ }+ if let Some(x) = val("min_hole_to_hole") {+ r.hole_hole = r.hole_hole.max(x);+ r.min.hole_to_hole = x;+ }+ r.min.clearance = val("min_clearance").unwrap_or(0);+ r.min.track = val("min_track_width").unwrap_or(0);+ if let Some(n) = r.explicit.neck {+ r.min.track = r.min.track.min(n);+ }+ if let Some(c) = r.explicit.clearance {+ r.min.clearance = r.min.clearance.min(c);+ }+ r.min.hole_clearance = val("min_hole_clearance").unwrap_or(0);+ r.min.via_dia = val("min_via_diameter").unwrap_or(0);+ r.min.through_drill = val("min_through_hole_diameter").unwrap_or(0);+ r.min.annular = val("min_via_annular_width").unwrap_or(0);+ r.min.mask_to_copper = val("solder_mask_to_copper_clearance").unwrap_or(0);+ }+ if let Some(o) = v.pointer("/board/design_settings/rule_severities").and_then(|x| x.as_object()) {+ for (k, s) in o {+ if let Some(s) = s.as_str() {+ r.severities.insert(k.clone(), s.to_string());+ }+ }+ }+ apply_dru(dru, r, &mut notes);+ notes+}++fn read_classes(v: &Value, r: &mut Rules, notes: &mut Vec<String>) {+ let Some(classes) = v.pointer("/net_settings/classes").and_then(|c| c.as_array()) else { return };+ for c in classes {+ let val = |k: &str| c.get(k).and_then(|x| x.as_f64()).map(mm);+ let name = c.get("name").and_then(|n| n.as_str()).unwrap_or("").to_string();+ if name == "Default" {+ if let (Some(d), Some(h)) = (val("via_diameter"), val("via_drill")) {+ if d != r.via || h != r.drill {+ notes.push(format!("project via is {}/{} (drill/diameter); the standard is {}/{}", to_mm(h), to_mm(d), to_mm(r.drill), to_mm(r.via)));+ }+ }+ }+ r.classes.push(NetClass {+ priority: c.get("priority").and_then(|x| x.as_i64()).unwrap_or(i64::MAX),+ clearance: val("clearance"),+ track: val("track_width"),+ via_dia: val("via_diameter"),+ via_drill: val("via_drill"),+ pair_width: val("diff_pair_width"),+ pair_gap: val("diff_pair_gap"),+ name,+ });+ }+ if let Some(ps) = v.pointer("/net_settings/netclass_patterns").and_then(|c| c.as_array()) {+ for p in ps {+ let (Some(pat), Some(class)) = (p.get("pattern").and_then(|x| x.as_str()), p.get("netclass").and_then(|x| x.as_str())) else { continue };+ if pat.starts_with('/') && pat.ends_with('/') && pat.len() > 1 {+ notes.push(format!("netclass pattern {pat} is a regular expression; not honoured, so {class} may be missing nets"));+ continue;+ }+ r.patterns.push((pat.to_string(), class.to_string()));+ }+ }+ if let Some(o) = v.pointer("/net_settings/netclass_assignments").and_then(|c| c.as_object()) {+ for (net, cls) in o {+ let list: Vec<String> = match cls {+ Value::String(s) => vec![s.clone()],+ Value::Array(a) => a.iter().filter_map(|x| x.as_str().map(String::from)).collect(),+ _ => Vec::new(),+ };+ r.assignments.insert(net.clone(), list);+ }+ }+}++/// "0.127mm", "5mil" or a bare mm number.+fn dru_value(s: &str) -> Option<i64> {+ if let Some(x) = s.strip_suffix("mm") {+ return x.parse::<f64>().ok().map(mm);+ }+ if let Some(x) = s.strip_suffix("mil") {+ return x.parse::<f64>().ok().map(|m| mm(m * 0.0254));+ }+ s.parse::<f64>().ok().map(mm)+}++fn apply_dru(path: &std::path::Path, r: &mut Rules, notes: &mut Vec<String>) {+ let Ok(text) = std::fs::read_to_string(path) else { return };+ // Rules sit side by side with # comments, so give the reader a single list without them.+ let body: String = text.lines().filter(|l| !l.trim_start().starts_with('#')).collect::<Vec<_>>().join("\n");+ let Ok(root) = sx::parse(&format!("(dru {body})")) else {+ notes.push(format!("{} did not parse: its rules are not honoured", path.display()));+ return;+ };+ for rule in root.children("rule") {+ let name = rule.atom(1).unwrap_or("").to_string();+ let cond = rule.child("condition").and_then(|c| c.atom(1));+ let layered = rule.child("layer").is_some();+ for c in rule.children("constraint") {+ let kind = c.atom(1).unwrap_or("");+ let Some(min) = c.child("min").and_then(|m| m.atom(1)).and_then(dru_value) else {+ notes.push(format!(".kicad_dru rule \"{name}\": constraint {kind} has no min value; not honoured"));+ continue;+ };+ // A rule scoped to one net at one pad is a recorded exception: KiCad's DRC honours it,+ // so the router and checker must too. The standing minimum still holds (Rules::bind).+ if let (Some(rule), "clearance") = (cond.and_then(|x| PadRule::parse(x, min)), kind) {+ r.pad_rules.push(rule);+ continue;+ }+ let class = cond.and_then(|x| x.strip_prefix("A.NetClass == '")).and_then(|x| x.strip_suffix('\''));+ match (cond, layered, kind) {+ (Some(_), false, "clearance") if class.is_some() => {+ if let Some(k) = r.classes.iter_mut().find(|k| Some(k.name.as_str()) == class) {+ k.clearance = Some(k.clearance.unwrap_or(0).max(min));+ }+ }+ (None, false, "clearance") => r.min.clearance = r.min.clearance.max(min),+ (None, false, "track_width") => r.min.track = r.min.track.max(min),+ (None, false, "hole_clearance") => r.min.hole_clearance = r.min.hole_clearance.max(min),+ (None, false, "hole_to_hole") => r.min.hole_to_hole = r.min.hole_to_hole.max(min),+ (None, false, "edge_clearance") => r.edge = r.edge.max(min),+ (None, false, "via_diameter") => r.min.via_dia = r.min.via_dia.max(min),+ (None, false, "hole_size") => r.min.through_drill = r.min.through_drill.max(min),+ (None, false, "annular_width") => r.min.annular = r.min.annular.max(min),+ _ => notes.push(format!(".kicad_dru rule \"{name}\": {kind} with its condition or layer is not honoured")),+ }+ }+ }+}+++/// Writes the output board's project: a copy of the input's, with any setting the user named written+/// into the Default class and the board minimums, so KiCad's DRC judges the route by the same numbers.+/// The input's project is never changed (4.6). Returns what it changed.+pub fn write_for_output(board_in: &str, board_out: &str, ex: &crate::rules::Explicit) -> Result<Vec<String>, String> {+ let src = std::path::Path::new(board_in).with_extension("kicad_pro");+ let dst = std::path::Path::new(board_out).with_extension("kicad_pro");+ let Ok(text) = std::fs::read_to_string(&src) else {+ return Ok(if ex.any() { vec![format!("no project at {} to carry your settings: set them in the board setup", src.display())] } else { Vec::new() });+ };+ if !ex.any() {+ std::fs::write(&dst, text).map_err(|e| format!("{}: {e}", dst.display()))?;+ return Ok(Vec::new());+ }+ let mut v: Value = serde_json::from_str(&text).map_err(|e| format!("{}: {e}", src.display()))?;+ let mut changed = Vec::new();+ let num = |x: i64| Value::from(to_mm(x));+ let mut set = |node: Option<&mut Value>, key: &str, x: Option<i64>, only_down: bool, what: &str| {+ let (Some(n), Some(x)) = (node, x) else { return };+ let old = n.get(key).and_then(|o| o.as_f64());+ if let Some(o) = old {+ if (mm(o) - x).abs() < 1 || (only_down && mm(o) <= x) {+ return;+ }+ }+ changed.push(format!("{what}: {} to {:.3} mm", old.map(|o| format!("{o:.3}")).unwrap_or_else(|| "unset".into()), to_mm(x)));+ n[key] = num(x);+ };+ let classes = v.pointer_mut("/net_settings/classes").and_then(|c| c.as_array_mut());+ if let Some(c) = classes.and_then(|c| c.iter_mut().find(|c| c.get("name").and_then(|n| n.as_str()) == Some("Default"))) {+ set(Some(&mut *c), "clearance", ex.clearance, false, "Default class clearance");+ set(Some(&mut *c), "track_width", ex.track, false, "Default class track");+ set(Some(&mut *c), "via_diameter", ex.via, false, "Default class via diameter");+ set(Some(&mut *c), "via_drill", ex.drill, false, "Default class via drill");+ }+ if let Some(r) = v.pointer_mut("/board/design_settings/rules") {+ set(Some(&mut *r), "min_track_width", ex.neck, true, "board minimum track");+ set(Some(&mut *r), "min_clearance", ex.clearance, true, "board minimum clearance");+ }+ let out = serde_json::to_string_pretty(&v).map_err(|e| e.to_string())?;+ std::fs::write(&dst, out).map_err(|e| format!("{}: {e}", dst.display()))?;+ Ok(changed)+}++/// Writes the output board's .kicad_dru: the input's rules unchanged, then any pad rules this route+/// proposes, marked for review. The input's file is never changed (4.6). Returns the path if written.+pub fn write_dru(board_in: &str, board_out: &str, rules: &[PadRule]) -> Result<Option<String>, String> {+ let src = std::path::Path::new(board_in).with_extension("kicad_dru");+ let dst = std::path::Path::new(board_out).with_extension("kicad_dru");+ let had = std::fs::read_to_string(&src).ok();+ if had.is_none() && rules.is_empty() {+ return Ok(None);+ }+ let mut text = had.unwrap_or_else(|| "(version 1)\n".to_string());+ if !rules.is_empty() {+ text.push_str("\n# adom-route proposes the rules below FOR REVIEW: each pad's own footprint leaves no track room\n# under its net class. Delete a rule to reject it; DRC then reports that clearance again.\n");+ }+ for r in rules {+ text.push_str(&format!("(rule \"adom-route pad exit {} {} {}\"\n (condition \"{}\")\n (constraint clearance (min {:.3}mm)))\n", r.net, r.reference, r.pad, r.condition(), to_mm(r.min)));+ }+ std::fs::write(&dst, text).map_err(|e| format!("{}: {e}", dst.display()))?;+ Ok(Some(dst.display().to_string()))+}
src/quality.rsadded+716@@ -0,0 +1,716 @@+//! What the designer rejects on sight and DRC never sees: copper that doubles back on itself, runs beside+//! itself, changes width every few millimetres, vias a short net needs no vias for, routes far longer+//! than their pads are apart, fills nothing feeds, rails thinner than 4.2's floor. Each one is graded+//! on the reference boards (bench/grade.json); where a threshold is a number he never wrote, the comment says+//! what it was read off.++use crate::board::*;+use crate::check::{Finding, NetStat, RailStat, Score};+use crate::classify::*;+use crate::geom::*;+use crate::rules::Rules;+use std::collections::{BTreeMap, HashMap, HashSet};++/// Shorter than this a segment is a width step or a stray join, not a direction change.+const JOG: f64 = 20_000.0;+/// Centre to centre, and overlap along the run, that make two same-net tracks one doubled run.+const DOUBLED_D: f64 = 0.4 * NM;+const DOUBLED_RUN: f64 = 0.3 * NM;+/// A retrace shorter than this past the pad or via it starts in is a join, not a fold. His boards'+/// longest such join is under his pads; our DIO retrace is 1.23 mm.+const FOLD_RUN: f64 = 0.1 * NM;++fn key(p: P) -> P {+ ((p.0 + 1000).div_euclid(2000), (p.1 + 1000).div_euclid(2000))+}++fn at_mm(p: P) -> (f64, f64) {+ (to_mm(p.0), to_mm(p.1))+}++fn len_of(t: &Track) -> f64 {+ hypot(sub(t.a.into(), t.b.into()))+}++/// Endpoints per net and layer, with micro-jogs merged so a 4 micron step is one node.+pub struct Nodes {+ link: HashMap<(NetId, usize, P), P>,+ pub at: HashMap<(NetId, usize, P), Vec<usize>>,+}++impl Nodes {+ pub fn new(board: &Board) -> Nodes {+ let mut n = Nodes { link: HashMap::new(), at: HashMap::new() };+ for t in &board.tracks {+ if len_of(t) <= JOG {+ let (x, y) = (n.rep(t.net, t.layer, t.a), n.rep(t.net, t.layer, t.b));+ if x != y {+ n.link.insert((t.net, t.layer, x), y);+ }+ }+ }+ for (i, t) in board.tracks.iter().enumerate() {+ if len_of(t) > JOG {+ let (x, y) = (n.rep(t.net, t.layer, t.a), n.rep(t.net, t.layer, t.b));+ n.at.entry((t.net, t.layer, x)).or_default().push(i);+ n.at.entry((t.net, t.layer, y)).or_default().push(i);+ }+ }+ n+ }+ pub fn rep(&self, net: NetId, layer: usize, p: P) -> P {+ let mut k = key(p);+ while let Some(&n) = self.link.get(&(net, layer, k)) {+ k = n;+ }+ k+ }+ /// A track's ends as seen from node `n`: (the end at n, the far end).+ fn from(&self, t: &Track, n: P) -> (P, P) {+ if self.rep(t.net, t.layer, t.a) == n { (t.a, t.b) } else { (t.b, t.a) }+ }+}++fn angle(from: P, to: P) -> f64 {+ ((to.1 - from.1) as f64).atan2((to.0 - from.0) as f64).to_degrees()+}++fn diff(a: f64, b: f64) -> f64 {+ let d = (a - b).rem_euclid(360.0);+ d.min(360.0 - d)+}++fn own_pad_at(board: &Board, net: NetId, layer: usize, v: V) -> Option<usize> {+ board.pads.iter().position(|q| q.net == net && q.layers.contains(&layer) && q.shape.dist_pt(v) <= 1.0)+}++/// How much of a to b lies outside the net's own pads and via lands: copper inside them can't be seen,+/// so a 0.3 mm overlap wholly inside U1.33 on his v1.3 is nothing, and a retrace out of a pad is.+fn visible(board: &Board, net: NetId, layer: usize, a: V, b: V) -> f64 {+ let l = hypot(sub(b, a));+ if l <= 0.0 {+ return 0.0;+ }+ let steps = ((l / 20_000.0).ceil() as usize).max(1);+ let mut out = 0.0;+ for i in 0..steps {+ let u = (i as f64 + 0.5) / steps as f64;+ let p = V(a.0 + (b.0 - a.0) * u, a.1 + (b.1 - a.1) * u);+ let hid = board.pads.iter().any(|q| q.net == net && q.layers.contains(&layer) && q.shape.dist_pt(p) <= 0.0)+ || board.vias.iter().any(|v| v.net == net && hypot(sub(v.at.into(), p)) <= v.size as f64 / 2.0);+ if !hid {+ out += l / steps as f64;+ }+ }+ out+}++/// Fold-back (a track retracing its neighbour from a shared node) and doubled runs (two same-net+/// tracks side by side). 4.2: "route as straight as possible, with the fewest segments". A pad or a+/// via hides a corner, not a retrace: DIO at R15.2 on the routed PiProbe goes to the pad's centre and+/// comes back along itself.+pub fn shape_rules(board: &Board, nodes: &Nodes, f: &mut Vec<Finding>) {+ for ((net, layer, n), list) in &nodes.at {+ for (x, &i) in list.iter().enumerate() {+ for &j in &list[x + 1..] {+ if i == j {+ continue;+ }+ let (ti, tj) = (&board.tracks[i], &board.tracks[j]);+ let (a, b) = nodes.from(ti, *n);+ let (c, d) = nodes.from(tj, *n);+ if diff(angle(a, b), angle(c, d)) <= 3.0 {+ let short = if len_of(ti) <= len_of(tj) { b } else { d };+ let run = visible(board, *net, *layer, a.into(), short.into());+ if run < FOLD_RUN {+ continue;+ }+ let v: V = a.into();+ let place = match own_pad_at(board, *net, *layer, v) {+ Some(p) => format!(" at its pad {}", board.pad_name(p)),+ None => String::new(),+ };+ f.push(Finding { rule: "fold-back", severity: "error", at: at_mm(a), text: format!("{}: retraces itself for {:.2} mm on {}{place} (4.2)", board.net_name(*net), run / NM, board.layers[*layer].name) });+ }+ }+ }+ }+ let mut by: BTreeMap<(NetId, usize), Vec<usize>> = BTreeMap::new();+ for (i, t) in board.tracks.iter().enumerate() {+ if len_of(t) > JOG && t.net != NO_NET {+ by.entry((t.net, t.layer)).or_default().push(i);+ }+ }+ for ((net, layer), list) in by {+ for (x, &i) in list.iter().enumerate() {+ for &j in &list[x + 1..] {+ let (s, o) = (&board.tracks[i], &board.tracks[j]);+ let (a, b): (V, V) = (s.a.into(), s.b.into());+ let (c, e): (V, V) = (o.a.into(), o.b.into());+ let (ls, lo) = (hypot(sub(b, a)), hypot(sub(e, c)));+ let u = V((b.0 - a.0) / ls, (b.1 - a.1) / ls);+ let w = sub(e, c);+ if (u.0 * w.1 - u.1 * w.0).abs() / lo > 0.02 {+ continue;+ }+ let d = ((c.0 - a.0) * -u.1 + (c.1 - a.1) * u.0).abs();+ if d > DOUBLED_D {+ continue;+ }+ let (t0, t1) = (dot(sub(c, a), u), dot(sub(e, a), u));+ let (from, to) = (t0.min(t1).max(0.0), ls.min(t0.max(t1)));+ if to - from < DOUBLED_RUN {+ continue;+ }+ let on_a = |u: f64| V(a.0 + (b.0 - a.0) * u / ls, a.1 + (b.1 - a.1) * u / ls);+ let run = visible(board, net, layer, on_a(from), on_a(to));+ if run < DOUBLED_RUN {+ continue;+ }+ // Two tracks sharing a node and leaving it the same way are the fold-back above.+ let share = [s.a, s.b].iter().any(|p| {+ let k = nodes.rep(net, layer, *p);+ k == nodes.rep(net, layer, o.a) || k == nodes.rep(net, layer, o.b)+ });+ if share && d < 2000.0 {+ continue;+ }+ f.push(Finding { rule: "same-net-parallel", severity: "error", at: at_mm(s.a), text: format!("{}: runs beside itself on {} for {:.2} mm, {:.3} mm centre to centre (4.2)", board.net_name(net), board.layers[layer].name, run / NM, d / NM) });+ }+ }+ }+}++/// Width steps on a net: a node of exactly two tracks of different widths, outside the net's pads,+/// where the step does not show. The reviewer's total on the reference boards: 5, 53, 45.+fn width_steps(board: &Board, nodes: &Nodes) -> HashMap<NetId, Vec<P>> {+ let mut out: HashMap<NetId, Vec<P>> = HashMap::new();+ for ((net, layer, n), list) in &nodes.at {+ if list.len() != 2 || board.tracks[list[0]].width == board.tracks[list[1]].width {+ continue;+ }+ let (a, _) = nodes.from(&board.tracks[list[0]], *n);+ if own_pad_at(board, *net, *layer, a.into()).is_some() {+ continue;+ }+ out.entry(*net).or_default().push(a);+ }+ out+}++/// Straight-line length of the shortest tree over a net's pads (Prim).+fn tree_length(board: &Board, pads: &[usize]) -> f64 {+ if pads.len() < 2 {+ return 0.0;+ }+ let c: Vec<V> = pads.iter().map(|&p| board.pads[p].shape.center()).collect();+ let mut best: Vec<f64> = c.iter().map(|q| hypot(sub(*q, c[0]))).collect();+ let mut used = vec![false; c.len()];+ used[0] = true;+ let mut total = 0.0;+ for _ in 1..c.len() {+ let (i, d) = best.iter().enumerate().filter(|(i, _)| !used[*i]).map(|(i, d)| (i, *d)).min_by(|x, y| x.1.total_cmp(&y.1)).unwrap();+ used[i] = true;+ total += d;+ for k in 0..c.len() {+ if !used[k] {+ best[k] = best[k].min(hypot(sub(c[k], c[i])));+ }+ }+ }+ total+}++/// A net's copper as a graph: pads, via lands and fills are single nodes, tracks are edges, split+/// wherever another track or a via lands on them. Everything the checker asks about topology (is+/// the cap first, where is the main run) is asked of this, so a via or a mid-track tap can't hide it.+pub struct Graph {+ pub adj: Vec<Vec<(usize, f64, i64, P)>>,+ pub pads_of: Vec<Vec<usize>>,+ pub fill: Vec<bool>,+ pub pad_node: HashMap<usize, usize>,+}++impl Graph {+ pub fn build(board: &Board, net: NetId) -> Graph {+ let tracks: Vec<&Track> = board.tracks.iter().filter(|t| t.net == net).collect();+ let vias: Vec<&Via> = board.vias.iter().filter(|v| v.net == net).collect();+ let mut ids: HashMap<(usize, P), usize> = HashMap::new();+ let mut pos: Vec<(usize, V)> = Vec::new();+ let mut node = |layer: usize, p: V, pos: &mut Vec<(usize, V)>| -> usize {+ *ids.entry((layer, key(to_p(p)))).or_insert_with(|| {+ pos.push((layer, p));+ pos.len() - 1+ })+ };+ let mut edges: Vec<(usize, usize, f64, i64, P)> = Vec::new();+ for t in &tracks {+ let (a, b): (V, V) = (t.a.into(), t.b.into());+ let l = hypot(sub(b, a)).max(1.0);+ let mut cuts: Vec<(f64, V)> = vec![(0.0, a), (l, b)];+ let mut try_cut = |p: V| {+ if pt_seg(p, a, b) <= 2000.0 {+ let u = dot(sub(p, a), V((b.0 - a.0) / l, (b.1 - a.1) / l));+ if u > 2000.0 && u < l - 2000.0 {+ cuts.push((u, p));+ }+ }+ };+ for o in &tracks {+ if o.layer == t.layer {+ try_cut(o.a.into());+ try_cut(o.b.into());+ }+ }+ for v in &vias {+ try_cut(v.at.into());+ }+ cuts.sort_by(|x, y| x.0.total_cmp(&y.0));+ for w in cuts.windows(2) {+ let (p, q) = (node(t.layer, w[0].1, &mut pos), node(t.layer, w[1].1, &mut pos));+ edges.push((p, q, w[1].0 - w[0].0, t.width, t.a));+ }+ }+ let n0 = pos.len();+ let pads: Vec<usize> = (0..board.pads.len()).filter(|&i| board.pads[i].net == net && !board.pads[i].layers.is_empty()).collect();+ let fills: Vec<(usize, Vec<V>)> = board.zones.iter().filter(|z| z.net == net && !z.keepout_tracks).flat_map(|z| {+ if z.fills.is_empty() { z.layers.iter().map(|l| (*l, z.outline.clone())).collect::<Vec<_>>() } else { z.fills.clone() }+ }).collect();+ // Node ids after the track nodes: pads, then vias, then fills.+ let (first_via, first_fill) = (n0 + pads.len(), n0 + pads.len() + vias.len());+ let total = first_fill + fills.len();+ let mut uf = crate::conn::Uf::new(total);+ for (k, &i) in pads.iter().enumerate() {+ let p = &board.pads[i];+ for (m, (l, v)) in pos.iter().enumerate() {+ if p.layers.contains(l) && p.shape.dist_pt(*v) <= 1.0 {+ uf.union(m as u32, (n0 + k) as u32);+ }+ }+ for (vi, v) in vias.iter().enumerate() {+ if p.shape.dist_pt(v.at.into()) <= v.size as f64 / 2.0 {+ uf.union((first_via + vi) as u32, (n0 + k) as u32);+ }+ }+ for (fi, (l, poly)) in fills.iter().enumerate() {+ if p.layers.contains(l) && crate::conn::shape_meets_poly(&p.shape, poly) {+ uf.union((first_fill + fi) as u32, (n0 + k) as u32);+ }+ }+ for (k2, &j) in pads.iter().enumerate().skip(k + 1) {+ let q = &board.pads[j];+ if p.layers.iter().any(|l| q.layers.contains(l)) && p.shape.dist_shape(&q.shape) <= 1.0 {+ uf.union((n0 + k) as u32, (n0 + k2) as u32);+ }+ }+ }+ for (vi, v) in vias.iter().enumerate() {+ let c: V = v.at.into();+ for (m, (_, q)) in pos.iter().enumerate() {+ if hypot(sub(*q, c)) <= v.size as f64 / 2.0 {+ uf.union(m as u32, (first_via + vi) as u32);+ }+ }+ for (fi, (_, poly)) in fills.iter().enumerate() {+ if in_poly(c, poly) {+ uf.union((first_fill + fi) as u32, (first_via + vi) as u32);+ }+ }+ }+ for (fi, (l, poly)) in fills.iter().enumerate() {+ for (m, (ml, q)) in pos.iter().enumerate() {+ if ml == l && in_poly(*q, poly) {+ uf.union(m as u32, (first_fill + fi) as u32);+ }+ }+ }+ let mut g = Graph { adj: vec![Vec::new(); total], pads_of: vec![Vec::new(); total], fill: vec![false; total], pad_node: HashMap::new() };+ for (k, &i) in pads.iter().enumerate() {+ let r = uf.find((n0 + k) as u32) as usize;+ g.pads_of[r].push(i);+ g.pad_node.insert(i, r);+ }+ for fi in 0..fills.len() {+ let r = uf.find((first_fill + fi) as u32) as usize;+ g.fill[r] = true;+ }+ for (p, q, l, w, at) in edges {+ let (a, b) = (uf.find(p as u32) as usize, uf.find(q as u32) as usize);+ if a != b {+ g.adj[a].push((b, l, w, at));+ g.adj[b].push((a, l, w, at));+ }+ }+ g+ }++ /// The longest of the shortest paths between two pads, as its edges (length, width). The main+ /// run: it carries the net end to end, whatever is drawn widest somewhere else.+ pub fn main_run(&self) -> Vec<(f64, i64)> {+ let dijkstra = |s: usize| -> (Vec<f64>, Vec<Option<(usize, f64, i64)>>) {+ let mut d = vec![f64::INFINITY; self.adj.len()];+ let mut from = vec![None; self.adj.len()];+ let mut done = vec![false; self.adj.len()];+ d[s] = 0.0;+ loop {+ let Some(u) = (0..d.len()).filter(|&i| !done[i] && d[i].is_finite()).min_by(|a, b| d[*a].total_cmp(&d[*b])) else { break };+ done[u] = true;+ for &(v, l, w, _) in &self.adj[u] {+ if d[u] + l < d[v] {+ d[v] = d[u] + l;+ from[v] = Some((u, l, w));+ }+ }+ }+ (d, from)+ };+ let ends: Vec<usize> = (0..self.adj.len()).filter(|&i| !self.pads_of[i].is_empty() && !self.adj[i].is_empty()).collect();+ let mut best: Vec<(f64, i64)> = Vec::new();+ let mut best_len = 0.0;+ for &s in &ends {+ let (d, from) = dijkstra(s);+ for &e in &ends {+ if d[e].is_finite() && d[e] > best_len {+ best_len = d[e];+ best.clear();+ let mut c = e;+ while let Some((p, l, w)) = from[c] {+ best.push((l, w));+ c = p;+ }+ }+ }+ }+ best+ }+}++/// Where a walk from a supply pin first stops (4.2: "each supply pin routes to its cap's pad first").+#[derive(Debug, PartialEq, Clone, Copy)]+enum Stop {+ Cap,+ Fill,+ Pad(usize),+ Junction(P),+ End,+}++fn walk(g: &Graph, board: &Board, start: usize, first: usize, at: P, is_cap: &dyn Fn(usize) -> bool) -> Stop {+ let (mut prev, mut cur, mut last) = (start, first, at);+ for _ in 0..200 {+ // A fill merges everything it touches into one node, so it is judged first: reaching it is+ // reaching the rail, whatever else it also touches.+ if g.fill[cur] {+ return Stop::Fill;+ }+ if g.pads_of[cur].iter().any(|&p| is_cap(p)) {+ return Stop::Cap;+ }+ if let Some(&p) = g.pads_of[cur].first() {+ return Stop::Pad(p);+ }+ // Distinct neighbours: a track and a stub laid over it are one way on.+ let mut on: Vec<(usize, P)> = Vec::new();+ for &(v, _, _, a) in &g.adj[cur] {+ if v != prev && !on.iter().any(|o| o.0 == v) {+ on.push((v, a));+ }+ }+ match on.len() {+ 0 => return Stop::End,+ 1 => {+ prev = cur;+ last = on[0].1;+ cur = on[0].0;+ }+ _ => return Stop::Junction(last),+ }+ }+ let _ = board;+ Stop::End+}++/// Cap first, walked on the real copper, through vias and mid-track taps.+fn cap_first(board: &Board, net: NetId, g: &Graph, f: &mut Vec<Finding>, s: &mut Score) {+ let is_cap = |p: usize| ref_prefix(&board.footprints[board.pads[p].fp].reference) == "C";+ let caps = g.pads_of.iter().flatten().filter(|&&p| is_cap(p)).count();+ if caps == 0 {+ return;+ }+ let mut pins: Vec<usize> = g.pads_of.iter().flatten().copied().filter(|&p| crate::placement::is_supply_pin(board, p)).collect();+ pins.sort();+ for pin in pins {+ let Some(&start) = g.pad_node.get(&pin) else { continue };+ s.cap_first.pins += 1;+ if g.fill[start] {+ s.cap_first.on_fill += 1;+ continue;+ }+ if g.pads_of[start].iter().any(|&p| is_cap(p)) {+ s.cap_first.direct += 1;+ continue;+ }+ // Pins tied side by side count as one pin (4.1): carry on from the neighbour.+ let mut stops: Vec<Stop> = Vec::new();+ let mut seen = vec![start];+ let mut work = vec![start];+ while let Some(n) = work.pop() {+ for &(v, _, _, a) in &g.adj[n] {+ let st = walk(g, board, n, v, a, &is_cap);+ if let Stop::Pad(p) = st {+ if board.pads[p].fp == board.pads[pin].fp {+ if let Some(&m) = g.pad_node.get(&p) {+ if !seen.contains(&m) {+ seen.push(m);+ work.push(m);+ continue;+ }+ }+ continue;+ }+ }+ stops.push(st);+ }+ }+ if stops.iter().any(|x| *x == Stop::Cap) {+ s.cap_first.cap += 1;+ } else if stops.iter().any(|x| *x == Stop::Fill) {+ s.cap_first.on_fill += 1;+ } else {+ // A pin with no copper yet is unrouted, which is `unconnected`'s to report, not this.+ let (at, what) = match stops.iter().find(|x| matches!(x, Stop::Junction(_))) {+ Some(Stop::Junction(p)) => (*p, "a junction".to_string()),+ _ => match stops.iter().find_map(|x| if let Stop::Pad(p) = x { Some(*p) } else { None }) {+ Some(p) => (board.pads[pin].center, format!("pad {}", board.pad_name(p))),+ None => continue,+ },+ };+ s.cap_first.junction += 1;+ f.push(Finding { rule: "cap-first", severity: "warn", at: at_mm(at), text: format!("{} ({}): reaches {what} before a cap; route pin to cap first (4.2)", board.pad_name(pin), board.net_name(net)) });+ }+ }+}++/// Boards under 3 x 3 in carry 4.2's floor on the main run; larger ones record the margin.+fn is_small(board: &Board) -> bool {+ let (w, h) = (board.bbox.2 - board.bbox.0, board.bbox.3 - board.bbox.1);+ w < 76.2 * NM && h < 76.2 * NM && w > 0.0+}++/// Rails, from the copper graph (4.2, and his decision of 2026-10-27: "Main runs only. Perform+/// analysis on branches"). The main run is the path that carries the net end to end; its width is+/// what most of that length is drawn at, so short necks at pins don't count against it and a short+/// wide patch doesn't excuse it. Everything else is a branch, recorded with its width and length.+fn rail_rules(board: &Board, rules: &Rules, net: NetId, g: &Graph, f: &mut Vec<Finding>, s: &mut Score) {+ let name = board.net_name(net);+ let main = g.main_run();+ if main.is_empty() {+ return;+ }+ let len: f64 = main.iter().map(|e| e.0).sum();+ let mut by: Vec<(i64, f64)> = Vec::new();+ for (l, w) in &main {+ match by.iter_mut().find(|x| x.0 == *w) {+ Some(x) => x.1 += l,+ None => by.push((*w, *l)),+ }+ }+ by.sort_by_key(|x| x.0);+ let mut acc = 0.0;+ let mut width = by[0].0;+ for (w, l) in &by {+ acc += l;+ if acc >= len / 2.0 {+ width = *w;+ break;+ }+ }+ let mut widths: BTreeMap<i64, f64> = BTreeMap::new();+ for t in board.tracks.iter().filter(|t| t.net == net && t.width != width) {+ *widths.entry(t.width).or_insert(0.0) += len_of(t);+ }+ s.rails.push(RailStat { name: name.to_string(), main_width_mm: to_mm(width), main_len_mm: len / NM, fed_by_fill: g.fill.iter().any(|x| *x), branches: widths.iter().map(|(w, l)| (to_mm(*w), (l / NM * 100.0).round() / 100.0)).collect() });+ let first = board.tracks.iter().find(|t| t.net == net && t.width == width).map(|t| t.a).unwrap_or((0, 0));+ if is_small(board) && width < rules.power_floor - 1000 && len >= RAIL_MIN_RUN {+ f.push(Finding { rule: "rail-floor", severity: "error", at: at_mm(first), text: format!("{name}: main run {:.2} mm long is {:.3} mm wide; 4.2's floor on a board under 3 x 3 in is {:.3} mm", len / NM, to_mm(width), to_mm(rules.power_floor)) });+ }+}++/// Under this the "main run" is a pin to its cap, which 4.2 calls a branch.+const RAIL_MIN_RUN: f64 = 3.0 * NM;+/// Fewest millimetres of straight line per via. His boards' lowest: 2.98 (benchmark C /MCU2_TO_1, a+/// non-priority net), so this reads off his practice; it warns, it does not gate. /RX_A: 0.93.+const VIA_SPACING: f64 = 2.9;+/// Copper over straight line on a net, read off the reference boards: worst 1.87 (v1.3 GPIO2). Warns only.+const EFFICIENCY: f64 = 1.9;+/// More width steps than this on one net. His boards' most on any net: 3 (benchmark C).+const NET_STEPS: usize = 3;++pub fn net_rules(board: &Board, rules: &Rules, infos: &[NetInfo], nodes: &Nodes, f: &mut Vec<Finding>, s: &mut Score) {+ let steps = width_steps(board, nodes);+ let mut with_fill: HashSet<NetId> = HashSet::new();+ for z in &board.zones {+ if z.net != NO_NET && !z.keepout_tracks {+ with_fill.insert(z.net);+ }+ }+ for info in infos {+ let name = board.net_name(info.net);+ let tracks: Vec<&Track> = board.tracks.iter().filter(|t| t.net == info.net).collect();+ if !looks_ground(name) && (looks_power(name) || info.level == Level::Power) {+ let g = Graph::build(board, info.net);+ cap_first(board, info.net, &g, f, s);+ rail_rules(board, rules, info.net, &g, f, s);+ }+ if tracks.is_empty() {+ continue;+ }+ let len: f64 = tracks.iter().map(|t| len_of(t)).sum();+ let mst = tree_length(board, &info.pads);+ let vias = board.vias.iter().filter(|v| v.net == info.net).count();+ let st = steps.get(&info.net).cloned().unwrap_or_default();+ let fill = with_fill.contains(&info.net);+ s.nets.push(NetStat { name: name.to_string(), level: info.level.name(), pads: info.pads.len(), len_mm: len / NM, tree_mm: mst / NM, vias, width_steps: st.len(), fill });+ let at = at_mm(tracks[0].a);+ if st.len() > NET_STEPS {+ f.push(Finding { rule: "width-steps", severity: "warn", at: at_mm(st[0]), text: format!("{name}: {} width changes; the reference boards' most on a net is {NET_STEPS} (4.2: one technique, fewest segments)", st.len()) });+ }+ // A rail may via often (4.2 item 3), and a fill gives no straight-line measure.+ let rail = looks_power(name) || looks_ground(name) || info.level == Level::PlaneFanout || fill;+ if !rail && vias > 0 && mst / NM / (vias as f64) < VIA_SPACING {+ f.push(Finding { rule: "via-count", severity: "warn", at, text: format!("{name}: {vias} vias on a net {:.2} mm across; the reference boards' tightest is {:.2} mm per via (4.2: don't hop where the top works)", mst / NM, 2.98) });+ }+ if !rail && mst >= 1.0 * NM && len / mst > EFFICIENCY {+ f.push(Finding { rule: "route-efficiency", severity: "warn", at, text: format!("{name}: {:.2} mm of copper for {:.2} mm of straight line, {:.2} x; the reference boards' worst is 1.87 x (4.2: as straight as possible)", len / NM, mst / NM, len / mst) });+ }+ }+ s.nets.sort_by(|a, b| a.name.cmp(&b.name));+ s.width_steps = steps.values().map(|v| v.len()).sum();+}++/// Copper a net has lying apart from any pad (4.3: "islands: in practice, never"). A fill nothing+/// feeds is an error: connectivity counted only pads, so an unfed ring passed. Tracks and vias with+/// no pad are reported as a warning, since a stitching via in a plane is one too.+pub fn island_rules(board: &Board, f: &mut Vec<Finding>) {+ for isl in crate::conn::islands(board) {+ if isl.has_pad {+ continue;+ }+ let name = board.net_name(isl.net);+ if isl.fill_pieces > 0 {+ f.push(Finding { rule: "fill-unfed", severity: "error", at: (isl.at.0 / NM, isl.at.1 / NM), text: format!("{name}: a fill on {} of about {:.2} sq mm reaches no pad of the net (4.3)", board.layers[isl.layer].name, isl.area / NM / NM) });+ } else {+ f.push(Finding { rule: "orphan-copper", severity: "warn", at: (isl.at.0 / NM, isl.at.1 / NM), text: format!("{name}: {} tracks and {} vias reach no pad of the net", isl.tracks, isl.vias) });+ }+ }+}++#[cfg(test)]+mod tests {+ use super::*;+ use crate::check::check;++ fn board(extra: &str) -> Board {+ let head = r#"(kicad_pcb (layers (0 "F.Cu" signal) (2 "B.Cu" signal)) (setup (pad_to_mask_clearance 0.05))+ (gr_rect (start 0 0) (end 30 30) (layer "Edge.Cuts"))+ (footprint "x" (layer "F.Cu") (at 10 10) (property "Reference" "R1")+ (pad "1" smd rect (at 0 0) (size 1 1) (layers "F.Cu" "F.Mask") (net "A"))+ (pad "2" smd rect (at 8 0) (size 1 1) (layers "F.Cu" "F.Mask") (net "A")))"#;+ Board::parse(format!("{head}{extra})")).unwrap()+ }++ fn seg(a: (f64, f64), b: (f64, f64)) -> String {+ format!(r#"(segment (start {} {}) (end {} {}) (width 0.2) (layer "F.Cu") (net "A"))"#, a.0, a.1, b.0, b.1)+ }++ fn count(extra: &str, rule: &str) -> usize {+ check(&board(extra), &Rules::default()).0.iter().filter(|x| x.rule == rule).count()+ }++ #[test]+ fn a_retrace_out_of_a_pad_is_a_fold_back_and_one_inside_it_is_not() {+ // Up to the pad's centre and back out along itself, as DIO did at R15.2.+ let out = format!("{}{}", seg((14.0, 14.0), (10.0, 10.0)), seg((10.0, 10.0), (13.0, 13.0)));+ assert_eq!(count(&out, "fold-back"), 1);+ assert_eq!(count(&out, "corner-acute"), 0);+ // The same retrace, wholly under the pad's copper.+ let hid = format!("{}{}", seg((14.0, 14.0), (10.0, 10.0)), seg((10.0, 10.0), (10.2, 10.2)));+ assert_eq!(count(&hid, "fold-back"), 0);+ }++ #[test]+ fn a_doubled_run_counts_only_what_shows() {+ let side = format!("{}{}", seg((12.0, 5.0), (16.0, 5.0)), seg((12.0, 5.3), (16.0, 5.3)));+ assert_eq!(count(&side, "same-net-parallel"), 1);+ let far = format!("{}{}", seg((12.0, 5.0), (16.0, 5.0)), seg((12.0, 5.6), (16.0, 5.6)));+ assert_eq!(count(&far, "same-net-parallel"), 0);+ }++ #[test]+ fn a_fill_with_no_pad_is_unfed() {+ let z = r#"(zone (net "A") (layer "F.Cu") (polygon (pts (xy 20 20) (xy 22 20) (xy 22 22) (xy 20 22))) (filled_polygon (layer "F.Cu") (pts (xy 20 20) (xy 22 20) (xy 22 22) (xy 20 22))))"#;+ assert_eq!(count(z, "fill-unfed"), 1);+ let fed = format!("{z}{}", seg((18.0, 10.0), (21.0, 21.0)));+ assert_eq!(count(&fed, "fill-unfed"), 0);+ }++ /// An IC with one supply pin at (10, 20), a cap's supply pad at (14, 20), one more pad on the rail.+ fn supply(extra: &str) -> Board {+ let head = r#"(kicad_pcb (layers (0 "F.Cu" signal) (2 "B.Cu" signal))+ (gr_rect (start 0 0) (end 30 30) (layer "Edge.Cuts"))+ (footprint "ic" (layer "F.Cu") (at 8 20) (property "Reference" "U1")+ (pad "1" smd rect (at 2 0) (size 0.5 0.5) (layers "F.Cu") (net "+3V3") (pintype "power_in"))+ (pad "2" smd rect (at 2 2) (size 0.5 0.5) (layers "F.Cu"))+ (pad "3" smd rect (at -2 0) (size 0.5 0.5) (layers "F.Cu"))+ (pad "4" smd rect (at -2 2) (size 0.5 0.5) (layers "F.Cu"))+ (pad "5" smd rect (at 0 3) (size 0.5 0.5) (layers "F.Cu")))+ (footprint "c" (layer "F.Cu") (at 14 20) (property "Reference" "C1")+ (pad "1" smd rect (at 0 0) (size 0.5 0.5) (layers "F.Cu") (net "+3V3"))+ (pad "2" smd rect (at 1 0) (size 0.5 0.5) (layers "F.Cu") (net "GND")))+ (footprint "r" (layer "F.Cu") (at 25 20) (property "Reference" "R1")+ (pad "1" smd rect (at 0 0) (size 0.5 0.5) (layers "F.Cu") (net "+3V3"))+ (pad "2" smd rect (at 1 0) (size 0.5 0.5) (layers "F.Cu") (net "X")))"#;+ Board::parse(format!("{head}{extra})")).unwrap()+ }++ fn t(a: (f64, f64), b: (f64, f64), layer: &str) -> String {+ format!(r#"(segment (start {} {}) (end {} {}) (width 0.5) (layer "{layer}") (net "+3V3"))"#, a.0, a.1, b.0, b.1)+ }++ fn caps(b: &Board) -> usize {+ check(b, &Rules::default()).0.iter().filter(|x| x.rule == "cap-first").count()+ }++ #[test]+ fn cap_first_sees_past_vias_and_taps() {+ // Pin, cap, then rail: fine.+ let good = format!("{}{}", t((10.0, 20.0), (14.0, 20.0), "F.Cu"), t((14.0, 20.0), (25.0, 20.0), "F.Cu"));+ assert_eq!(caps(&supply(&good)), 0);+ // A tap landing mid-track between the pin and the cap: the old walk saw only endpoints.+ let tap = format!("{good}{}", t((12.0, 20.0), (12.0, 24.0), "F.Cu"));+ assert_eq!(caps(&supply(&tap)), 1);+ // The pin goes down a via with the rail on the far side, and only then to the cap.+ let via = format!(+ "{}{}{}{}{}",+ t((10.0, 20.0), (11.0, 20.0), "F.Cu"),+ r#"(via (at 11 20) (size 0.6) (drill 0.3) (layers "F.Cu" "B.Cu") (net "+3V3"))"#,+ t((11.0, 20.0), (25.0, 20.0), "B.Cu"),+ t((11.0, 20.0), (14.0, 20.0), "F.Cu"),+ r#"(via (at 25 20) (size 0.6) (drill 0.3) (layers "F.Cu" "B.Cu") (net "+3V3"))"#,+ );+ assert_eq!(caps(&supply(&via)), 1);+ }++ #[test]+ fn the_floor_reads_the_main_run_not_the_widest_scrap() {+ // 20 mm drawn at 0.2 with 1 mm at 0.8 is a 0.2 mm rail, on a small board.+ let small = |s: &str| Board::parse(format!(r#"(kicad_pcb (layers (0 "F.Cu" signal) (2 "B.Cu" signal)) (gr_rect (start 0 0) (end 30 30) (layer "Edge.Cuts")) (footprint "r" (layer "F.Cu") (at 2 5) (property "Reference" "R1") (pad "1" smd rect (at 0 0) (size 0.5 0.5) (layers "F.Cu") (net "+3V3"))) (footprint "q" (layer "F.Cu") (at 23 5) (property "Reference" "R2") (pad "1" smd rect (at 0 0) (size 0.5 0.5) (layers "F.Cu") (net "+3V3"))){s})"#)).unwrap();+ let tr = |a: f64, b: f64, w: f64| format!(r#"(segment (start {a} 5) (end {b} 5) (width {w}) (layer "F.Cu") (net "+3V3"))"#);+ let thin = format!("{}{}", tr(2.0, 22.0, 0.2), tr(22.0, 23.0, 0.8));+ let rails = |b: &Board| check(b, &Rules::default()).0.iter().filter(|x| x.rule == "rail-floor").count();+ assert_eq!(rails(&small(&thin)), 1);+ // Mostly 0.5 with short necks at the pins passes: 4.2 lets a branch be narrower.+ let good = format!("{}{}{}", tr(2.0, 3.0, 0.2), tr(3.0, 22.0, 0.5), tr(22.0, 23.0, 0.2));+ assert_eq!(rails(&small(&good)), 0);+ }+}
src/render.rsadded+99@@ -0,0 +1,99 @@+//! A plain SVG of the copper, to look at a result. Top layer red, bottom blue, vias white.++use crate::board::*;+use crate::geom::*;+use std::fmt::Write;++/// How to draw. `fills`: the zones' filled copper as saved, not their outlines, so a board KiCad has+/// refilled shows what the fab gets. `highlight`: only these nets at full strength. `labels`: name pads.+#[derive(Default)]+pub struct View {+ pub fills: bool,+ pub highlight: Vec<String>,+ pub labels: bool,+}++pub fn svg(b: &Board, window: Option<(f64, f64, f64, f64)>) -> String {+ svg_view(b, window, &View { labels: window.is_some(), ..Default::default() })+}++pub fn svg_view(b: &Board, window: Option<(f64, f64, f64, f64)>, view: &View) -> String {+ let bb = window.map(|w| (w.0 * NM, w.1 * NM, w.2 * NM, w.3 * NM)).unwrap_or(b.bbox);+ let (w, h) = ((bb.2 - bb.0) / NM, (bb.3 - bb.1) / NM);+ let last = b.layers.len() - 1;+ let mut s = String::new();+ let hit = |b: &Board, n: NetId| view.highlight.is_empty() || view.highlight.iter().any(|h| h == b.net_name(n));+ let op = |b: &Board, n: NetId, full: f64| if hit(b, n) { full } else { 0.18 };+ let _ = write!(s, r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="{} {} {} {}" width="{}" height="{}"><rect x="{}" y="{}" width="{}" height="{}" fill="#101418"/>"##, bb.0 / NM, bb.1 / NM, w, h, 1500.0, 1500.0 * h / w, bb.0 / NM, bb.1 / NM, w, h);+ for z in &b.zones {+ if z.keepout_tracks || z.outline.len() < 3 || !(z.layers.contains(&0) || z.layers.contains(&last)) {+ continue;+ }+ let col = if z.layers.contains(&0) { "#a03030" } else { "#3050a0" };+ let lit = hit(b, z.net);+ if view.fills && !z.fills.is_empty() {+ for (l, f) in &z.fills {+ if *l != 0 && *l != last {+ continue;+ }+ let col = if *l == 0 { "#a03030" } else { "#3050a0" };+ let pts: String = f.iter().map(|p| format!("{:.4},{:.4} ", p.0 / NM, p.1 / NM)).collect();+ let _ = write!(s, r#"<polygon points="{pts}" fill="{col}" fill-opacity="{}"/>"#, if lit { 0.55 } else { 0.22 });+ }+ continue;+ }+ let pts: String = z.outline.iter().map(|p| format!("{:.4},{:.4} ", p.0 / NM, p.1 / NM)).collect();+ let _ = write!(s, r#"<polygon points="{pts}" fill="{col}" fill-opacity="{}"/>"#, if lit { 0.4 } else { 0.25 });+ }+ fn draw(s: &mut String, shape: &Shape, col: &str) {+ match shape {+ Shape::Circle { c, r } => {+ let _ = write!(s, r#"<circle cx="{:.4}" cy="{:.4}" r="{:.4}" fill="{col}"/>"#, c.0 / NM, c.1 / NM, r / NM);+ }+ Shape::Capsule { a, b, r } => {+ let _ = write!(s, r#"<line x1="{:.4}" y1="{:.4}" x2="{:.4}" y2="{:.4}" stroke="{col}" stroke-width="{:.4}" stroke-linecap="round"/>"#, a.0 / NM, a.1 / NM, b.0 / NM, b.1 / NM, r * 2.0 / NM);+ }+ Shape::Poly { pts, .. } => {+ let t: String = pts.iter().map(|p| format!("{:.4},{:.4} ", p.0 / NM, p.1 / NM)).collect();+ let _ = write!(s, r#"<polygon points="{t}" fill="{col}"/>"#);+ }+ Shape::Multi(v) => v.iter().for_each(|x| draw(s, x, col)),+ }+ }+ let pad = |s: &mut String, p: &Pad, col: &str| draw(s, &p.shape, col);+ for p in b.pads.iter().filter(|p| p.layers.contains(&last) && !p.layers.contains(&0)) {+ pad(&mut s, p, "#5070c0");+ }+ for t in b.tracks.iter().filter(|t| t.layer != 0) {+ let _ = write!(s, r##"<line x1="{:.4}" y1="{:.4}" x2="{:.4}" y2="{:.4}" stroke="#4d8dff" stroke-opacity="{:.2}" stroke-width="{:.4}" stroke-linecap="round"/>"##, to_mm(t.a.0), to_mm(t.a.1), to_mm(t.b.0), to_mm(t.b.1), op(b, t.net, 0.85), to_mm(t.width));+ }+ for p in b.pads.iter().filter(|p| p.layers.contains(&0)) {+ pad(&mut s, p, "#c8a040");+ }+ for t in b.tracks.iter().filter(|t| t.layer == 0) {+ let _ = write!(s, r##"<line x1="{:.4}" y1="{:.4}" x2="{:.4}" y2="{:.4}" stroke="#ff5050" stroke-opacity="{:.2}" stroke-width="{:.4}" stroke-linecap="round"/>"##, to_mm(t.a.0), to_mm(t.a.1), to_mm(t.b.0), to_mm(t.b.1), op(b, t.net, 0.9), to_mm(t.width));+ }+ for v in b.vias.iter().filter(|v| hit(b, v.net)) {+ let _ = write!(s, r##"<circle cx="{:.4}" cy="{:.4}" r="{:.4}" fill="#e8e8e8"/><circle cx="{:.4}" cy="{:.4}" r="{:.4}" fill="#101418"/>"##, to_mm(v.at.0), to_mm(v.at.1), to_mm(v.size) / 2.0, to_mm(v.at.0), to_mm(v.at.1), to_mm(v.drill) / 2.0);+ }+ // Zoomed in, name the pads: ref.pin and net, small enough to sit inside the view.+ if view.labels {+ let size = w.min(h) / 42.0;+ for p in &b.pads {+ let c: V = p.center.into();+ if c.0 < bb.0 || c.0 > bb.2 || c.1 < bb.1 || c.1 > bb.3 || p.layers.is_empty() || !hit(b, p.net) {+ continue;+ }+ let net = b.net_name(p.net).rsplit('/').next().unwrap_or("");+ let net = if net.starts_with("unconnected-") { "n/c" } else { net };+ let label = format!("{} {}", b.pad_name(b.pads.iter().position(|q| std::ptr::eq(q, p)).unwrap()), net.replace('&', "and").replace('<', "").replace('>', ""));+ let tw = label.len() as f64 * size * 0.56;+ let _ = write!(s, r##"<rect x="{:.4}" y="{:.4}" width="{tw:.4}" height="{:.4}" fill="#000000" fill-opacity="0.7"/><text x="{:.4}" y="{:.4}" font-family="Arial" font-size="{size:.4}" fill="#ffffff" text-anchor="middle">{label}</text>"##, c.0 / NM - tw / 2.0, c.1 / NM - size * 0.75, size * 1.15, c.0 / NM, c.1 / NM + size * 0.15);+ }+ }+ for (a, c) in &b.edges {+ let _ = write!(s, r##"<line x1="{:.4}" y1="{:.4}" x2="{:.4}" y2="{:.4}" stroke="#d0d040" stroke-width="0.08"/>"##, a.0 / NM, a.1 / NM, c.0 / NM, c.1 / NM);+ }+ s.push_str("</svg>");+ s+}
src/repair.rsadded+2142@@ -0,0 +1,2142 @@+//! Completion: what happens to a connection the first pass couldn't make, and the lanes that keep+//! fine-pitch pins from getting stuck in the first place. Three ideas, all transactional, so a+//! repair that doesn't close leaves the board exactly as it found it:+//! - Copper is the truth. What is open is read from the routed copper the way the checker reads it,+//! so a join a fill already made isn't routed again and a join the plan forgot isn't lost.+//! - Displace, don't rip and hope. A soft search names who is in the way; those routes come up,+//! this task goes down, and each victim must find a new place before the move is accepted.+//! A victim keeps the parts of its route that are still legal (the splice below, from Organic+//! Maps' AdjustRoute: search from the break to the old route and keep the old tail; VTR and+//! nextpnr keep a route's legal part the same way). Only if that fails does it route afresh.+//! - Memory and backoff. A victim that couldn't move is banned for the next try (Freerouting's+//! rip-up memory), and a task that fails the same way again is given up on and handed to the+//! designer with its reason (Part 3 step 6).++use super::*;++/// Price of moving a route, nm of track: sets how far a soft search detours before it would rather+/// ask someone to move. Measured on piprobe_v13: 0.5, 1 and 2 mm land within one join of each other.+const SOFT_PRICE: f64 = 1.0 * NM;+/// Budget of a soft search and of a repair's own searches; see `pf_limit` for the small windows.+const SOFT_NODES: usize = 160_000;+const SOFT_WEIGHT: f64 = 1.5;+/// Blocker sets tried per task per round before it is left for the next round.+const REPAIR_TRIES: u32 = 4;+/// Rounds of swap moves once plain repair has stalled.+const SWAP_ROUNDS: u32 = 2;++/// One repair in progress, and how to undo it.+#[derive(Default)]+pub(super) struct Txn {+ removed: Vec<Route>,+ added: Vec<u32>,+ /// Routes laid in this repair: nothing in the same repair may take them back.+ locked: HashSet<u32>,+ /// Tasks that lost their place to a higher priority task in a swap move.+ dropped: Vec<Task>,+ banned: HashSet<(usize, usize)>,+ /// How many tasks this repair may touch, so one bad spot can't eat the whole time budget.+ fuel: i32,+ /// Each net a victim belongs to and how many pieces it was in before: a rail's tap may hang off+ /// the piece that moved, so a move is only good if no net ends up in more pieces.+ apart: HashMap<NetId, usize>,+}++#[derive(Clone, Copy)]+pub(super) struct Mode {+ /// May move higher priority data too (never pairs or Kelvin taps, 4.2).+ pub escalate: bool,+ /// May leave one lower priority victim unrouted: a sideways move that a later repair can use.+ pub swap: bool,+}++pub(super) enum Repair {+ Fixed,+ Swapped(Task),+ Failed,+}++pub(super) struct Best {+ pub score: (usize, usize, i64),+ pub routes: HashMap<u32, Route>,+ pub failed: Vec<Task>,+ pub notes: Vec<String>,+}++fn route_tie(level: Level) -> usize {+ // A hole in a higher level of the hierarchy is worse than one lower down.+ match level {+ Level::Kelvin | Level::Pair => 4,+ Level::Crystal | Level::Data => 3,+ Level::Power => 2,+ Level::PlaneFanout | Level::Other => 1,+ }+}++impl<'a> Router<'a> {+ /// A track starting at a pad's centre can break a class clearance the pads themselves are set+ /// tighter than: on benchmark B U3.1's centre line is 0.231 mm from U3.12, class 0.25 (his+ /// project excludes that DRC finding). Shifting the start across the pad, inside its copper,+ /// keeps the rule instead of bending it. Only pads whose centre fails get one.+ pub(super) fn find_pad_entries(&mut self, plan: &Plan) {+ let n = self.rules.neck;+ let half = n as f64 / 2.0;+ let pads: HashSet<usize> = plan.tasks.iter().flat_map(|t| [t.a, t.b]).collect();+ let mut found = Vec::new();+ let mut boxed = Vec::new();+ for i in pads {+ let p = &self.board.pads[i];+ let Some(layer) = p.layers.iter().copied().find(|l| self.world.route_layers.contains(l)) else { continue };+ let c = snap(p.center);+ if self.seg_clear(c, c, layer, n, p.net, NO_NET) {+ continue;+ }+ let b = p.shape.bbox();+ let (rx, ry) = (((b.2 - b.0) / 2.0 - half).max(0.0) as i64, ((b.3 - b.1) / 2.0 - half).max(0.0) as i64);+ let mut best: Option<(i64, P)> = None;+ // Steps of 0.01 mm, plus the flush offsets where the track's edge meets the pad's edge:+ // on benchmark B U3.12 (pad 0.2, neck 0.15) only the flush 0.025 mm meets the class's+ // 0.25 mm to U3.11, and it falls between two steps.+ let mut steps: Vec<i64> = (1..=50i64).map(|k| k * 10_000).chain([rx, ry]).filter(|&d| d > 0).collect();+ steps.sort_unstable();+ steps.dedup();+ for d in steps {+ for (dx, dy) in [(d, 0), (-d, 0), (0, d), (0, -d)] {+ if dx.abs() > rx || dy.abs() > ry {+ continue;+ }+ let q = snap((c.0 + dx, c.1 + dy));+ if p.shape.dist_pt(q.into()) <= 0.0 && self.seg_clear(q, q, layer, n, p.net, NO_NET) && best.is_none() {+ best = Some((d, q));+ }+ }+ if best.is_some() {+ break;+ }+ }+ match best {+ Some((_, q)) => found.push((i, q)),+ None => boxed.push(i),+ }+ }+ // No start on these pads meets the class. Where the footprint's own pads are what box one in,+ // a local rule at the pad settles it for review; otherwise only the designer can.+ let boxed: Vec<usize> = boxed.into_iter().filter(|&i| !self.propose_pad_rule(i)).collect();+ for i in boxed {+ for t in plan.tasks.iter().filter(|t| t.a == i || t.b == i) {+ self.boxed_pads.insert((t.a, t.b), i);+ }+ }+ for (i, q) in found {+ self.log.push(format!("{}: track starts {:.3} mm off the pad centre, which is closer to a neighbour than the class clearance", self.board.pad_name(i), hypot(sub(q.into(), self.board.pads[i].center.into())) / NM));+ self.pad_entry.insert(i, q);+ }+ }++ /// Length of a path that runs inside pours of other nets (those it may cross, see World).+ pub(crate) fn pour_run(&self, net: NetId, pts: &[(P, usize)]) -> f64 {+ let mut polys: Vec<(usize, &Vec<V>)> = Vec::new();+ for (z, nets) in self.world.pour_open.iter().enumerate() {+ if nets.binary_search(&net).is_ok() {+ let zone = &self.board.zones[z];+ polys.extend(zone.layers.iter().map(|&l| (l, &zone.outline)));+ }+ }+ if polys.is_empty() {+ return 0.0;+ }+ let mut run = 0.0;+ for w in pts.windows(2).filter(|w| w[0].1 == w[1].1) {+ let (a, b): (V, V) = (w[0].0.into(), w[1].0.into());+ let m = V((a.0 + b.0) / 2.0, (a.1 + b.1) / 2.0);+ if polys.iter().any(|(l, p)| *l == w[0].1 && in_poly(m, p)) {+ run += hypot(sub(a, b));+ }+ }+ run+ }++ /// The start on pad `i` that leaves the most room to its footprint's other pads, and a local+ /// rule lowering the clearance to those pads to exactly that room, never under the standing+ /// minimum. True if a track can then start there.+ fn propose_pad_rule(&mut self, i: usize) -> bool {+ let p = self.board.pads[i].clone();+ let n = self.rules.neck;+ let half = n as f64 / 2.0;+ let Some(layer) = p.layers.iter().copied().find(|l| self.world.route_layers.contains(l)) else { return false };+ let reference = self.board.footprints[p.fp].reference.clone();+ let near: Vec<usize> = self.board.footprints[p.fp].pads.iter().copied().filter(|&q| q != i && self.board.pads[q].net != p.net && self.board.pads[q].layers.contains(&layer)).collect();+ let c = snap(p.center);+ let b = p.shape.bbox();+ let (rx, ry) = (((b.2 - b.0) / 2.0 - half).max(0.0) as i64, ((b.3 - b.1) / 2.0 - half).max(0.0) as i64);+ let room = |s: P| near.iter().map(|&q| self.board.pads[q].shape.dist_pt(s.into()) - half).fold(f64::INFINITY, f64::min);+ let mut best = (room(c), c);+ for s in [(rx, 0), (-rx, 0), (0, ry), (0, -ry)].map(|d| snap((c.0 + d.0, c.1 + d.1))) {+ if p.shape.dist_pt(s.into()) <= 0.0 && room(s) > best.0 {+ best = (room(s), s);+ }+ }+ // To the micron below, so the rule's number is one a person can read and DRC agrees with.+ let min = (best.0 / 1000.0).floor() as i64 * 1000;+ if min < self.rules.floor.max(self.rules.min.clearance) {+ return false;+ }+ let needed: Vec<usize> = near.iter().copied().filter(|&q| {+ let o = &self.board.pads[q];+ o.shape.dist_pt(best.1.into()) - half < self.rules.clearance_for(p.net, o.net) as f64 && o.local_clearance <= min as f64+ }).collect();+ if needed.is_empty() {+ return false;+ }+ let net = self.board.net_name(p.net).to_string();+ let kept = self.rules.pad_rules.len();+ for q in needed {+ self.rules.pad_rules.push(PadRule { net: net.clone(), reference: reference.clone(), pad: self.board.pads[q].number.clone(), min });+ }+ self.rules.bind(self.board);+ if !self.seg_clear(best.1, best.1, layer, n, p.net, NO_NET) {+ self.rules.pad_rules.truncate(kept);+ self.rules.bind(self.board);+ return false;+ }+ self.log.push(format!("{}: no start on the pad meets the class, so a local rule at {:.3} mm is proposed", self.board.pad_name(i), to_mm(min)));+ self.pad_entry.insert(i, best.1);+ true+ }++ /// A way off a big pour for pad `i`: the via site within a short stub that leaves the most room to+ /// its footprint's other pads, and local rules lowering the clearance to just that room, never+ /// under the standing minimum. True if it added a rule to try. His own exit at J1 on benchmark B+ /// sits inside USB's 0.4 mm class in the same way, with those DRC findings excluded by hand.+ pub(super) fn propose_exit_rule(&mut self, i: usize) -> bool {+ if !self.pour_exit_pads.contains(&i) {+ return false;+ }+ let p = self.board.pads[i].clone();+ let net = p.net;+ let layer = p.layers[0];+ let (via_d, _) = self.rules.via_for(net);+ let vr = via_d as f64 / 2.0;+ let half = self.rules.neck as f64 / 2.0;+ let own = vr + self.rules.pad_via_gap as f64;+ let reference = self.board.footprints[p.fp].reference.clone();+ let near: Vec<usize> = self.board.footprints[p.fp].pads.iter().copied().filter(|&q| q != i && self.board.pads[q].net != net && self.board.pads[q].layers.contains(&layer)).collect();+ let c = snap(p.center);+ let room_at = |s: P| -> f64 {+ near.iter().map(|&q| {+ let o = &self.board.pads[q].shape;+ (o.dist_pt(s.into()) - vr).min(o.dist_seg(c.into(), s.into()) - half)+ }).fold(f64::INFINITY, f64::min)+ };+ let mut best: Option<(f64, P)> = None;+ for dir in 0..8usize {+ let (dx, dy) = DIRS[dir];+ for k in 1..=(self.pour_limit / 50_000.0) as i64 {+ let d = k * 50_000;+ let t = if dx != 0 && dy != 0 { (d as f64 / SQRT2) as i64 } else { d };+ let s = snap((c.0 + dx * t, c.1 + dy * t));+ if p.shape.dist_pt(s.into()) < own || self.world.mask.via_hit(net, s.into(), vr).is_some() {+ continue;+ }+ let room = room_at(s);+ if best.map(|b| room > b.0).unwrap_or(true) {+ best = Some((room, s));+ }+ }+ }+ let Some((room, _)) = best else { return false };+ let min = (room / 1000.0).floor() as i64 * 1000;+ if min < self.rules.floor.max(self.rules.min.clearance) {+ return false;+ }+ let name = self.board.net_name(net).to_string();+ let mut added = false;+ for &q in &near {+ let o = &self.board.pads[q];+ let number = o.number.clone();+ if (min as f64) >= self.rules.clearance_for(net, o.net) as f64 || o.local_clearance > min as f64 {+ continue;+ }+ if self.rules.pad_rules.iter().any(|r| r.net == name && r.reference == reference && r.pad == number && r.min <= min) {+ continue;+ }+ self.rules.pad_rules.retain(|r| !(r.net == name && r.reference == reference && r.pad == number));+ self.rules.pad_rules.push(PadRule { net: name.clone(), reference: reference.clone(), pad: number, min });+ added = true;+ }+ if added {+ self.rules.bind(self.board);+ self.log.push(format!("{}: no way off the pour under the class, so local rules at {:.3} mm are proposed", self.board.pad_name(i), to_mm(min)));+ }+ added+ }++ /// A proposal stands only where a track really uses it, and then the designer hears about it.+ pub(super) fn settle_pad_rules(&mut self) {+ let mut used = Vec::new();+ for rule in std::mem::take(&mut self.rules.pad_rules) {+ let Some(net) = self.board.net_ids.get(&rule.net).copied() else { continue };+ // A number can name several pads: a connector's shell pads are all SH.+ let vr = self.rules.via_for(net).0 as f64 / 2.0;+ let mut tightest: Option<(f64, f64)> = None;+ for q in self.board.pads.iter().filter(|q| q.number == rule.pad && self.board.footprints[q.fp].reference == rule.reference) {+ let class = self.rules.clearance_for(net, q.net) as f64;+ let track = self.routes.values().filter(|r| r.task.net == net).flat_map(|r| r.segs.iter()).filter(|s| q.layers.contains(&s.layer)).map(|s| q.shape.dist_seg(s.a.into(), s.b.into()) - s.width as f64 / 2.0).fold(f64::INFINITY, f64::min);+ let closest = self.routes.values().filter(|r| r.task.net == net).flat_map(|r| r.vias.iter()).map(|v| q.shape.dist_pt((*v).into()) - vr).fold(track, f64::min);+ if closest < class - DRC_TOL && tightest.map(|t| closest < t.0).unwrap_or(true) {+ tightest = Some((closest, class));+ }+ }+ if let Some((closest, class)) = tightest {+ self.notes.push(format!("for the designer: {} passes {} {} at {:.3} mm under a proposed local rule (class {:.3} mm): no track fits its pad otherwise; review the rule in the output's .kicad_dru", rule.net, rule.reference, rule.pad, closest / NM, class / NM));+ used.push(rule);+ }+ }+ self.rules.pad_rules = used;+ self.rules.bind(self.board);+ }++ /// `pour_run` of a laid route, segment by segment: a route's segments need not join end to end.+ pub(crate) fn seg_pour_run(&self, r: &Route) -> f64 {+ r.segs.iter().map(|s| self.pour_run(r.task.net, &[(s.a, s.layer), (s.b, s.layer)])).sum()+ }++ /// The pours this board's copper would split on a refill, by net name: (net, extra pieces,+ /// cutting nets). Judged from the written board, the same way the checker judges it.+ fn pour_splits(&self) -> Vec<(String, usize, Vec<NetId>)> {+ // In id order: a hash map's order changes on every start of the program (clock.rs).+ let mut routes: Vec<&Route> = self.routes.values().collect();+ routes.sort_by_key(|r| r.id);+ let text = crate::write::render(self.board, &routes, &self.rules);+ let Ok(written) = Board::parse(text) else { return Vec::new() };+ let mut rules = self.rules.clone();+ rules.bind(&written);+ let world = build_world(&written, &rules);+ crate::pour::splits(&written, &rules, &world)+ .into_iter()+ .map(|s| (written.net_name(s.net).to_string(), s.extra, s.cutters.iter().filter_map(|&c| self.board.net_ids.get(written.net_name(c)).copied()).collect()))+ .collect()+ }++ /// Ground stitching, and the same for any poured net: where a refill would leave a piece of a+ /// pour apart from the rest of its net, a via ties it to the net's copper on another layer.+ /// The boards on the wiki do this everywhere (the designer's and another designer's have 100 to 300 vias where the+ /// router laid 20 to 60), and without it KiCad reports the pieces unconnected: on four of the+ /// first eight wiki boards the router called every join made while KiCad found 10 to 16 open+ /// (2026-11-10). Judged from the written board; a via stays only where one is legal and the+ /// net has a pour or plane on another layer there.+ pub(super) fn stitch_pours(&mut self) {+ let t0 = Instant::now();+ let (mut added, mut left) = (0usize, 0usize);+ let mut tried: HashSet<P> = HashSet::new();+ let mut tried_pairs: HashSet<(usize, usize)> = HashSet::new();+ let mut failed_pieces: HashSet<usize> = HashSet::new();+ let mut tracks = 0usize;+ let octile_p = |a: P, b: P| -> f64 {+ let (dx, dy) = ((a.0 - b.0).abs() as f64, (a.1 - b.1).abs() as f64);+ dx.max(dy) + (SQRT2 - 1.0) * dx.min(dy)+ };+ for _round in 0..60 {+ if self.over_time() {+ break;+ }+ let mut routes: Vec<&Route> = self.routes.values().collect();+ routes.sort_by_key(|r| r.id);+ let text = crate::write::render(self.board, &routes, &self.rules);+ let Ok(written) = Board::parse(text) else { return };+ let mut rules = self.rules.clone();+ rules.bind(&written);+ let world = build_world(&written, &rules);+ let nets: std::collections::BTreeSet<NetId> = written.zones.iter().filter(|z| z.net != NO_NET && !z.keepout_tracks && z.outline.len() >= 3).map(|z| z.net).collect();+ let (mut placed, mut loose) = (0usize, 0usize);+ for wnet in nets {+ let Some(&net) = self.board.net_ids.get(written.net_name(wnet)) else { continue };+ // A run asked for a few nets (a fixture) mends only those.+ if self.only.as_ref().map_or(false, |o| !o.contains(&net)) {+ continue;+ }+ let Some(pad) = self.board.pads.iter().position(|p| p.net == net && !p.layers.is_empty()) else { continue };+ for (layer, cells, island_pads) in crate::pour::loose_islands(&written, &rules, &world, wnet) {+ loose += 1;+ let mut done = false;+ // Somewhere to go: the net's pour or plane on another layer over this point.+ let other = |c: V| self.board.zones.iter().any(|z| z.net == net && !z.keepout_tracks && z.layers.iter().any(|&l| l != layer) && in_poly(c, &z.outline));+ let profile = Profile { width: 0, neck: 0, clearance: self.rules.clearance_for(net, net), target_gap: 0, max_vias: 0, margin: 0.0, layer_bias: None };+ let q = Query { net, also: None, sources: &[], targets: &[], profile, rules: &self.rules, soft: None, via_in: None, history: None, free: &[], arrive: None };+ for c in cells {+ let at = crate::search::snap(to_p(c));+ if !tried.insert(at) || !other(c) {+ continue;+ }+ let (mut b, mut sc) = (Vec::new(), 0.0);+ if self.world.via_ok(at, &q, None, &mut b, &mut sc) {+ let id = self.fresh_id();+ let task = Task { net, level: Level::PlaneFanout, a: pad, b: pad, main: false, zone: None };+ self.place(id, task, Vec::new(), vec![at]);+ placed += 1;+ done = true;+ break;+ }+ }+ // No room on the piece itself, but the net's other layer lies under one of its pads:+ // a via beside that pad, on a short stub, as a plane pad gets (4.2). Cheaper than a+ // track and the way he ties such a pad (a motor-controller board, Q302.2 over the B.Cu plane).+ if !done {+ for &ip in &island_pads {+ let at = self.board.pads[ip].center;+ if !other(at.into()) || !tried.insert(at) {+ continue;+ }+ let task = Task { net, level: Level::PlaneFanout, a: ip, b: ip, main: false, zone: None };+ if self.fan_out_pad(ip, &task) {+ placed += 1;+ done = true;+ break;+ }+ }+ }+ // No copper of the net on another layer to drop to, or no room for the via: a track+ // from a pad on this piece to the nearest pad of the net off it.+ // One try per piece: the join already aims at every piece off this one, so a second+ // try from another of its pads fails the same way (it used up stitching's time).+ let piece_key = island_pads.iter().copied().min();+ if !done && !island_pads.is_empty() && !piece_key.map_or(false, |k| failed_pieces.contains(&k)) {+ let c = |i: usize| self.board.pads[i].center;+ let mut best: Option<(f64, usize, usize)> = None;+ for &ip in &island_pads {+ for (o, p) in self.board.pads.iter().enumerate() {+ if p.net != net || p.layers.is_empty() || island_pads.contains(&o) {+ continue;+ }+ let d = octile_p(c(ip), c(o));+ if best.map_or(true, |b| d < b.0) && !tried_pairs.contains(&(ip, o)) {+ best = Some((d, ip, o));+ }+ }+ }+ if let Some((_, ip, o)) = best {+ tried_pairs.insert((ip, o));+ let off: Vec<usize> = self.board.pads.iter().enumerate().filter(|(i, p)| p.net == net && !p.layers.is_empty() && !island_pads.contains(i)).map(|(i, _)| i).collect();+ if self.join_by_track(net, ip, o, &off) {+ placed += 1;+ tracks += 1;+ done = true;+ } else if let Some(k) = piece_key {+ failed_pieces.insert(k);+ }+ }+ }+ // Still loose: a via beside a pad on it, on a short stub, as a plane pad gets (4.2).+ if !done {+ for ip in island_pads {+ if !tried.insert(self.board.pads[ip].center) {+ continue;+ }+ let task = Task { net, level: Level::PlaneFanout, a: ip, b: ip, main: false, zone: None };+ if self.fan_out_pad(ip, &task) {+ placed += 1;+ break;+ }+ }+ }+ }+ }+ added += placed;+ left = loose - placed.min(loose);+ if placed == 0 {+ break;+ }+ }+ if added > 0 || left > 0 {+ self.log.push(format!("stitching: {} vias tie pour pieces to their net on another layer, {tracks} tracks join pads a pour leaves apart; {left} pieces had no place for either, {:.1} s", added - tracks, t0.elapsed().as_secs_f64()));+ }+ }++ /// A track from the copper of pad `a`'s piece to the nearest copper of the pads `to`, searched+ /// without the net's pour as a target, since the pour is what failed to join them. Both ends are+ /// whole pieces (pads and their tracks), so the new track never runs along one already there.+ fn join_by_track(&mut self, net: NetId, a: usize, b: usize, to: &[usize]) -> bool {+ let task = Task { net, level: Level::Other, a, b, main: false, zone: None };+ let (width, neck) = self.widths_for(&task);+ let clearance = self.rules.clearance_for(net, net);+ let comps = self.components_with(net, false);+ let (mut sources, mut targets) = (Vec::new(), Vec::new());+ for c in &comps {+ if c.pads.contains(&a) {+ self.comp_terminals(c, 0.0, &mut sources);+ } else if c.pads.iter().any(|p| to.contains(p)) {+ self.comp_terminals(c, 0.0, &mut targets);+ }+ }+ if sources.is_empty() || targets.is_empty() {+ return false;+ }+ // A mend, not a first route: a join that is not found quickly is left for the designer's note.+ // At the full node limit a few hopeless joins used all of stitching's time on a motor-controller board.+ let mut rules = self.rules.clone();+ rules.node_limit = rules.node_limit.min(60_000);+ // A search leaves from pads and may land on a track, never the reverse. So both ways are+ // tried and the shorter kept: the side that already has a track toward the other is then+ // the one landed on, and nothing is laid twice.+ let len = |pts: &[(P, usize)]| -> f64 { pts.windows(2).map(|w| hypot(sub(w[0].0.into(), w[1].0.into()))).sum() };+ for (max_vias, margin) in [(0u32, 3.0), (2, 6.0), (2, 15.0)] {+ let pf = Profile { width, neck, clearance, target_gap: clearance, max_vias, margin: margin * NM, layer_bias: None };+ let mut best: Option<(f64, Vec<(P, usize)>)> = None;+ for (from, to) in [(&sources, &targets), (&targets, &sources)] {+ let q = Query { net, also: None, sources: from, targets: to, profile: pf, rules: &rules, soft: None, via_in: None, history: None, free: &[], arrive: None };+ self.searches += 1;+ let Some(path) = search(&self.world, &q) else { continue };+ self.nodes += path.nodes as u64;+ let pts = self.finish_path(&path, net, NO_NET, neck);+ let l = len(&pts);+ if best.as_ref().map_or(true, |b| l < b.0) {+ best = Some((l, pts));+ }+ }+ if let Some((_, pts)) = best {+ self.commit(&task, &pts, width, neck, NO_NET);+ return true;+ }+ }+ false+ }++ /// Takes the nets that cut a pour off it. His way (benchmark B USB connector): a short stub off+ /// the pad and straight to the other layer. One net at a time first, then all of them together,+ /// since two neighbours can each block the other's way down. A move stands only if the refill+ /// model says the pour is better for it and no join opened; otherwise the old copper goes back.+ pub(super) fn heal_pours(&mut self) {+ self.grant_time(60.0);+ for _ in 0..8 {+ let splits = self.pour_splits();+ let total: usize = splits.iter().map(|s| s.1).sum();+ if total == 0 {+ break;+ }+ let cutters: Vec<NetId> = splits.iter().flat_map(|s| s.2.iter().copied()).collect();+ let mut sets: Vec<Vec<NetId>> = cutters.iter().map(|&c| vec![c]).collect();+ if cutters.len() > 1 {+ sets.push(cutters.clone());+ }+ // A net alone may not be the cut: a pour can be blocked on both sides of a connector by+ // different nets, and only moving them together opens it. So every net leaving a big pour.+ let mut leaving: Vec<NetId> = self.pour_exit.iter().copied().filter(|&n| !looks_ground(self.board.net_name(n))).collect();+ leaving.sort_unstable();+ if leaving.len() > 1 {+ sets.push(leaving);+ }+ if !sets.iter().any(|set| self.move_off_pour(set, total)) {+ break;+ }+ }+ for (net, extra, cutters) in self.pour_splits() {+ let names: Vec<&str> = cutters.iter().map(|&c| self.board.net_name(c)).collect();+ self.notes.push(format!("for the designer: the {net} pour refills into {extra} extra separate pieces, cut by {}: move them or bridge the pieces", names.join(", ")));+ }+ }++ fn move_off_pour(&mut self, nets: &[NetId], total: usize) -> bool {+ let names = nets.iter().map(|&c| self.board.net_name(c).to_string()).collect::<Vec<_>>().join(", ");+ // Even a short link can cut a pour: D+ tying a USB-C receptacle's two rows together across the+ // gap between them. So anything more than a stub off a pad is up for moving, and may not come+ // back longer than one (reference J1: each pad drops to the run by its own via).+ let ids: Vec<u32> = self.routes.values().filter(|r| nets.contains(&r.task.net) && self.seg_pour_run(r) > STUB_RUN).map(|r| r.id).collect();+ if ids.is_empty() {+ self.log.push(format!("pours: {names}: nothing more than a stub in the pour to move"));+ return false;+ }+ self.pour_limit = STUB_RUN;+ let open: usize = nets.iter().map(|&c| self.open_pieces(c)).sum();+ let saved: Vec<Route> = ids.iter().filter_map(|&i| self.unplace(i)).collect();+ let others: HashSet<u32> = self.routes.keys().copied().collect();+ self.pour_strict.extend(nets.iter().copied());+ let mut failed = 0;+ for r in &saved {+ if !self.try_route(&r.task) {+ failed += 1;+ }+ }+ for c in nets {+ self.pour_strict.remove(c);+ }+ self.pour_limit = POUR_RUN;+ let now_open: usize = nets.iter().map(|&c| self.open_pieces(c)).sum();+ let after: usize = if failed == 0 && now_open <= open { self.pour_splits().iter().map(|s| s.1).sum() } else { total };+ if after < total {+ self.log.push(format!("pours: moved {names} off the pour, extra pieces {total} to {after}"));+ return true;+ }+ let new: Vec<u32> = self.routes.keys().copied().filter(|i| !others.contains(i)).collect();+ for i in new {+ self.unplace(i);+ }+ for r in saved {+ self.place(r.id, r.task, r.segs, r.vias);+ }+ self.log.push(format!("pours: {names}: put back ({} of {} routes found no way off the pour, joins open {open} to {now_open}, extra pieces {total} to {after})", failed, ids.len()));+ false+ }++ /// Hold a pin's way out, straight off the pad, until its route is laid, on any part whose pitch+ /// makes the straight line that pin's only way out. That is decided from geometry, not from the+ /// part: a track on a neighbour's line can't get past a via on this one when the pitch is under+ /// the via's radius + clearance + half the track. RP-family parts qualify at 0.4 mm, and so does+ /// anything else that tight; the fine-pitch rule in the Troubleshooting doc ("parallel at pin+ /// pitch before calling it blocked") applies to them all, while 4.7's supply rings don't and+ /// aren't here. An earlier route then can't cut across the row and a GND via can't stand in the+ /// lane, and bus lines stagger their turns by themselves. Pins that stub inward to their own fill+ /// have no lane.+ pub(super) fn lay_lanes(&mut self, plan: &Plan) {+ // A new order of battle on a restart: lanes from the last run come off first.+ let old: Vec<usize> = self.lanes.keys().copied().collect();+ for pad in old {+ self.lane_off(pad);+ }+ self.lanes.clear();+ if self.lane_len <= 0.0 {+ return;+ }+ let inward: HashSet<usize> = plan.tasks.iter().filter(|t| t.zone.is_some_and(|z| z != RAIL_TAP)).map(|t| t.a).collect();+ // Where each pin's route is headed, for which way its lane's neighbours should stair.+ let mut goes_to: HashMap<usize, V> = HashMap::new();+ for t in plan.tasks.iter().filter(|t| t.a != t.b) {+ goes_to.entry(t.a).or_insert(self.board.pads[t.b].center.into());+ goes_to.entry(t.b).or_insert(self.board.pads[t.a].center.into());+ }+ let working: HashSet<usize> = plan.tasks.iter().flat_map(|t| [t.a, t.b]).collect();+ let r = self.rules.neck as f64 / 2.0;+ let stair = 0.06 * NM;+ let mut lanes = Vec::new();+ for f in &self.board.footprints {+ if f.min_pitch >= (self.rules.via as f64 / 2.0) + self.rules.clearance as f64 + self.rules.neck as f64 / 2.0 {+ continue;+ }+ let n = f.pads.len().max(1) as f64;+ let cx: f64 = f.pads.iter().map(|&p| self.board.pads[p].center.0 as f64).sum::<f64>() / n;+ let cy: f64 = f.pads.iter().map(|&p| self.board.pads[p].center.1 as f64).sum::<f64>() / n;+ // One row per side: (runs along y, outward sign) -> (position along the row, pad, tip).+ let mut rows: HashMap<(bool, bool), Vec<(f64, usize, V, usize)>> = HashMap::new();+ for &i in &f.pads {+ let p = &self.board.pads[i];+ if !working.contains(&i) || inward.contains(&i) {+ continue;+ }+ let Some(layer) = p.layers.iter().copied().find(|l| self.world.route_layers.contains(l)) else { continue };+ let b = p.shape.bbox();+ let (w, h) = (b.2 - b.0, b.3 - b.1);+ let c: V = p.center.into();+ // The pad's long axis points the way out; a square pad has no such way.+ if w > h * 1.5 {+ let out = c.0 >= cx;+ rows.entry((true, out)).or_default().push((c.1, i, V(if out { b.2 } else { b.0 }, c.1), layer));+ } else if h > w * 1.5 {+ let out = c.1 >= cy;+ rows.entry((false, out)).or_default().push((c.0, i, V(c.0, if out { b.3 } else { b.1 }), layer));+ }+ }+ for ((along_y, out), mut row) in rows {+ row.sort_by(|a, b| a.0.total_cmp(&b.0));+ let turn = |i: usize, at: f64| -> i32 {+ let Some(t) = goes_to.get(&i) else { return 0 };+ let d = if along_y { t.1 } else { t.0 } - at;+ if d.abs() < f.min_pitch { 0 } else if d > 0.0 { 1 } else { -1 }+ };+ // Neighbours turning the same way at 45 degrees need more than the pin pitch between+ // them, so their turns stair by a little each (reference QSPI bus: "each bends further+ // out"). The pin on the inside of the turn gets the shortest lane, or the bus closes.+ let mut k = 0;+ while k < row.len() {+ let s = turn(row[k].1, row[k].0);+ let mut e = k + 1;+ while e < row.len() && s != 0 && turn(row[e].1, row[e].0) == s && row[e].0 - row[e - 1].0 < f.min_pitch * 1.5 {+ e += 1;+ }+ for j in k..e {+ let (_, i, tip, layer) = row[j];+ let steps = if s > 0 { e - 1 - j } else { j - k } as f64;+ let len = if e - k == 1 { self.lane_len } else { (self.lane_len * 0.5 + stair * steps).min(self.lane_len * 2.0) };+ let sign = if out { 1.0 } else { -1.0 };+ // A lane stops short of fixed copper in its way: a part placed right off+ // the pin must stay reachable, its own pad most of all.+ let net = self.board.pads[i].net;+ let mut len = len;+ let mut to = tip;+ while len >= 0.1 * NM {+ to = if along_y { V(tip.0 + sign * len, tip.1) } else { V(tip.0, tip.1 + sign * len) };+ if self.seg_clear(to_p(tip), to_p(to), layer, self.rules.neck, net, NO_NET) {+ break;+ }+ len -= 0.05 * NM;+ }+ if len >= 0.1 * NM {+ lanes.push((i, layer, Shape::Capsule { a: tip, b: to, r }));+ }+ }+ k = e;+ }+ }+ }+ lanes.sort_by_key(|l| l.0);+ for (i, layer, shape) in lanes {+ self.lanes.insert(i, (layer, shape, None));+ self.lane_on(i);+ }+ }++ pub(super) fn lane_off(&mut self, pad: usize) {+ if let Some((layer, _, h)) = self.lanes.get_mut(&pad) {+ if let Some(id) = h.take() {+ self.world.layers[*layer].remove(id);+ }+ }+ }++ pub(super) fn lane_on(&mut self, pad: usize) {+ if !self.lanes.contains_key(&pad) || self.routes.values().any(|r| r.task.a == pad || r.task.b == pad) {+ return;+ }+ let net = self.board.pads[pad].net;+ let (layer, shape, h) = self.lanes.get_mut(&pad).unwrap();+ if h.is_none() {+ *h = Some(self.world.layers[*layer].add(shape.clone(), net, Owner::Lane(pad as u32)));+ }+ }++ fn pieces_of(&self, net: NetId) -> Vec<(NetId, conn::Piece)> {+ let mut extra = Vec::new();+ for r in self.routes.values().filter(|r| r.task.net == net) {+ for s in &r.segs {+ extra.push((net, conn::Piece::Seg { layer: s.layer, a: s.a.into(), b: s.b.into(), r: s.width as f64 / 2.0 }));+ }+ for v in &r.vias {+ extra.push((net, conn::Piece::Via { at: (*v).into(), r: self.rules.via as f64 / 2.0 }));+ }+ }+ extra+ }++ /// A net's islands as the checker sees them: pads, routed copper and fills.+ pub(crate) fn islands(&self, net: NetId) -> Vec<Vec<usize>> {+ let Some((_, pads)) = self.net_info.get(&net) else { return Vec::new() };+ conn::net_groups(self.board, net, &self.pieces_of(net))+ .into_iter()+ .map(|g| g.into_iter().filter(|p| pads.contains(p)).collect::<Vec<_>>())+ .filter(|g: &Vec<usize>| !g.is_empty())+ .collect()+ }++ /// Does the copper already join this task's two pads? Nets of two pads can't be joined by+ /// anything but their own route, so they skip the work.+ pub(super) fn joined(&self, task: &Task) -> bool {+ if task.a == task.b || task.zone.is_some_and(|z| z != RAIL_TAP) {+ return false;+ }+ let Some((_, pads)) = self.net_info.get(&task.net) else { return false };+ if pads.len() < 3 {+ return false;+ }+ self.islands(task.net).iter().any(|g| g.contains(&task.a) && g.contains(&task.b))+ }++ /// What is really open: the failed tasks that still join two islands, plus the joins nothing+ /// asked for. Plane fan-outs keep their own record, a pad either has its via or it hasn't.+ pub(super) fn open_work(&self, failed: Vec<Task>) -> Vec<Task> {+ let mut out: Vec<Task> = failed.iter().filter(|t| t.a == t.b && t.zone.is_none() && t.level == Level::PlaneFanout).cloned().collect();+ let planes = self.board.plane_nets();+ let mut nets: Vec<NetId> = self.net_info.keys().copied().collect();+ nets.sort();+ for net in nets {+ if self.only.as_ref().map(|o| !o.contains(&net)).unwrap_or(false) {+ continue;+ }+ let (level, _) = self.net_info[&net];+ if level == Level::PlaneFanout && planes.contains_key(&net) {+ continue;+ }+ // A rail joins as a rail: whatever is apart taps the rest, by the bus and its stubs,+ // and a fill nothing feeds counts too, since an island is never acceptable (4.3).+ if level == Level::Power {+ out.extend(self.rail_work(net));+ continue;+ }+ let live = self.islands(net);+ if live.len() < 2 {+ continue;+ }+ let mut uf = conn::Uf::new(live.len());+ let at = |p: usize| live.iter().position(|g| g.contains(&p));+ for t in failed.iter().filter(|t| t.net == net && t.a != t.b && t.zone.is_none()) {+ if let (Some(x), Some(y)) = (at(t.a), at(t.b)) {+ if uf.find(x as u32) != uf.find(y as u32) {+ uf.union(x as u32, y as u32);+ out.push(t.clone());+ }+ }+ }+ // Whatever is still apart joins at its nearest pads (Prim over the islands).+ loop {+ let mut best: Option<(f64, usize, usize, usize, usize)> = None;+ for i in 0..live.len() {+ for j in i + 1..live.len() {+ if uf.find(i as u32) == uf.find(j as u32) {+ continue;+ }+ for &a in &live[i] {+ for &b in &live[j] {+ let d = hypot(sub(self.board.pads[a].center.into(), self.board.pads[b].center.into()));+ if best.map(|x| d < x.0).unwrap_or(true) {+ best = Some((d, i, j, a, b));+ }+ }+ }+ }+ }+ let Some((_, i, j, a, b)) = best else { break };+ uf.union(i as u32, j as u32);+ out.push(Task { net, level, a, b, main: false, zone: None });+ }+ }+ // A fill nothing feeds is open too, though no pad says so (4.3: islands, never).+ out.extend(self.unfed_fills());+ out.sort_by_key(|t| t.level);+ out+ }++ /// One tap per piece of a rail still apart: from the pad of each smaller piece nearest the+ /// biggest, then from the nearest pad to each fill with no feed.+ fn rail_work(&self, net: NetId) -> Vec<Task> {+ let comps = self.components(net);+ let mk = |p: usize| Task { net, level: Level::Power, a: p, b: p, main: false, zone: Some(RAIL_TAP) };+ let with_pads: Vec<&crate::post::Comp> = comps.iter().filter(|c| !c.pads.is_empty()).collect();+ let Some(big) = with_pads.iter().max_by_key(|c| c.pads.len() + c.segs.len()) else { return Vec::new() };+ let dist = |a: usize, b: usize| hypot(sub(self.board.pads[a].center.into(), self.board.pads[b].center.into()));+ let mut out = Vec::new();+ for c in with_pads.iter().filter(|c| !std::ptr::eq(**c, *big)) {+ let near = |p: usize| big.pads.iter().map(|&q| dist(p, q)).fold(f64::INFINITY, f64::min);+ if let Some(&p) = c.pads.iter().min_by(|&&x, &&y| near(x).total_cmp(&near(y))) {+ out.push(mk(p));+ }+ }+ for c in comps.iter().filter(|c| c.pads.is_empty() && c.zones.iter().any(|&z| self.world.route_layers.contains(&self.world.zones[z].1))) {+ let z = c.zones[0];+ let poly = &self.world.zones[z].2;+ if poly.is_empty() {+ continue;+ }+ let m = V(poly.iter().map(|p| p.0).sum::<f64>() / poly.len() as f64, poly.iter().map(|p| p.1).sum::<f64>() / poly.len() as f64);+ if let Some(&p) = big.pads.iter().min_by(|&&x, &&y| hypot(sub(self.board.pads[x].center.into(), m)).total_cmp(&hypot(sub(self.board.pads[y].center.into(), m)))) {+ out.push(mk(p));+ }+ }+ out+ }++ /// How many pieces a net is in, for telling whether a move took it apart.+ fn pieces_apart(&self, net: NetId) -> usize {+ match self.net_info.get(&net) {+ Some((Level::Power, _)) => self.rail_work(net).len() + 1,+ Some((Level::PlaneFanout, _)) => 1,+ _ => self.islands(net).len(),+ }+ }++ /// Fullest first, then the fewest holes high in the hierarchy, then the cleanest copper.+ pub(super) fn score(&self, failed: &[Task]) -> (usize, usize, i64) {+ let holes: usize = failed.iter().map(|t| route_tie(t.level)).sum();+ let mut cost = 0.0;+ for r in self.routes.values() {+ for s in &r.segs {+ cost += hypot(sub(s.a.into(), s.b.into()));+ }+ cost += r.vias.len() as f64 * self.rules.cost_via;+ }+ // Of two boards equally full, the one with the bus plan: its copper may be longer, but the plain+ // go exists to close holes, not to win on length (4.2 weighs order against distance by hand).+ let plain = if self.plain_go { i64::MAX / 4 } else { 0 };+ (failed.len(), holes, cost as i64 + plain)+ }++ pub(super) fn keep_if_best(&self, failed: &[Task], best: &mut Option<Best>) {+ let score = self.score(failed);+ if best.as_ref().map(|b| score < b.score).unwrap_or(true) {+ *best = Some(Best { score, routes: self.routes.clone(), failed: failed.to_vec(), notes: self.notes.clone() });+ }+ }++ fn undo(&mut self, txn: Txn) {+ for id in txn.added {+ self.rip(id);+ }+ for r in txn.removed.into_iter().rev() {+ self.place(r.id, r.task, r.segs, r.vias);+ }+ }++ /// Who may be moved for this task, at what price. GND vias may always move, they are the+ /// lowest priority copper (4.4). Pairs and Kelvin never move for anything lower (4.2).+ fn prices_for(&self, task: &Task, mode: Mode, txn: &Txn) -> HashMap<u32, f64> {+ let me = (task.a, task.b);+ self.routes+ .values()+ .map(|r| {+ let them = (r.task.a, r.task.b);+ let protected = matches!(r.task.level, Level::Kelvin | Level::Pair) || (r.segs.is_empty() && r.task.a == r.task.b);+ let via = r.task.level == Level::PlaneFanout;+ let lower = r.task.level >= task.level || via;+ let times = self.memory.get(&(me, them)).copied().unwrap_or(0);+ let rc = self.rip_count.get(&them).copied().unwrap_or(0) as f64;+ let closed = protected || (!lower && !mode.escalate) || times >= 6 || txn.locked.contains(&r.id) || txn.banned.contains(&them);+ let base = if via { 0.3 * NM } else { SOFT_PRICE };+ (r.id, if closed { f64::INFINITY } else { base * (1.0 + 0.25 * rc) })+ })+ .collect()+ }++ /// A soft search with a tight budget, a coarser lattice and a greedier heuristic. Free copper+ /// is flooded first at the usual weights, so an unbudgeted exact one spends everything before+ /// paying to cross anyone: measured on piprobe_v13 as 800k expansions, 8 s and no answer for a+ /// pin 0.4 mm from its way out.+ fn soft_path(&mut self, task: &Task, mode: Mode, txn: &Txn) -> Option<(Path, Profile)> {+ let prices = self.prices_for(task, mode, txn);+ let price = |c: u32| prices.get(&c).copied().unwrap_or(f64::INFINITY);+ let sources = [self.pad_terminal(task.a)];+ let targets = self.targets_of(task);+ let (_, n) = self.widths_for(task);+ let mut rules = self.rules.clone();+ rules.node_limit = SOFT_NODES * if self.deep { 6 } else { 1 };+ rules.search_weight = SOFT_WEIGHT;+ // Non-priority nets and rails go for the via first: a rail's way is along its bus.+ let bias = self.bus_of(task);+ let ladder: &[(u32, f64)] = if task.level == Level::Other || bias.is_some() { &[(2, 3.0), (2, 10.0), (4, 40.0)] } else { &[(0, 3.0), (2, 3.0), (2, 10.0), (4, 40.0)] };+ for &(vias, margin) in ladder {+ let pf = Profile { width: n, neck: n, clearance: self.rules.clearance, target_gap: self.rules.clearance, max_vias: vias, margin: margin * NM, layer_bias: bias };+ let q = Query { net: task.net, also: None, sources: &sources, targets: &targets, profile: pf, rules: &rules, soft: Some(&price), via_in: None, history: Some(&self.history), free: &[], arrive: None };+ self.searches += 1;+ if let Some(path) = search(&self.world, &q) {+ self.nodes += path.nodes as u64;+ return Some((path, pf));+ }+ if self.trace {+ eprintln!(" soft {} {} -> {} vias {} margin {}: {}", self.board.net_name(task.net), self.board.pad_name(task.a), self.board.pad_name(task.b), vias, margin, if RAN_OUT.with(|c| c.get()) { "ran out" } else { "walled in" });+ }+ }+ None+ }++ /// A legal route for this task with a budget fit for a repair, not a first attempt. Power goes to+ /// the bus first, like any other rail task (4.2): a repair is no reason to wrap a chip on the pad+ /// layer. It may then join anywhere on the other island, a pad, a via or a bend (a branch, not a+ /// new run).+ pub(super) fn route_hard(&mut self, task: &Task) -> Option<u32> {+ if task.level == Level::Power && task.zone.map(|z| z == RAIL_TAP).unwrap_or(true) {+ let rail = Task { zone: Some(RAIL_TAP), main: true, ..task.clone() };+ // A rail the power stage planned (it has a current) is searched as often as repair asks:+ // capped here, an benchmark C +5V feed came out thin and failed its fixture (0.26% drop against+ // 0.25%). The cap is for rails with no spec, where the ladder is all there is.+ // (2026-11-10: the memo and the cap of post.rs `rail_route_guarded` were also tried here for+ // rails without a spec. They saved nothing on the board they were meant for, esc-g431, and+ // cost joins on two others whose repair needs the repeats: benchmark C without its+ // spec 300 -> 297, a current-monitor board 521 -> 520. Repair searches unguarded.)+ let made = self.rail_route(&rail);+ if let Some(id) = made {+ return Some(id);+ }+ }+ let mut rules = self.rules.clone();+ rules.node_limit = SOFT_NODES * if self.deep { 6 } else { 1 };+ let (sources, targets) = if task.level == Level::Power && task.a != task.b { self.island_terminals(task) } else { (vec![self.pad_terminal(task.a)], self.targets_of(task)) };+ // At the neck: this is about finding room. A no-via way round is weighed against a via.+ let (w, n) = self.widths_for(task);+ let c = self.rules.clearance_for(task.net, task.net);+ let ladder: &[(u32, f64)] = if task.level == Level::Other { &[(2, 3.0), (2, 8.0), (4, 40.0)] } else { &[(0, 3.0), (2, 3.0), (0, 8.0), (2, 8.0), (4, 40.0)] };+ let profiles: Vec<Profile> = ladder.iter().map(|&(vias, margin)| Profile { width: n, neck: n, clearance: c, target_gap: c, max_vias: vias, margin: margin * NM, layer_bias: None }).collect();+ let (path, _) = self.weigh(task, &sources, &targets, &profiles, &rules)?;+ let pts = self.finish_path(&path, task.net, NO_NET, n);+ let id = self.commit(task, &pts, w, n, NO_NET);+ // A join onto an island's copper is only worth keeping if it really joined.+ if task.level == Level::Power && task.a != task.b && (!self.joined(task) || !self.joints_ok(id)) {+ self.rip(id);+ return None;+ }+ Some(id)+ }++ /// Does this route meet its net's other copper at an obtuse angle? A branch joins as a "Y",+ /// never square or acute (4.2).+ fn joints_ok(&self, id: u32) -> bool {+ let Some(r) = self.routes.get(&id) else { return true };+ for s in &r.segs {+ for (p, far) in [(s.a, s.b), (s.b, s.a)] {+ for o in self.routes.values().filter(|o| o.id != id && o.task.net == r.task.net) {+ for t in o.segs.iter().filter(|t| t.layer == s.layer) {+ let other = if t.a == p { Some(t.b) } else if t.b == p { Some(t.a) } else { None };+ if let Some(q) = other {+ if turn_of(q, p, far).map(|x| x > 1).unwrap_or(false) {+ return false;+ }+ }+ }+ }+ }+ }+ true+ }++ fn island_terminals(&self, task: &Task) -> (Vec<Terminal>, Vec<Terminal>) {+ let live = self.islands(task.net);+ let side = |pad: usize| -> (Vec<Terminal>, Vec<Terminal>) {+ let Some(g) = live.iter().find(|g| g.contains(&pad)) else { return (vec![self.pad_terminal(pad)], Vec::new()) };+ let mut points: Vec<Terminal> = g.iter().map(|&p| self.pad_terminal(p)).collect();+ let mut fills: Vec<Terminal> = Vec::new();+ for r in self.routes.values().filter(|r| r.task.net == task.net && (g.contains(&r.task.a) || g.contains(&r.task.b))) {+ for s in &r.segs {+ points.push(Terminal::Point { p: s.b, layers: vec![s.layer] });+ }+ for v in &r.vias {+ points.push(Terminal::Point { p: *v, layers: self.world.route_layers.clone() });+ }+ // The fill this island stubs into is part of it: a via down into the ring is how+ // his caps get their +1V1 (4.7).+ if let Some(z) = r.task.zone.filter(|&z| z != RAIL_TAP) {+ let (_, layer, poly) = &self.world.zones[z];+ fills.push(Terminal::Zone { layer: *layer, poly: poly.clone() });+ }+ }+ (points, fills)+ };+ let (a, fa) = side(task.a);+ let (mut b, fb) = side(task.b);+ // A fill can only be a target (a source has no one point to start from).+ if fb.is_empty() && !fa.is_empty() {+ b.extend(fa);+ return (b, a);+ }+ b.extend(fb);+ (a, b)+ }++ /// Move a route only as much as the new copper needs: keep what is still legal and search from+ /// the break to the old route. Often what comes out is a shove, since the detour hugs the+ /// copper that displaced it.+ fn splice(&mut self, old: &Route, txn: &mut Txn) -> bool {+ // A tap has no fixed far end to keep: its target is whatever of the rail is nearest now.+ if old.task.a == old.task.b || old.segs.is_empty() {+ return false;+ }+ let task = &old.task;+ let mut pts: Vec<(P, usize)> = Vec::new();+ for s in &old.segs {+ if let Some(&(q, _)) = pts.last() {+ // Tidy-up can drop a piece hidden in the net's own fill: no chain to keep.+ if q != s.a {+ return false;+ }+ }+ if pts.last() != Some(&(s.a, s.layer)) {+ pts.push((s.a, s.layer));+ }+ pts.push((s.b, s.layer));+ }+ // A route that joined an island elsewhere has no pad to keep.+ if pts[0].0 != snap(self.board.pads[task.a].center) {+ return false;+ }+ let (w, n) = self.widths_for(task);+ let via_q = |net| Query { net, also: None, sources: &[], targets: &[], profile: Profile { width: 0, neck: 0, clearance: self.rules.clearance, target_gap: 0, max_vias: 0, margin: 0.0, layer_bias: None }, rules: &self.rules, soft: None, via_in: None, history: None, free: &[], arrive: None };+ let mut first = None;+ let mut last = 0;+ for (i, x) in pts.windows(2).enumerate() {+ let bad = if x[0].1 == x[1].1 {+ !self.seg_clear(x[0].0, x[1].0, x[0].1, n, task.net, NO_NET)+ } else {+ let (mut b, mut c) = (Vec::new(), 0.0);+ !self.world.via_ok(x[0].0, &via_q(task.net), None, &mut b, &mut c)+ };+ if bad {+ first.get_or_insert(i);+ last = i;+ }+ }+ let Some(first) = first else {+ // The path it was ripped for bent away in tidy-up: nothing to change.+ let id = self.commit(task, &pts, w, n, NO_NET);+ txn.added.push(id);+ return true;+ };+ let mut rules = self.rules.clone();+ rules.node_limit = SOFT_NODES / 2;+ for grow in [0usize, 1, 3] {+ let i = first.saturating_sub(grow);+ let j = (last + 1 + grow).min(pts.len() - 1);+ let term = |k: usize| -> Terminal {+ if k == 0 {+ self.pad_terminal(task.a)+ } else if k == pts.len() - 1 && task.zone.is_none() {+ self.pad_terminal(task.b)+ } else {+ Terminal::Point { p: pts[k].0, layers: vec![pts[k].1] }+ }+ };+ let sources = [term(i)];+ let targets = [term(j)];+ let arrive = if j + 1 < pts.len() && pts[j].1 == pts[j + 1].1 { dir_of(pts[j].0, pts[j + 1].0) } else { None };+ for (vias, margin) in [(0u32, 1.5), (2, 3.0)] {+ let pf = Profile { width: n, neck: n, clearance: self.rules.clearance, target_gap: self.rules.clearance, max_vias: vias, margin: margin * NM, layer_bias: None };+ let q = Query { net: task.net, also: None, sources: &sources, targets: &targets, profile: pf, rules: &rules, soft: None, via_in: None, history: Some(&self.history), free: &[], arrive };+ self.searches += 1;+ let Some(path) = search(&self.world, &q) else { continue };+ self.nodes += path.nodes as u64;+ let mut new: Vec<(P, usize)> = pts[..i].to_vec();+ new.extend(path.pts.iter().copied());+ new.extend(pts[j + 1..].iter().copied());+ simplify(&mut new);+ // The old part and the detour must meet at an obtuse or square angle: a square one+ // gets its chamfer below, an acute one isn't worth keeping.+ let acute = new.windows(3).any(|x| x[0].1 == x[1].1 && x[1].1 == x[2].1 && turn_of(x[0].0, x[1].0, x[2].0).map(|t| t > 2).unwrap_or(false));+ // The kept part's vias are off the board while the detour is found, so the search+ // can't keep clear of them: benchmark C got two holes of one net 0.002 mm apart this way.+ let holes: Vec<P> = new.windows(2).filter(|x| x[0].1 != x[1].1).map(|x| x[0].0).collect();+ let min = (self.rules.drill + self.rules.hole_hole) as f64;+ let crowded = holes.iter().enumerate().any(|(k, a)| holes[k + 1..].iter().any(|b| hypot(sub((*a).into(), (*b).into())) < min));+ if acute || crowded {+ continue;+ }+ let whole = Path { pts: new, ..Default::default() };+ let tidy = self.finish_path(&whole, task.net, NO_NET, n);+ let id = self.commit(task, &tidy, w, n, NO_NET);+ txn.added.push(id);+ self.splices += 1;+ return true;+ }+ }+ false+ }++ /// Lay this task again, moving what is in the way. True only if it holds, every net it touched+ /// stays whole and nothing was given up; otherwise the board is as it was.+ pub(crate) fn move_for_width(&mut self, task: &Task, open: usize) -> bool {+ let mode = Mode { escalate: true, swap: false };+ let mut txn = Txn { fuel: 14, ..Default::default() };+ let ok = self.displace(task, mode, 3, &mut txn) && self.still_whole(&mut txn, task) && txn.dropped.is_empty() && self.open_pieces(task.net) <= open;+ if !ok {+ self.undo(txn);+ }+ ok+ }++ /// Find this task a place, moving whoever is in the way, who then do the same, `depth` deep.+ fn displace(&mut self, task: &Task, mode: Mode, depth: u32, txn: &mut Txn) -> bool {+ if self.over_time() || txn.fuel <= 0 {+ return false;+ }+ txn.fuel -= 1;+ if task.a == task.b && task.zone.is_none() {+ return self.displace_fanout(task, mode, depth, txn);+ }+ if self.joined(task) || (task.zone == Some(RAIL_TAP) && self.rail_targets(task, 0.0).is_empty()) {+ return true;+ }+ if let Some(id) = self.route_hard(task) {+ txn.added.push(id);+ txn.locked.insert(id);+ return true;+ }+ if depth == 0 {+ return false;+ }+ let Some((path, pf)) = self.soft_path(task, mode, txn) else {+ let why = if RAN_OUT.with(|c| c.get()) { "the search ran out of budget before reaching it, even allowed to move routes" } else { "no path even through the routes it may move: pads, fills or protected copper wall it in" };+ self.why.insert((task.a, task.b), why.to_string());+ return false;+ };+ let victims = self.take_victims(task, &path.blockers, txn);+ if self.trace {+ eprintln!(" move for {} {} -> {}: {:?}", self.board.net_name(task.net), self.board.pad_name(task.a), self.board.pad_name(task.b), victims.iter().map(|r| self.board.net_name(r.task.net).to_string()).collect::<Vec<_>>());+ }+ let (w, _) = self.widths_for(task);+ // The soft lattice has no lines along movable copper, so its path grazes more routes than+ // it must. With them up, the exact lattice finds the hugging path, and any victim it no+ // longer touches goes straight back (splice sees nothing wrong with it).+ let exact = self.route_hard(task);+ let id = match exact {+ Some(id) => id,+ None => {+ let pts = self.finish_path(&path, task.net, NO_NET, pf.neck);+ // A victim's lane came back when it came up, and the soft path couldn't see it:+ // laying that path would box the victim's own pin in.+ let legal = pts.windows(2).all(|x| x[0].1 != x[1].1 || self.seg_clear(x[0].0, x[1].0, x[0].1, pf.neck, task.net, NO_NET));+ if !legal {+ if let Some(v) = victims.first() {+ txn.banned.insert((v.task.a, v.task.b));+ }+ self.why.insert((task.a, task.b), "the way through crosses another pin's way out".to_string());+ return false;+ }+ self.commit(task, &pts, w, pf.neck, NO_NET)+ }+ };+ txn.added.push(id);+ txn.locked.insert(id);+ self.relocate(task, victims, mode, depth, txn)+ }++ fn take_victims(&mut self, task: &Task, ids: &[u32], txn: &mut Txn) -> Vec<Route> {+ let me = (task.a, task.b);+ let mut out = Vec::new();+ for b in ids {+ let Some(r) = self.routes.get(b).cloned() else { continue };+ *self.memory.entry((me, (r.task.a, r.task.b))).or_insert(0) += 1;+ if !txn.apart.contains_key(&r.task.net) {+ let n = self.pieces_apart(r.task.net);+ txn.apart.insert(r.task.net, n);+ }+ self.rip(*b);+ self.rips += 1;+ txn.removed.push(r.clone());+ out.push(r);+ }+ out.sort_by_key(|r| r.task.level);+ out+ }++ fn relocate(&mut self, task: &Task, victims: Vec<Route>, mode: Mode, depth: u32, txn: &mut Txn) -> bool {+ for r in victims {+ let before = txn.added.len();+ if self.splice(&r, txn) {+ let added: Vec<u32> = txn.added[before..].to_vec();+ txn.locked.extend(added);+ continue;+ }+ if self.displace(&r.task, mode, depth - 1, txn) {+ continue;+ }+ if mode.swap && r.task.level >= task.level && txn.dropped.is_empty() {+ txn.dropped.push(r.task.clone());+ continue;+ }+ self.why.insert((task.a, task.b), format!("its way is held by {} ({} to {}), which found no other place", self.board.net_name(r.task.net), self.board.pad_name(r.task.a), self.board.pad_name(r.task.b)));+ txn.banned.insert((r.task.a, r.task.b));+ return false;+ }+ true+ }++ /// A pad with no via site in a straight line: a bent stub to the nearest site that has one, and+ /// failing that, a stub to the net's own copper that already reaches the plane, such as a USB+ /// connector's shield pad (reference benchmark B joins J1's GND pins to the shield this way).+ fn fan_out_search(&mut self, task: &Task) -> Option<u32> {+ let p = &self.board.pads[task.a];+ let (center, net) = (p.center, p.net);+ let layer = p.layers.iter().copied().find(|l| self.world.route_layers.contains(l))?;+ let (n, c) = (self.rules.neck, self.rules.clearance);+ let vr = self.rules.via as f64 / 2.0;+ let pad_gap = self.rules.pad_via_gap as f64;+ let shape = p.shape.clone();+ let mut sites: Vec<(f64, P)> = Vec::new();+ let step = 0.1 * NM;+ let reach = (2.5 * NM / step) as i64;+ for j in -reach..=reach {+ for i in -reach..=reach {+ let v = snap((center.0 + (i as f64 * step) as i64, center.1 + (j as f64 * step) as i64));+ let d = hypot(sub(v.into(), center.into()));+ if d > 2.5 * NM || shape.dist_pt(v.into()) < vr + pad_gap {+ continue;+ }+ let q = Query { net, also: None, sources: &[], targets: &[], profile: Profile { width: 0, neck: 0, clearance: c, target_gap: 0, max_vias: 0, margin: 0.0, layer_bias: None }, rules: &self.rules, soft: None, via_in: None, history: None, free: &[], arrive: None };+ let (mut b, mut sc) = (Vec::new(), 0.0);+ if self.world.via_ok(v, &q, None, &mut b, &mut sc) {+ sites.push((d, v));+ }+ }+ }+ sites.sort_by(|a, b| a.0.total_cmp(&b.0));+ let mut rules = self.rules.clone();+ rules.node_limit = SOFT_NODES / 4;+ let pf = Profile { width: n, neck: n, clearance: c, target_gap: c, max_vias: 0, margin: 2.0 * NM, layer_bias: None };+ let sources = [self.pad_terminal(task.a)];+ // Sites close together fail together: try a spread of them.+ let mut tried: Vec<P> = Vec::new();+ for (_, v) in sites {+ if tried.len() >= 8 {+ break;+ }+ if tried.iter().any(|t| hypot(sub((*t).into(), v.into())) < 0.3 * NM) {+ continue;+ }+ tried.push(v);+ let targets = [Terminal::Point { p: v, layers: vec![layer] }];+ let q = Query { net, also: None, sources: &sources, targets: &targets, profile: pf, rules: &rules, soft: None, via_in: None, history: None, free: &[], arrive: None };+ self.searches += 1;+ let Some(path) = search(&self.world, &q) else { continue };+ self.nodes += path.nodes as u64;+ let pts = self.finish_path(&path, net, NO_NET, n);+ let id = self.fresh_id();+ let segs: Vec<Seg> = pts.windows(2).filter(|w| w[0].1 == w[1].1 && w[0].0 != w[1].0).map(|w| Seg { a: w[0].0, b: w[1].0, layer: w[0].1, width: n }).collect();+ self.place(id, task.clone(), segs, vec![v]);+ return Some(id);+ }+ // Own copper already on the plane: through-hole pads and vias of this net.+ let mut targets: Vec<Terminal> = Vec::new();+ let planes = self.board.plane_nets();+ let inner = planes.get(&net).cloned().unwrap_or_default();+ let own_fp = self.board.pads[task.a].fp;+ for (i, q) in self.board.pads.iter().enumerate() {+ // Through-hole pads reach the plane; a pin beside its own part's exposed pad joins that+ // (benchmark C's U6.5 and U8.5: no via site in the row, and the pad is right there).+ let exposed = q.fp == own_fp && q.size.0 >= 1.0 * NM && q.size.1 >= 1.0 * NM;+ if i != task.a && q.net == net && (exposed || q.layers.iter().any(|l| inner.contains(l))) && q.layers.contains(&layer) && hypot(sub(q.center.into(), center.into())) < 6.0 * NM {+ targets.push(self.pad_terminal(i));+ }+ }+ for r in self.routes.values().filter(|r| r.task.net == net) {+ for v in r.vias.iter().filter(|v| hypot(sub((**v).into(), center.into())) < 6.0 * NM) {+ targets.push(Terminal::Point { p: *v, layers: vec![layer] });+ }+ }+ if targets.is_empty() {+ return None;+ }+ let pf = Profile { margin: 3.0 * NM, ..pf };+ let q = Query { net, also: None, sources: &sources, targets: &targets, profile: pf, rules: &rules, soft: None, via_in: None, history: None, free: &[], arrive: None };+ self.searches += 1;+ let path = search(&self.world, &q)?;+ let pts = self.finish_path(&path, net, NO_NET, n);+ let id = self.fresh_id();+ let segs: Vec<Seg> = pts.windows(2).filter(|w| w[0].1 == w[1].1 && w[0].0 != w[1].0).map(|w| Seg { a: w[0].0, b: w[1].0, layer: w[0].1, width: n }).collect();+ self.notes.push(format!("{}: joined to the net's own plane copper, no via site of its own (4.4)", self.board.pad_name(task.a)));+ self.place(id, task.clone(), segs, Vec::new());+ Some(id)+ }++ fn displace_fanout(&mut self, task: &Task, mode: Mode, depth: u32, txn: &mut Txn) -> bool {+ let before = self.next_id;+ if self.fan_out_pad(task.a, task) {+ for id in before..self.next_id {+ txn.added.push(id);+ txn.locked.insert(id);+ }+ return true;+ }+ if let Some(id) = self.fan_out_search(task) {+ txn.added.push(id);+ txn.locked.insert(id);+ return true;+ }+ if depth == 0 {+ return false;+ }+ let prices = self.prices_for(task, mode, txn);+ let Some((v, layer, blockers)) = self.fan_out_site(task.a, &prices) else {+ self.why.insert((task.a, task.b), "no via site within 2 mm, even moving the routes it may move".to_string());+ return false;+ };+ let victims = self.take_victims(task, &blockers, txn);+ let id = self.next_id;+ self.next_id += 1;+ self.place(id, task.clone(), vec![Seg { a: snap(self.board.pads[task.a].center), b: v, layer, width: self.rules.neck }], vec![v]);+ txn.added.push(id);+ txn.locked.insert(id);+ self.relocate(task, victims, mode, depth, txn)+ }++ /// No net a repair touched may end up in more pieces than it was in, bar a swap's one hole.+ fn still_whole(&mut self, txn: &mut Txn, task: &Task) -> bool {+ if self.loose {+ return true;+ }+ let mut ok = true;+ for (net, was) in txn.apart.clone() {+ let spare = txn.dropped.iter().filter(|t| t.net == net).count();+ if self.pieces_apart(net) > was + spare {+ ok = false;+ let hit: Vec<(usize, usize)> = txn.removed.iter().filter(|r| r.task.net == net).map(|r| (r.task.a, r.task.b)).collect();+ txn.banned.extend(hit);+ self.why.insert((task.a, task.b), format!("moving {} for it would take that net apart", self.board.net_name(net)));+ }+ }+ ok+ }++ /// One task's repair, whole or not at all. A victim that couldn't move is banned for the next+ /// try, which so has to find another way; the same failure twice ends it.+ pub(super) fn repair_one(&mut self, task: &Task, mode: Mode) -> Repair {+ let mut banned = HashSet::new();+ for _ in 0..REPAIR_TRIES {+ let mut txn = Txn { banned: banned.clone(), fuel: 14, ..Default::default() };+ let ok = self.displace(task, mode, 3, &mut txn) && self.still_whole(&mut txn, task);+ if ok {+ return match txn.dropped.pop() {+ Some(t) => Repair::Swapped(t),+ None => Repair::Fixed,+ };+ }+ let learned = txn.banned.len() > banned.len();+ banned = std::mem::take(&mut txn.banned);+ self.undo(txn);+ if !learned || self.over_time() {+ break;+ }+ }+ Repair::Failed+ }++ /// Part 3 step 8, the move a plain re-search can't make: this connection has a good way but someone+ /// sits on it. Take it, then let them find another place. Kept only if the board's cost falls and+ /// no net comes apart. The caller has already lifted the route; on failure it goes back.+ /// `own_pieces`: how apart the route's own net was before it was lifted.+ pub(crate) fn swap_for(&mut self, old: &Route, old_cost: f64, own_pieces: usize) -> bool {+ let task = old.task.clone();+ let mode = Mode { escalate: false, swap: false };+ let mut txn = Txn { fuel: 8, ..Default::default() };+ let Some((path, _)) = self.soft_path(&task, mode, &txn) else { return false };+ if path.blockers.is_empty() || path.blockers.len() > 3 {+ return false;+ }+ if path.blockers.iter().any(|b| self.routes.get(b).map(|r| self.in_bus_row(&r.task)).unwrap_or(false)) {+ return false;+ }+ let nets: Vec<NetId> = {+ let mut v: Vec<NetId> = path.blockers.iter().filter_map(|b| self.routes.get(b).map(|r| r.task.net)).collect();+ v.push(task.net);+ v.sort_unstable();+ v.dedup();+ v+ };+ let pieces: usize = nets.iter().map(|&n| if n == task.net { own_pieces } else { self.open_pieces(n) }).sum();+ let before: f64 = path.blockers.iter().filter_map(|b| self.routes.get(b)).map(|r| self.route_cost(r)).sum::<f64>() + old_cost;+ let victims = self.take_victims(&task, &path.blockers, &mut txn);+ let done = match self.route_hard(&task) {+ Some(id) => {+ txn.added.push(id);+ txn.locked.insert(id);+ self.relocate(&task, victims, mode, 1, &mut txn)+ }+ None => false,+ };+ let after: f64 = txn.added.iter().filter_map(|id| self.routes.get(id)).map(|r| self.route_cost(r)).sum();+ let whole = nets.iter().map(|&n| self.open_pieces(n)).sum::<usize>() <= pieces;+ if self.trace {+ eprintln!(" swap {} {}: blockers {:?} placed {done} whole {whole} cost {:.1} -> {:.1}", self.board.net_name(task.net), self.board.pad_name(task.a), nets.iter().map(|&n| self.board.net_name(n)).collect::<Vec<_>>(), before / NM, after / NM);+ }+ if done && whole && after + 0.05 * NM < before {+ return true;+ }+ self.undo(txn);+ false+ }++ /// A 90-degree corner that tidy-up couldn't chamfer for lack of room breaks 4.2 outright: go+ /// around again, with square corners priced out of the search. Stays as it was if that fails.+ pub(super) fn fix_square_corners(&mut self) {+ let square = |r: &Route| -> bool {+ r.segs.windows(2).any(|s| s[0].layer == s[1].layer && s[0].b == s[1].a && turn_of(s[0].a, s[0].b, s[1].b) == Some(2))+ };+ // A fan-out stub has no second pad to route to: searching would lay nothing and lose its via.+ let ids: Vec<u32> = self.routes.values().filter(|r| square(r) && !matches!(r.task.level, Level::Pair | Level::Kelvin) && !(r.task.a == r.task.b && r.task.zone.is_none())).map(|r| r.id).collect();+ let mut rules = self.rules.clone();+ rules.cost_bend90 = 30.0 * NM;+ rules.node_limit = SOFT_NODES * if self.deep { 6 } else { 1 };+ for id in ids {+ let Some(old) = self.routes.get(&id).cloned() else { continue };+ self.rip(id);+ let sources = [self.pad_terminal(old.task.a)];+ let targets = self.targets_of(&old.task);+ let (w, n) = self.widths_for(&old.task);+ let mut placed = false;+ for (vias, margin) in [(old.vias.len() as u32, 3.0), (old.vias.len() as u32 + 2, 8.0)] {+ let pf = Profile { width: n, neck: n, clearance: self.rules.clearance, target_gap: self.rules.clearance, max_vias: vias, margin: margin * NM, layer_bias: None };+ let q = Query { net: old.task.net, also: None, sources: &sources, targets: &targets, profile: pf, rules: &rules, soft: None, via_in: None, history: None, free: &[], arrive: None };+ let Some(path) = search(&self.world, &q) else { continue };+ let pts = self.finish_path(&path, old.task.net, NO_NET, n);+ let new = self.commit(&old.task, &pts, w, n, NO_NET);+ if self.routes.get(&new).map(square).unwrap_or(true) {+ self.rip(new);+ continue;+ }+ placed = true;+ break;+ }+ if !placed {+ self.place(old.id, old.task, old.segs, old.vias);+ }+ }+ }++ /// The teardrop KiCad will draw where this route leaves a pad, as a polygon: from the pad's edge+ /// to a point half the pad's size along the track (at most 1 mm), and as wide as the pad where it+ /// meets it. Read from the project's teardrop settings as the reference boards carry them, and checked+ /// against the one KiCad reported on piprobe_v13 (its side point, 173.26 by 67.75, is this+ /// polygon's). Follows the track round one bend, as KiCad's two-track teardrops do.+ fn teardrop_at(&self, pad: usize, chain: &[(P, i64)]) -> Option<(usize, Vec<V>)> {+ let p = &self.board.pads[pad];+ let b = p.shape.bbox();+ let size = (b.2 - b.0).min(b.3 - b.1);+ let (first, w) = chain.first().copied()?;+ if w as f64 >= 0.9 * size || chain.len() < 2 {+ return None;+ }+ let _ = first;+ // Walk out of the pad.+ let mut exit = None;+ for k in 0..chain.len() - 1 {+ let (u, v): (V, V) = (chain[k].0.into(), chain[k + 1].0.into());+ if p.shape.dist_pt(v) > 0.0 {+ let l = hypot(sub(v, u)).max(1.0);+ let mut t = 0.0;+ while t < l {+ let q = V(u.0 + (v.0 - u.0) * t / l, u.1 + (v.1 - u.1) * t / l);+ if p.shape.dist_pt(q) > 0.0 {+ break;+ }+ t += 0.01 * NM;+ }+ exit = Some((k, V(u.0 + (v.0 - u.0) * t / l, u.1 + (v.1 - u.1) * t / l), V((v.0 - u.0) / l, (v.1 - u.1) / l)));+ break;+ }+ }+ let (k, out, dir) = exit?;+ let mut left = (0.5 * size).min(1.0 * NM);+ let mut at = out;+ let mut along = dir;+ for j in k..chain.len() - 1 {+ let v: V = chain[j + 1].0.into();+ let l = hypot(sub(v, at));+ if l >= left {+ at = V(at.0 + along.0 * left, at.1 + along.1 * left);+ break;+ }+ left -= l;+ at = v;+ if j + 2 < chain.len() {+ let n: V = chain[j + 2].0.into();+ let m = hypot(sub(n, v)).max(1.0);+ along = V((n.0 - v.0) / m, (n.1 - v.1) / m);+ }+ }+ let half = w as f64 / 2.0;+ let h = (size / 2.0).min(1.0 * NM);+ let (na, nd) = (V(-along.1, along.0), V(-dir.1, dir.0));+ let clamp = |q: V| V(q.0.clamp(b.0, b.2), q.1.clamp(b.1, b.3));+ Some((0, vec![+ V(at.0 + na.0 * half, at.1 + na.1 * half),+ V(at.0 - na.0 * half, at.1 - na.1 * half),+ clamp(V(out.0 - nd.0 * h, out.1 - nd.1 * h)),+ clamp(V(out.0 + nd.0 * h, out.1 + nd.1 * h)),+ ]))+ }++ /// Who a route's future teardrops would bridge: (pad on the route, foreign pad, layer).+ fn teardrop_hits(&self, r: &Route) -> Vec<(usize, usize, usize)> {+ let mut out = Vec::new();+ let by_layer: Vec<usize> = { let mut v: Vec<usize> = r.segs.iter().map(|s| s.layer).collect(); v.dedup(); v };+ for layer in by_layer {+ let run: Vec<&Seg> = r.segs.iter().filter(|s| s.layer == layer).collect();+ if run.is_empty() {+ continue;+ }+ let mut fwd: Vec<(P, i64)> = vec![(run[0].a, run[0].width)];+ fwd.extend(run.iter().map(|s| (s.b, s.width)));+ let back: Vec<(P, i64)> = fwd.iter().rev().copied().collect();+ for chain in [fwd, back] {+ let start: V = chain[0].0.into();+ let pad = self.board.pads.iter().position(|p| p.net == r.task.net && p.layers.contains(&layer) && p.shape.dist_pt(start) <= 1.0);+ let Some(pad) = pad else { continue };+ if let Some((_, poly)) = self.teardrop_at(pad, &chain) {+ if let Some((who, _)) = self.world.mask.poly_near(r.task.net, layer, &poly, 0.03 * NM) {+ out.push((pad, who, layer));+ }+ }+ }+ }+ out+ }++ /// KiCad draws teardrops at refill and its DRC then judges them, so a track that clears a pad's+ /// mask opening can still have a teardrop that bridges it. Find those now and route them again+ /// wide of that opening; a route that can't go wide stays and KiCad will say so.+ pub(super) fn fix_teardrops(&mut self) {+ // Benchmark B's project ignores mask bridges, so this would chase what nobody judges.+ if self.rules.severity("solder_mask_bridge") != "error" {+ return;+ }+ // Not a fan-out stub: it has no second pad to route to, and "routing" one would lay nothing.+ let ids: Vec<u32> = self.routes.values().filter(|r| !r.segs.is_empty() && !matches!(r.task.level, Level::Pair | Level::Kelvin) && !(r.task.a == r.task.b && r.task.zone.is_none())).map(|r| r.id).collect();+ for id in ids {+ let Some(old) = self.routes.get(&id).cloned() else { continue };+ let hits = self.teardrop_hits(&old);+ if hits.is_empty() {+ continue;+ }+ self.unplace(id);+ let mut done = false;+ for wide in [0.4, 0.25, 0.15] {+ let mut held = Vec::new();+ for &(_, who, layer) in &hits {+ let b = self.board.pads[who].shape.bbox();+ let m = wide * NM;+ let s = Shape::Poly { pts: vec![V(b.0 - m, b.1 - m), V(b.2 + m, b.1 - m), V(b.2 + m, b.3 + m), V(b.0 - m, b.3 + m)], r: 0.0 };+ held.push((layer, self.world.layers[layer].add(s, NO_NET, Owner::Keepout)));+ }+ let before = self.next_id;+ let placed = if old.task.zone == Some(RAIL_TAP) { self.try_rail(&old.task) } else { self.try_route_inner(&old.task) };+ for (l, h) in held {+ self.world.layers[l].remove(h);+ }+ if placed && self.next_id > before {+ let new = self.next_id - 1;+ let ok = self.routes.get(&new).map(|r| self.teardrop_hits(r).is_empty()).unwrap_or(false) && self.pieces_apart(old.task.net) <= 1;+ if ok {+ done = true;+ break;+ }+ self.unplace(new);+ }+ }+ if done {+ self.log.push(format!("teardrop: {} moved clear of a pad's mask opening", self.board.net_name(old.task.net)));+ } else {+ let task = old.task.clone();+ self.place(old.id, task, old.segs.clone(), old.vias.clone());+ self.notes.push(format!("{}: KiCad's teardrop at {} may bridge {}'s mask opening; no other way in was found", self.board.net_name(old.task.net), self.board.pad_name(hits[0].0), self.board.pad_name(hits[0].1)));+ }+ }+ }++ /// Plane-net pads that the plan left to an outer-layer fill: that fill is the old board's, and+ /// KiCad's refill around the new tracks can cut a pad off it (benchmark C U6.5, U8.5; benchmark B+ /// U3.10). Each gets its own via where one stands (4.2: individual vias for each passive). No+ /// site is no failure here: the fill may well hold, and KiCad's DRC is the judge of that.+ pub(super) fn stitch_fill_pads(&mut self) {+ let planes = self.board.plane_nets();+ let mut count = 0;+ let mut pads: Vec<usize> = self.net_info.iter().filter(|(n, (l, _))| *l == Level::PlaneFanout && planes.contains_key(n)).flat_map(|(_, (_, p))| p.iter().copied()).collect();+ pads.sort_unstable();+ for pad in pads {+ let net = self.board.pads[pad].net;+ let inner = &planes[&net];+ // An exposed pad has its thermal vias already: piprobe_v13's U1 EP got a stub across the+ // +1V1 ring from here before this test.+ let size = self.board.pads[pad].size;+ if (size.0 >= 1.0 * NM && size.1 >= 1.0 * NM) || self.board.pads[pad].layers.iter().any(|l| inner.contains(l)) || self.routes.values().any(|r| r.task.a == pad || r.task.b == pad) {+ continue;+ }+ let task = Task { net, level: Level::PlaneFanout, a: pad, b: pad, main: false, zone: None };+ if self.fan_out_pad(pad, &task) || self.fan_out_search(&task).is_some() {+ count += 1;+ }+ }+ if count > 0 {+ self.log.push(format!("{count} plane-net pads that relied on an old fill got their own via"));+ }+ }++ /// A stuck connection usually isn't stuck by the board, only by the few routes laid around its+ /// pin before it had a turn. Lift everything near its ends, lay it first, and lay them back. Much+ /// cheaper than a restart, and it can't lose ground: the board stays as it was unless every+ /// route lifted finds a place again.+ fn local_restart(&mut self, task: &Task, radius: f64) -> bool {+ let ends: Vec<V> = vec![self.board.pads[task.a].center.into(), self.board.pads[task.b].center.into()];+ let near = |r: &Route| -> bool {+ ends.iter().any(|&e| r.segs.iter().any(|s| pt_seg(e, s.a.into(), s.b.into()) <= radius) || r.vias.iter().any(|v| hypot(sub(e, (*v).into())) <= radius))+ };+ let mut ids: Vec<u32> = self.routes.values().filter(|r| near(r) && !matches!(r.task.level, Level::Pair | Level::Kelvin) && !(r.segs.is_empty() && r.task.a == r.task.b)).map(|r| r.id).collect();+ ids.sort_unstable();+ if ids.is_empty() {+ return false;+ }+ let mut txn = Txn { fuel: ids.len() as i32 * 2 + 4, ..Default::default() };+ let victims = self.take_victims(task, &ids, &mut txn);+ let mode = Mode { escalate: true, swap: false };+ let mut ok = self.displace(task, mode, 0, &mut txn);+ if ok {+ for r in victims {+ let before = txn.added.len();+ if self.splice(&r, &mut txn) {+ let added: Vec<u32> = txn.added[before..].to_vec();+ txn.locked.extend(added);+ } else if !self.displace(&r.task, mode, 1, &mut txn) {+ ok = false;+ break;+ }+ }+ }+ if ok && self.still_whole(&mut txn, task) {+ return true;+ }+ self.undo(txn);+ false+ }++ /// Rounds over everything still open until a round closes nothing. Then one round of swap+ /// moves, which can walk a hole to a place where it closes; the best state seen is what counts.+ pub(super) fn repair(&mut self, failed: Vec<Task>, best: &mut Option<Best>) -> Vec<Task> {+ let mut open = self.open_work(failed);+ self.keep_if_best(&open, best);+ let mut swaps_left = SWAP_ROUNDS;+ let mut swapped: HashSet<(usize, usize)> = HashSet::new();+ while !open.is_empty() && !self.over_time() {+ let swap = swaps_left > 0 && self.stalled;+ let mut still = Vec::new();+ let before = open.len();+ let mut work: std::collections::VecDeque<Task> = open.into();+ while let Some(task) = work.pop_front() {+ if self.over_time() {+ still.push(task);+ continue;+ }+ let t0 = Instant::now();+ let mode = Mode { escalate: true, swap: swap && !swapped.contains(&(task.a, task.b)) };+ // One stuck spot may not eat the run: benchmark B spent 73 s on two tasks.+ let deadline = self.rules.deadline;+ let turn = Instant::now() + std::time::Duration::from_secs_f64(self.rules.time_limit / 10.0);+ self.rules.deadline = deadline.map(|d| d.min(turn));+ let mut r = self.repair_one(&task, mode);+ if matches!(r, Repair::Failed) && task.a != task.b {+ for radius in [0.8, 1.5, 3.0] {+ if self.local_restart(&task, radius * NM) {+ r = Repair::Fixed;+ self.log.push(format!("{} {}: stuck pin freed by lifting what's within {radius} mm", self.board.net_name(task.net), self.board.pad_name(task.a)));+ break;+ }+ }+ }+ self.rules.deadline = deadline;+ if self.trace {+ eprintln!(" {:<7.2}s repair {} {} {} -> {} {}", t0.elapsed().as_secs_f64(), match r { Repair::Fixed => "fixed", Repair::Swapped(_) => "swap ", Repair::Failed => "FAIL " }, task.level.name(), self.board.pad_name(task.a), self.board.pad_name(task.b), self.board.net_name(task.net));+ }+ match r {+ Repair::Fixed => {}+ Repair::Swapped(t) => {+ swapped.insert((task.a, task.b));+ still.push(t);+ }+ Repair::Failed => still.push(task),+ }+ }+ open = self.open_work(still);+ self.keep_if_best(&open, best);+ // Only fewer holes counts as progress. The same holes again means the next round+ // would repeat this one exactly, unless a swap changes the board.+ if open.len() < before {+ self.stalled = false;+ continue;+ }+ if self.stalled {+ swaps_left = swaps_left.saturating_sub(1);+ if swaps_left == 0 {+ break;+ }+ }+ self.stalled = true;+ }+ self.stalled = false;+ self.fix_square_corners();+ self.keep_if_best(&open, best);+ open+ }+}++/// One net's way out of a connector: its stubs on the pads' layer, vias, and any short jog onto a+/// run already on the far layer.+#[derive(Clone)]+struct ExitPlan {+ net: NetId,+ pads: Vec<usize>,+ vias: Vec<P>,+ top: Vec<(P, P)>,+ bottom: Vec<(P, P)>,+ /// Pads of the part that the plan passes inside their class clearance: each needs a local rule.+ close: Vec<(usize, f64)>,+ cost: f64,+}++/// Search reach and how many ways per net go into the combined search.+/// The most of a net that may lie in a big pour once its way out is settled: a stub off a pad.+pub(super) const STUB_RUN: f64 = 1.2 * NM;+const EXIT_STEP: i64 = 50_000;+const EXIT_KEEP: usize = 24;+/// A jog from a via onto its own run, longest and shortest: he stands 0.2 mm off (4.4 "where there is room").+const JOG_MAX: f64 = 0.9 * NM;+const JOG_MIN: f64 = 0.15 * NM;++impl<'a> Router<'a> {+ /// Copper of `net` on `layer` from a to b: clear of everything by class, except this part's own+ /// pads, which may come as close as the standing minimum and are noted for a local rule.+ fn exit_clear(&self, a: P, b: P, half: f64, layer: usize, net: NetId, fp: usize, close: &mut Vec<(usize, f64)>) -> bool {+ let (va, vb): (V, V) = (a.into(), b.into());+ let floor = self.rules.floor.max(self.rules.min.clearance) as f64;+ let reach = half + self.rules.max_clearance() as f64 + self.world.max_local + 1.0;+ let bb = (va.0.min(vb.0) - reach, va.1.min(vb.1) - reach, va.0.max(vb.0) + reach, va.1.max(vb.1) + reach);+ let mut ok = true;+ self.world.layers[layer].visit(bb, |o| {+ if o.net == net || self.world.crosses_pour(o, net) {+ return true;+ }+ let (mine, local) = match o.owner {+ Owner::Pad(i) => (self.board.pads[i as usize].fp == fp, self.world.pad_clearance[i as usize]),+ _ => (false, 0.0),+ };+ let class = (self.rules.clearance_for(net, o.net) as f64).max(local);+ let need = half + if mine { floor.max(local) } else { class };+ let d = o.dist_within(va, vb, (half + class) * 1.01);+ if d < need - DRC_TOL {+ ok = false;+ return false;+ }+ if let (true, Owner::Pad(i)) = (mine && d < half + class - DRC_TOL, o.owner) {+ close.push((i as usize, d - half));+ }+ true+ });+ ok && self.world.mask.seg_hit(net, layer, va, vb, half).is_none() && self.world.holes_clear(&self.rules, va, vb, half, net)+ }++ fn exit_via_ok(&self, s: P, net: NetId, fp: usize, close: &mut Vec<(usize, f64)>) -> bool {+ let (dia, drill) = self.rules.via_for(net);+ let vr = dia as f64 / 2.0;+ let own = vr + self.rules.pad_via_gap as f64;+ let v: V = s.into();+ if self.board.pads.iter().any(|p| p.net == net && !p.layers.is_empty() && hypot(sub(p.center.into(), v)) < 3.0 * NM && p.shape.dist_pt(v) < own) {+ return false;+ }+ if self.world.mask.via_hit(net, v, vr).is_some() || !self.world.holes_clear(&self.rules, v, v, vr, net) {+ return false;+ }+ let hole = drill as f64 / 2.0 + self.rules.hole_to_hole() as f64;+ let mut clear = true;+ self.world.holes.visit((v.0 - hole, v.1 - hole, v.0 + hole, v.1 + hole), |o| {+ if o.shape.dist_pt(v) < hole {+ clear = false;+ return false;+ }+ true+ });+ if !clear {+ return false;+ }+ (0..self.board.layers.len()).filter(|&l| self.board.layers[l].signal).all(|l| self.exit_clear(s, s, vr, l, net, fp, close))+ }++ /// Pad centre to a via: straight if it can be, else one 45-degree bend either way round.+ fn exit_stub(&self, c: P, s: P, layer: usize, net: NetId, fp: usize, close: &mut Vec<(usize, f64)>) -> Option<Vec<(P, P)>> {+ self.exit_stub_at(c, s, layer, net, fp, self.rules.neck, close)+ }++ fn exit_stub_at(&self, c: P, s: P, layer: usize, net: NetId, fp: usize, width: i64, close: &mut Vec<(usize, f64)>) -> Option<Vec<(P, P)>> {+ let half = width as f64 / 2.0;+ let (dx, dy) = (s.0 - c.0, s.1 - c.1);+ let (ax, ay) = (dx.abs(), dy.abs());+ let m = ax.min(ay);+ let (sx, sy) = (dx.signum(), dy.signum());+ let diag = (sx * m, sy * m);+ let straight = if ax > ay { (sx * (ax - ay), 0) } else { (0, sy * (ay - ax)) };+ let mut ways: Vec<Vec<(P, P)>> = Vec::new();+ if m == 0 || ax == ay {+ ways.push(vec![(c, s)]);+ } else {+ // Straight off the pad first (reference exits), then the 45.+ let e1 = snap((c.0 + straight.0, c.1 + straight.1));+ let e2 = snap((c.0 + diag.0, c.1 + diag.1));+ ways.push(vec![(c, e1), (e1, s)]);+ ways.push(vec![(c, e2), (e2, s)]);+ }+ for w in ways {+ let mut got = Vec::new();+ if w.iter().all(|(a, b)| self.exit_clear(*a, *b, half, layer, net, fp, &mut got)) {+ close.extend(got);+ return Some(w);+ }+ }+ None+ }++ /// From a via, the shortest straight jog onto the net's run on `layer`, standing off it (4.4).+ fn exit_jog(&self, v: P, layer: usize, net: NetId, fp: usize, close: &mut Vec<(usize, f64)>) -> Option<(P, P, f64)> {+ let segs: Vec<Seg> = self.routes.values().filter(|r| r.task.net == net).flat_map(|r| r.segs.iter().cloned()).filter(|s| s.layer == layer).collect();+ let at: V = v.into();+ let mut best: Option<(P, P, f64)> = None;+ for s in &segs {+ if pt_seg(at, s.a.into(), s.b.into()) < JOG_MIN {+ return None;+ }+ let (sa, sb): (V, V) = (s.a.into(), s.b.into());+ for (dx, dy) in DIRS {+ let (ux, uy) = (dx as f64, dy as f64);+ let (ex, ey) = (sb.0 - sa.0, sb.1 - sa.1);+ let denom = ux * ey - uy * ex;+ if denom.abs() < 1e-6 {+ continue;+ }+ // A branch joins as a Y, never square (4.2).+ if (ux * ex + uy * ey).abs() < 1e-6 * hypot(V(ex, ey)) {+ continue;+ }+ let (wx, wy) = (sa.0 - at.0, sa.1 - at.1);+ let t = (wx * ey - wy * ex) / denom;+ let u = (wx * uy - wy * ux) / denom;+ let len = t * hypot(V(ux, uy));+ if t <= 0.0 || !(0.0..=1.0).contains(&u) || len > JOG_MAX {+ continue;+ }+ let hit = (at.0 + ux * t, at.1 + uy * t);+ let q = (hit.0.round() as i64, hit.1.round() as i64);+ if best.map(|b| len < b.2).unwrap_or(true) {+ let half = s.width as f64 / 2.0;+ let mut got = Vec::new();+ if self.exit_clear(v, q, half, layer, net, fp, &mut got) {+ close.extend(got);+ best = Some((v, q, len));+ }+ }+ }+ }+ best+ }++ /// Every way worth trying for one net's pads on this part, cheapest first.+ fn exit_ways(&self, net: NetId, pads: &[usize], fp: usize, far: usize, on_run: bool) -> Vec<ExitPlan> {+ let top = self.board.pads[pads[0]].layers[0];+ let reach = (POUR_RUN as i64) / EXIT_STEP;+ let centers: Vec<P> = pads.iter().map(|&p| snap(self.board.pads[p].center)).collect();+ let mid = (centers.iter().map(|c| c.0).sum::<i64>() / centers.len() as i64, centers.iter().map(|c| c.1).sum::<i64>() / centers.len() as i64);+ let mut out: Vec<ExitPlan> = Vec::new();+ for ix in -reach..=reach {+ for iy in -reach..=reach {+ let s = snap((mid.0 + ix * EXIT_STEP, mid.1 + iy * EXIT_STEP));+ let v: V = s.into();+ if centers.iter().any(|c| {+ let (ax, ay) = ((c.0 - s.0).abs() as f64, (c.1 - s.1).abs() as f64);+ ax.max(ay) + (SQRT2 - 1.0) * ax.min(ay) > POUR_RUN+ }) {+ continue;+ }+ let mut close = Vec::new();+ if !self.exit_via_ok(s, net, fp, &mut close) {+ continue;+ }+ let mut topway = Vec::new();+ if !centers.iter().all(|c| match self.exit_stub(*c, s, top, net, fp, &mut close) {+ Some(w) => {+ topway.extend(w);+ true+ }+ None => false,+ }) {+ continue;+ }+ let mut bottom = Vec::new();+ if on_run {+ match self.exit_jog(s, far, net, fp, &mut close) {+ Some((a, b, _)) => bottom.push((a, b)),+ None => continue,+ }+ }+ let len = |w: &[(P, P)]| w.iter().map(|(a, b)| hypot(sub((*a).into(), (*b).into()))).sum::<f64>();+ let mut rule_pads: Vec<usize> = close.iter().map(|c| c.0).collect();+ rule_pads.sort_unstable();+ rule_pads.dedup();+ // Each exception weighs what 0.5 mm of stub does: fewest first, but not at any price.+ let cost = len(&topway) + 0.5 * len(&bottom) + 0.5 * NM * rule_pads.len() as f64;+ let _ = v;+ out.push(ExitPlan { net, pads: pads.to_vec(), vias: vec![s], top: topway, bottom, close, cost });+ }+ }+ out.sort_by(|a, b| a.cost.total_cmp(&b.cost));+ // Spread out, or the search only sees one spot many times over.+ let mut kept: Vec<ExitPlan> = Vec::new();+ for w in out {+ if kept.iter().all(|k| hypot(sub(k.vias[0].into(), w.vias[0].into())) >= 0.1 * NM) {+ kept.push(w);+ if kept.len() >= EXIT_KEEP {+ break;+ }+ }+ }+ kept+ }++ fn exits_fit(&self, a: &ExitPlan, b: &ExitPlan) -> bool {+ let clr = self.rules.clearance_for(a.net, b.net) as f64 - DRC_TOL;+ let half = self.rules.neck as f64 / 2.0;+ let vr = |p: &ExitPlan| self.rules.via_for(p.net).0 as f64 / 2.0;+ let (ra, rb) = (vr(a), vr(b));+ let hole = (self.rules.via_for(a.net).1 + self.rules.via_for(b.net).1) as f64 / 2.0 + self.rules.hole_to_hole() as f64;+ for &x in &a.vias {+ for &y in &b.vias {+ let d = hypot(sub(x.into(), y.into()));+ if d < ra + rb + clr || d < hole {+ return false;+ }+ }+ for (p, q) in b.top.iter().chain(&b.bottom) {+ if pt_seg(x.into(), (*p).into(), (*q).into()) < ra + half + clr {+ return false;+ }+ }+ }+ for &y in &b.vias {+ for (p, q) in a.top.iter().chain(&a.bottom) {+ if pt_seg(y.into(), (*p).into(), (*q).into()) < rb + half + clr {+ return false;+ }+ }+ }+ for (list_a, list_b) in [(&a.top, &b.top), (&a.bottom, &b.bottom)] {+ for (p, q) in list_a {+ for (r, s) in list_b {+ if seg_seg((*p).into(), (*q).into(), (*r).into(), (*s).into()) < 2.0 * half + clr {+ return false;+ }+ }+ }+ }+ true+ }++ /// Lay every exit of this connector as one plan: the cheapest combination in which all of its nets+ /// fit together. The nets of the run through it join it by a jog; the others keep a via their own+ /// routes start from. False leaves the board untouched.+ pub(super) fn plan_exits(&mut self, fp: usize, run: [NetId; 2], far: usize) -> bool {+ let mut by_net: Vec<(NetId, Vec<usize>)> = Vec::new();+ for &i in &self.board.footprints[fp].pads {+ if !self.pour_exit_pads.contains(&i) || looks_ground(self.board.net_name(self.board.pads[i].net)) {+ continue;+ }+ let net = self.board.pads[i].net;+ match by_net.iter_mut().find(|x| x.0 == net) {+ Some(x) => x.1.push(i),+ None => by_net.push((net, vec![i])),+ }+ }+ if by_net.is_empty() {+ return false;+ }+ // A net's pads share a via if that works; otherwise each has its own, as his A7 and B7 do.+ let mut options: Vec<Vec<Vec<ExitPlan>>> = Vec::new();+ for (net, pads) in &by_net {+ let on_run = run.contains(net);+ let mut ways: Vec<Vec<ExitPlan>> = self.exit_ways(*net, pads, fp, far, on_run).into_iter().map(|w| vec![w]).collect();+ if pads.len() == 2 {+ let (a, b) = (self.exit_ways(*net, &pads[..1], fp, far, on_run), self.exit_ways(*net, &pads[1..], fp, far, on_run));+ for x in a.iter().take(10) {+ for y in b.iter().take(10) {+ if hypot(sub(x.vias[0].into(), y.vias[0].into())) >= self.rules.via_for(*net).0 as f64 + 0.15 * NM {+ ways.push(vec![x.clone(), y.clone()]);+ }+ }+ }+ }+ if ways.is_empty() {+ self.log.push(format!("exits: {} has no legal way off {}", self.board.net_name(*net), self.board.footprints[fp].reference));+ return false;+ }+ let cost = |w: &Vec<ExitPlan>| w.iter().map(|p| p.cost).sum::<f64>();+ ways.sort_by(|a, b| cost(a).total_cmp(&cost(b)));+ ways.truncate(EXIT_KEEP * 2);+ options.push(ways);+ }+ // The tightest net first, so a dead end shows early.+ options.sort_by_key(|w| w.len());+ let mut best: Option<(f64, Vec<usize>)> = None;+ let mut pick = vec![0usize; options.len()];+ self.exit_search(&options, 0, 0.0, &mut pick, &mut best);+ let Some((_, chosen)) = best else {+ self.log.push(format!("exits: no combination fits at {}", self.board.footprints[fp].reference));+ return false;+ };+ let name = self.board.footprints[fp].reference.clone();+ let far_layer = far;+ for (k, &c) in chosen.iter().enumerate() {+ for plan in options[k][c].clone() {+ let top = self.board.pads[plan.pads[0]].layers[0];+ let neck = self.rules.neck;+ let run_w = self.routes.values().filter(|r| r.task.net == plan.net).flat_map(|r| r.segs.iter()).find(|s| s.layer == far_layer).map(|s| s.width).unwrap_or(neck);+ let mut segs: Vec<Seg> = plan.top.iter().filter(|(a, b)| a != b).map(|(a, b)| Seg { a: *a, b: *b, layer: top, width: neck }).collect();+ segs.extend(plan.bottom.iter().filter(|(a, b)| a != b).map(|(a, b)| Seg { a: *a, b: *b, layer: far_layer, width: run_w }));+ let (a, b) = (plan.pads[0], *plan.pads.last().unwrap());+ let task = Task { net: plan.net, level: Level::Pair, a, b, main: false, zone: None };+ let id = self.fresh_id();+ self.place(id, task, segs, plan.vias.clone());+ self.exit_tasks.insert((a, b));+ if !run.contains(&plan.net) {+ for &p in &plan.pads {+ self.exit_via.insert(p, plan.vias[0]);+ }+ }+ // Exceptions, each at what the plan really leaves.+ let net_name = self.board.net_name(plan.net).to_string();+ let mut seen: HashMap<usize, f64> = HashMap::new();+ for (pad, room) in &plan.close {+ let e = seen.entry(*pad).or_insert(*room);+ *e = e.min(*room);+ }+ for (pad, room) in seen {+ let number = self.board.pads[pad].number.clone();+ let min = ((room / 1000.0).floor() as i64 * 1000).max(self.rules.floor.max(self.rules.min.clearance));+ if !self.rules.pad_rules.iter().any(|r| r.net == net_name && r.reference == name && r.pad == number && r.min <= min) {+ self.rules.pad_rules.retain(|r| !(r.net == net_name && r.reference == name && r.pad == number));+ self.rules.pad_rules.push(PadRule { net: net_name.clone(), reference: name.clone(), pad: number, min });+ }+ }+ }+ }+ self.rules.bind(self.board);+ self.log.push(format!("exits: planned {} nets off {} as one group", chosen.len(), name));+ true+ }++ fn exit_search(&self, options: &[Vec<Vec<ExitPlan>>], k: usize, cost: f64, pick: &mut Vec<usize>, best: &mut Option<(f64, Vec<usize>)>) {+ if best.as_ref().map(|b| cost >= b.0).unwrap_or(false) {+ return;+ }+ if k == options.len() {+ *best = Some((cost, pick.clone()));+ return;+ }+ for (c, way) in options[k].iter().enumerate() {+ let fits = (0..k).all(|j| options[j][pick[j]].iter().all(|a| way.iter().all(|b| self.exits_fit(a, b))));+ if !fits {+ continue;+ }+ pick[k] = c;+ self.exit_search(options, k + 1, cost + way.iter().map(|p| p.cost).sum::<f64>(), pick, best);+ }+ }++ /// A stub from a pad to the nearest through-hole pad of its net on the same part, if one is close.+ pub(super) fn tie_to_sibling(&mut self, pad: usize, task: &Task) -> bool {+ const TIE_MAX: f64 = 2.5 * NM;+ let p = self.board.pads[pad].clone();+ let Some(&layer) = p.layers.first() else { return false };+ let c = snap(p.center);+ let net = p.net;+ let fp = p.fp;+ let mut near: Vec<(f64, P)> = self.board.footprints[fp].pads.iter().copied().filter(|&q| q != pad).map(|q| &self.board.pads[q]).filter(|q| q.net == net && q.layers.len() > 1 && q.layers.contains(&layer)).map(|q| (hypot(sub(q.center.into(), p.center.into())), snap(q.center))).filter(|x| x.0 <= TIE_MAX + 2.0 * NM).collect();+ near.sort_by(|a, b| a.0.total_cmp(&b.0));+ for (_, to) in near {+ for width in [mm(0.4), self.rules.track, self.rules.neck] {+ let mut close = Vec::new();+ if let Some(way) = self.exit_stub_at(c, to, layer, net, fp, width, &mut close) {+ if !close.is_empty() {+ continue;+ }+ let segs: Vec<Seg> = way.iter().filter(|(a, b)| a != b).map(|(a, b)| Seg { a: *a, b: *b, layer, width }).collect();+ let id = self.fresh_id();+ self.place(id, Task { level: Level::Pair, zone: None, main: false, ..task.clone() }, segs, Vec::new());+ return true;+ }+ }+ }+ false+ }++ /// After the best board is put back: which pads start from a planned via.+ pub(super) fn rebuild_exit_vias(&mut self) {+ self.exit_via.clear();+ let run_nets: Vec<(usize, usize, P)> = self.routes.values().filter(|r| self.exit_tasks.contains(&(r.task.a, r.task.b)) && !r.vias.is_empty() && !r.segs.iter().any(|s| s.layer != self.board.pads[r.task.a].layers[0])).map(|r| (r.task.a, r.task.b, r.vias[0])).collect();+ for (a, b, v) in run_nets {+ self.exit_via.insert(a, v);+ self.exit_via.insert(b, v);+ }+ }+}
src/router.rsadded+2147@@ -0,0 +1,2147 @@+//! The driver: routes the whole board in the designer's hierarchy (4.2), level by level, commits exact+//! copper, resolves conflicts by rip-up with memory, and post-processes each route.++use crate::board::*;+use crate::classify::*;+use crate::conn;+use crate::geom::*;+use crate::index::*;+use crate::rules::*;+use crate::search::*;+use std::collections::{HashMap, HashSet};+use crate::clock::Instant;++#[path = "repair.rs"]+pub(crate) mod repair;++/// How far a track may run inside a rail pour before a via round it is worth trying first.+/// A run through a pour its pad sits in, past which it counts as crossing rather than leaving. His own+/// J1 exit on benchmark B runs about 2.4 mm on F.Cu to get past the turn in the pair below it.+pub(crate) const POUR_RUN: f64 = 3.0 * NM;+/// How far a coupled run keeps straight past its fan-in, off a pad pair.+const PAIR_RUN_IN: f64 = 0.3 * NM;+/// Fine-pitch lane lengths past the pad, mm, one per run.+const LANES: [f64; 4] = [0.4, 0.5, 0.55, 0.45];++#[derive(Clone, Debug, PartialEq)]+pub struct Seg {+ pub a: P,+ pub b: P,+ pub layer: usize,+ pub width: i64,+}++#[derive(Clone, Debug)]+pub struct Route {+ pub id: u32,+ pub task: Task,+ pub segs: Vec<Seg>,+ pub vias: Vec<P>,+ pub(crate) handles: Vec<(usize, u32)>,+ pub(crate) via_handles: Vec<u32>,+}++pub struct Router<'a> {+ pub board: &'a Board,+ pub rules: Rules,+ pub world: World,+ pub routes: HashMap<u32, Route>,+ next_id: u32,+ pub history: History,+ /// (ripper, victim) pad pairs and how often: Freerouting's rip-up memory, so two nets cannot+ /// keep taking the same spot from each other.+ memory: HashMap<((usize, usize), (usize, usize)), u32>,+ pub(crate) rip_count: HashMap<(usize, usize), u32>,+ pub log: Vec<String>,+ pub rips: u32,+ pub searches: u32,+ pub nodes: u64,+ pub notes: Vec<String>,+ pub(crate) start: Instant,+ trace: bool,+ /// Each net's level and routable pads, for reading what is really open.+ pub(crate) net_info: HashMap<NetId, (Level, Vec<usize>)>,+ /// Why a task is still open, for handing it to the designer (Part 3 step 6).+ why: HashMap<(usize, usize), String>,+ pub splices: u32,+ stalled: bool,+ lane_len: f64,+ /// This run's repair style. Loose: a rail victim takes any neck-width way back and a net may+ /// come apart for a round, to be closed by the next. Tight: rails keep their shape and no move+ /// may split a net. Neither wins everywhere (piprobe_v13: loose 96/96, tight 95/96), so runs+ /// alternate and the best board is kept.+ loose: bool,+ /// Tasks with a pad no track can leave under the rules, for the designer's note.+ boxed_pads: HashMap<(usize, usize), usize>,+ /// Where a track starts on a pad whose centre sits too close to a neighbour for the rule.+ pad_entry: HashMap<usize, P>,+ /// Each fine-pitch pin's held lane: pad to (layer, shape, handle while it is on the board).+ lanes: HashMap<usize, (usize, Shape, Option<u32>)>,+ pub(crate) bus: crate::bus::BusPlan,+ /// This go is running without the bus plan.+ pub(crate) plain_go: bool,+ /// Nets being moved off a pour they cut: a long run through it is no longer taken at all.+ pub(crate) pour_strict: HashSet<NetId>,+ /// The last-mile repair: the run is nearly out of time with a few joins open, so what is left+ /// goes to deeper searches for those instead of a fresh run that could not finish.+ pub(crate) deep: bool,+ /// Rail taps that found no way, with the state they failed in (post.rs `rail_stamp`).+ pub(crate) rail_walled: HashMap<(usize, usize), u64>,+ /// How many times each rail tap's ladder has been run in this run (post.rs `try_rail`).+ pub(crate) rail_tries: HashMap<(usize, usize), u32>,+ /// Rails whose source and heavy loads share one connected pour: there the pour is the feed and+ /// a track is only a tie-in to it. A rail with a few local patches is not one of these.+ pour_fed: HashSet<NetId>,+ /// Per rail pad: the current into its branch (current.rs branch_amps). Rails fed by their pour+ /// have none: there each pad draws its own from the pour.+ pub(crate) tap_amps: HashMap<usize, f64>,+ /// The power stage's plan for the rated rails that are not fed by their pour (power.rs).+ pub(crate) power: crate::power::PowerPlan,+ /// The only nets this run may route, when it was asked for a few (a fixture). Repair finds its+ /// work from every net left apart on the board, which without this is the whole board.+ pub only: Option<HashSet<NetId>>,+ /// Planned rail branches already laid once in this pass. Signals outrank power (4.2), so a signal+ /// may move one, but it gives way by changing layer, never by thinning: laid again, it is tried+ /// only at its planned width, with more layer changes allowed (the designer, 2026-11-10: "making that+ /// rail via more often to protect signals"). If no full-width way exists the move is refused.+ pub(crate) rail_laid: HashSet<(usize, usize)>,+ /// Nets with a pour over much of an outer layer, and that layer: the board's plane in all but+ /// name on a two-layer board. Each pad of the net on another layer gets its own via to it,+ /// staked right after the pairs like an inner plane's (4.2), because after routing there is no+ /// room left for them: on the wiki's two-layer boards a refill left 6 to 16 ground pads and+ /// pour pieces cut off, where their designers have 100 to 300 vias (2026-11-10).+ pub(crate) pour_plane: HashMap<NetId, usize>,+ /// The pads those vias are for. One that finds no site is not a failed join: its pour may hold.+ pub(crate) pour_pads: HashSet<usize>,+ /// A connection being routed again at the width its current turned out to need.+ pub(crate) rail_override: HashMap<(usize, usize), i64>,+ /// Nets with a single-layer pad inside another net's big pour. They leave it his way, a short stub+ /// and straight to another layer, rather than running through and splitting it on refill.+ pub(crate) pour_exit: HashSet<NetId>,+ /// The pads that make a net one of those.+ pub(crate) pour_exit_pads: HashSet<usize>,+ /// This attempt has been let off `pour_exit`: no exit was found, and an open join is worse.+ pub(crate) pour_relax: bool,+ /// The run through a pour allowed just now: `POUR_RUN`, or less when moving a net that cut one.+ pub(crate) pour_limit: f64,+ /// Planned exits: pads whose own route starts from a via laid with the group, and which tasks+ /// were laid that way.+ pub(crate) exit_via: HashMap<usize, P>,+ pub(crate) exit_tasks: HashSet<(usize, usize)>,+}++#[derive(Default, Clone, Debug)]+pub struct Outcome {+ pub failed: Vec<Task>,+ pub seconds: f64,+}++fn poly_of(zone: &Zone, layer: usize) -> Vec<Vec<V>> {+ let fills: Vec<Vec<V>> = zone.fills.iter().filter(|(l, _)| *l == layer).map(|(_, p)| p.clone()).collect();+ if fills.is_empty() { vec![zone.outline.clone()] } else { fills }+}++/// A supply net whose pads all lie within this is local to one part and is routed with the first group.+const LOCAL_NET_MM: f64 = 6.0;+/// The most times one rail tap's search ladder is run in a run (three more in the last-mile repair).+pub(crate) const RAIL_TRIES: u32 = 6;+/// A supply pin's stub to its capacitor is laid with the first group when the two are this near.+const PIN_CAP_MM: f64 = 3.0;++pub fn build_world(board: &Board, rules: &Rules) -> World {+ let bb = if board.edges.is_empty() {+ let mut b = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);+ for p in &board.pads {+ let s = p.shape.bbox();+ b = (b.0.min(s.0), b.1.min(s.1), b.2.max(s.2), b.3.max(s.3));+ }+ b+ } else {+ board.bbox+ };+ let cell = 0.5 * NM;+ let mut layers: Vec<Grid> = board.layers.iter().map(|_| Grid::new(bb, cell)).collect();+ let mut holes = Grid::new(bb, cell);+ let mut edge = Grid::new(bb, cell);+ for (a, b) in &board.edges {+ edge.add(Shape::Capsule { a: *a, b: *b, r: 0.0 }, NO_NET, Owner::Edge);+ }+ let n = board.layers.len();+ for (i, p) in board.pads.iter().enumerate() {+ if p.npth {+ let r = p.drill.unwrap_or(p.size.0) / 2.0;+ let s = Shape::Circle { c: p.center.into(), r };+ for g in layers.iter_mut() {+ g.add(s.clone(), NO_NET, Owner::Keepout);+ }+ holes.add(s, NO_NET, Owner::Pad(i as u32));+ continue;+ }+ for &l in &p.layers {+ layers[l].add(p.shape.clone(), p.net, Owner::Pad(i as u32));+ }+ if let Some(d) = p.drill {+ holes.add(Shape::Circle { c: p.center.into(), r: d / 2.0 }, p.net, Owner::Pad(i as u32));+ }+ }+ for t in &board.tracks {+ layers[t.layer].add(Shape::Capsule { a: t.a.into(), b: t.b.into(), r: t.width as f64 / 2.0 }, t.net, Owner::Fixed);+ }+ for v in &board.vias {+ for g in layers.iter_mut() {+ g.add(Shape::Circle { c: v.at.into(), r: v.size as f64 / 2.0 }, v.net, Owner::Fixed);+ }+ holes.add(Shape::Circle { c: v.at.into(), r: v.drill as f64 / 2.0 }, v.net, Owner::Fixed);+ }+ let mut zones = Vec::new();+ let pour_open: Vec<Vec<NetId>> = board+ .zones+ .iter()+ .map(|z| {+ let mut nets: Vec<NetId> = board.pads.iter().filter(|p| p.net != z.net && p.net != NO_NET && p.layers.iter().any(|l| z.layers.contains(l)) && in_poly(p.center.into(), &z.outline)).map(|p| p.net).collect();+ nets.sort_unstable();+ nets.dedup();+ nets+ })+ .collect();+ let plane_set = board.plane_layers();+ for (zi, z) in board.zones.iter().enumerate() {+ if z.keepout_tracks {+ for &l in &z.layers {+ layers[l].add(Shape::Poly { pts: z.outline.clone(), r: 0.0 }, NO_NET, Owner::Keepout);+ }+ }+ if z.keepout_vias {+ holes.add(Shape::Poly { pts: z.outline.clone(), r: 0.0 }, NO_NET, Owner::Keepout);+ }+ if z.keepout_tracks || z.keepout_vias || z.net == NO_NET {+ continue;+ }+ for &l in &z.layers {+ for poly in poly_of(z, l) {+ zones.push((z.net, l, poly.clone()));+ // A pour on a signal layer of a rail is deliberate copper: other nets stay out.+ // A GND fill refills around tracks, so it blocks nothing.+ let is_signal_layer = board.layers[l].signal;+ // An inner plane is not such a pour, whatever its net: vias of every other net pass+ // through it and KiCad cuts their antipads. KiCad types every layer "signal" unless+ // told otherwise, so the layer type cannot tell the two apart; the plane test does.+ // On a 4-layer switch board a +3.3V plane on In2.Cu refused all 49 GND vias.+ if is_signal_layer && !plane_set.contains(&l) && !looks_ground(board.net_name(z.net)) && poly.len() >= 3 {+ layers[l].add(Shape::Poly { pts: poly, r: 0.0 }, z.net, Owner::Zone(zi as u32));+ }+ }+ }+ }+ let planes = board.plane_layers();+ let mut route_layers: Vec<usize> = (0..n).filter(|&l| board.layers[l].signal && !planes.contains(&l)).collect();+ if route_layers.is_empty() {+ route_layers = vec![0, n - 1];+ }+ let mut escapes = Vec::new();+ for (fi, f) in board.footprints.iter().enumerate() {+ if crate::classify::is_fine_pitch(board, rules, fi) {+ let mut b = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);+ for &p in &f.pads {+ let s = board.pads[p].shape.bbox();+ b = (b.0.min(s.0), b.1.min(s.1), b.2.max(s.2), b.3.max(s.3));+ }+ let m = rules.escape_margin;+ escapes.push((b.0 - m, b.1 - m, b.2 + m, b.3 + m));+ }+ }+ let rail_nets = rules.rails.keys().filter_map(|name| board.net_ids.iter().find(|(k, _)| k.eq_ignore_ascii_case(name)).map(|(_, v)| *v)).collect();+ // Pads that reach their pour through thermal spokes (the same test as pour.rs makes).+ let mut relief: Vec<Grid> = board.layers.iter().map(|_| Grid::new(bb, cell)).collect();+ let mut relief_gap = vec![0.0f64; board.pads.len()];+ let mut relief_reach = 0.0f64;+ if std::env::var_os("ADOM_ROUTE_NO_RELIEF").is_none() {+ for z in board.zones.iter().filter(|z| z.net != NO_NET && !z.keepout_tracks && z.outline.len() >= 3) {+ let Some((gap, _)) = z.thermal else { continue };+ for (i, p) in board.pads.iter().enumerate().filter(|(_, p)| p.net == z.net && !p.npth) {+ let thermal = match p.zone_connect {+ Some(2) | Some(0) => false,+ Some(3) => p.drill.is_some(),+ _ => !z.thermal_tht_only || p.drill.is_some(),+ };+ if !thermal || !in_poly(p.center.into(), &z.outline) {+ continue;+ }+ for &l in p.layers.iter().filter(|l| z.layers.contains(l)) {+ if relief_gap[i] == 0.0 {+ relief[l].add(p.shape.clone(), p.net, Owner::Pad(i as u32));+ }+ }+ relief_gap[i] = relief_gap[i].max(gap);+ relief_reach = relief_reach.max(gap);+ }+ }+ }+ let mut partner = HashMap::new();+ for pr in &rules.pairs {+ let id = |name: &str| board.net_ids.iter().find(|(k, _)| k.eq_ignore_ascii_case(name)).map(|(_, v)| *v);+ if let (Some(a), Some(b)) = (id(&pr.p), id(&pr.n)) {+ partner.insert(a, b);+ partner.insert(b, a);+ }+ }+ World { relief, relief_gap, relief_reach, partner, pair_segs: Default::default(), rail_nets, follow: Default::default(), pour_avoid: HashMap::new(), pour_open, layers, holes, edge, route_layers, escapes, zones, guide: Default::default(), mask: if std::env::var_os("ADOM_ROUTE_NO_MASK_GUARD").is_some() { crate::mask::MaskMap::new(board, rules) } else { crate::mask::MaskMap::new(board, rules).guarded() }, pad_clearance: board.pads.iter().map(|p| p.local_clearance).collect(), max_local: board.pads.iter().map(|p| p.local_clearance).fold(0.0, f64::max) }+}++fn turn_of(a: P, b: P, c: P) -> Option<i32> {+ let d1 = dir_of(a, b)?;+ let d2 = dir_of(b, c)?;+ Some(((d2 as i32 - d1 as i32).rem_euclid(8)).min((d1 as i32 - d2 as i32).rem_euclid(8)))+}++pub fn dir_of(a: P, b: P) -> Option<u8> {+ let (dx, dy) = (b.0 - a.0, b.1 - a.1);+ if dx == 0 && dy == 0 {+ return None;+ }+ let tol = 2;+ if dy.abs() <= tol && dx.abs() > tol {+ return Some(if dx > 0 { 0 } else { 4 });+ }+ if dx.abs() <= tol && dy.abs() > tol {+ return Some(if dy > 0 { 2 } else { 6 });+ }+ if (dx.abs() - dy.abs()).abs() <= tol {+ return Some(match (dx > 0, dy > 0) {+ (true, true) => 1,+ (false, true) => 3,+ (false, false) => 5,+ (true, false) => 7,+ });+ }+ None+}++/// Merge straight runs on one layer.+pub fn simplify(pts: &mut Vec<(P, usize)>) {+ pts.dedup();+ let mut i = 1;+ while i + 1 < pts.len() {+ let (a, b, c) = (pts[i - 1], pts[i], pts[i + 1]);+ if a.1 == b.1 && b.1 == c.1 && dir_of(a.0, b.0) == dir_of(b.0, c.0) {+ pts.remove(i);+ if i > 1 {+ i -= 1;+ }+ } else {+ i += 1;+ }+ }+}++impl<'a> Router<'a> {+ pub fn new(board: &'a Board, mut rules: Rules) -> Router<'a> {+ // A board set up with a wider minimum than the standing neck is judged by its setup (2.2):+ // Benchmark C's project says 0.2 mm, and 199 of KiCad's findings were 0.15 mm necks.+ rules.neck = rules.neck.max(rules.min.track);+ rules.track = rules.track.max(rules.neck);+ let world = build_world(board, &rules);+ let history = History::new(board.bbox);+ let net_info = classify(board, &rules).into_iter().map(|i| (i.net, (i.level, i.pads))).collect();+ Router { board, rules, world, routes: HashMap::new(), bus: Default::default(), plain_go: false, deep: false, rail_walled: HashMap::new(), rail_tries: HashMap::new(), pour_strict: Default::default(), pour_fed: Default::default(), tap_amps: HashMap::new(), power: Default::default(), only: None, rail_laid: HashSet::new(), pour_plane: HashMap::new(), pour_pads: HashSet::new(), rail_override: HashMap::new(), pour_exit: Default::default(), pour_exit_pads: Default::default(), pour_relax: false, pour_limit: POUR_RUN, exit_via: HashMap::new(), exit_tasks: HashSet::new(), next_id: 1, history, memory: HashMap::new(), rip_count: HashMap::new(), log: Vec::new(), rips: 0, searches: 0, nodes: 0, notes: Vec::new(), start: Instant::now(), trace: std::env::var_os("ADOM_ROUTE_TRACE").is_some(), net_info, why: HashMap::new(), splices: 0, stalled: false, loose: true, boxed_pads: HashMap::new(), lane_len: LANES[0] * NM, pad_entry: HashMap::new(), lanes: HashMap::new() }+ }++ pub(crate) fn pad_terminal(&self, i: usize) -> Terminal {+ let p = &self.board.pads[i];+ // A pad with a planned exit is reached at its via, from the layers its stub isn't on.+ if let Some(&via) = self.exit_via.get(&i) {+ return Terminal::Point { p: via, layers: self.world.route_layers.iter().copied().filter(|l| !p.layers.contains(l)).collect() };+ }+ Terminal::Point { p: self.pad_entry.get(&i).copied().unwrap_or(p.center), layers: p.layers.iter().copied().filter(|l| self.world.route_layers.contains(l)).collect() }+ }++ /// A rail's widths: the spec's wideNets (main run, branches, stubs), else a net class that sets+ /// more than 4.2's floor, else the floor. A stub off a rail runs at the data width unless the spec+ /// names one (4.2 gives branches no number of their own: check them against their current). A+ /// branch without a figure stays at the main width, its current not being known.+ pub(crate) fn widths_for(&self, t: &Task) -> (i64, i64) {+ if let Some(&w) = self.rail_override.get(&(t.a, t.b)) {+ return (w, self.rules.neck.min(w));+ }+ let r = &self.rules;+ let name = self.board.net_name(t.net);+ let class = r.track_for(t.net);+ match t.level {+ // An approved current: the main run carries the rail, a tap only what its pad draws, and+ // a stub or tap is never asked to be narrower than the data band's neck (4.2).+ Level::Power if r.rail(name).is_some() => {+ let rail = r.rail(name).unwrap();+ // A planned rail's tap has its width from the plan; into the pad it is no wider than the pad.+ if let (true, Some(pp)) = (t.a == t.b, self.power.pads.get(&t.a)) {+ let pad = &self.board.pads[t.a];+ let at_pad = pad.size.0.min(pad.size.1) as i64;+ return (pp.width, pp.width.min(at_pad).max(r.neck));+ }+ let own = crate::current::pad_load(self.board, r, t.a).unwrap_or(0.0);+ // A tap carries its pad's whole branch, not only what the pad draws.+ let through = self.tap_amps.get(&t.a).copied().unwrap_or(own).max(own);+ let plain = if class > mm(0.3) { r.track } else { class };+ let main = if self.pour_fed.contains(&t.net) { r.rail_width(own, false) } else { r.rail_width(rail.amps, true) }.max(plain);+ // The drop limit on top of the chart (reference rule 2026-11-09), from the distance the copper+ // has to cover: a tap from its pad to the nearest source at its branch's current, the+ // trunk end to end at the rail's. The exact drop is rated from the laid copper after.+ let oct = |p: P, q: P| -> f64 { let (dx, dy) = ((p.0 - q.0).abs() as f64, (p.1 - q.1).abs() as f64); dx.max(dy) + (SQRT2 - 1.0) * dx.min(dy) };+ let drop_w = |amps: f64, len: f64| -> i64 { if self.pour_fed.contains(&t.net) { 0 } else { rail.volts.map(|v| r.drop_width(amps, len, v)).unwrap_or(0) } };+ let to_source = self.board.pads.iter().filter(|p| p.net == t.net && rail.sources.iter().any(|s| *s == self.board.footprints[p.fp].reference)).map(|p| oct(p.center, self.board.pads[t.a].center)).fold(f64::INFINITY, f64::min);+ let tap_drop = if to_source.is_finite() { drop_w(through, to_source) } else { 0 };+ let main = if t.a != t.b { main.max(drop_w(rail.amps, oct(self.board.pads[t.a].center, self.board.pads[t.b].center))) } else { main };+ // At the pad the track is no wider than the pad, unless the pad's own draw needs it.+ let pad = &self.board.pads[t.a];+ let at_pad = (pad.size.0.min(pad.size.1) as i64).max(r.rail_width(own, false));+ let stub = r.rail_width(through, false).min(at_pad).max(r.neck);+ match (t.zone == Some(RAIL_TAP), t.main) {+ // One pad tapping toward its net's fill carries its own branch, like any tap:+ // at the rail's full width, benchmark C's +5V_OUT pull-up R46 (0.5 mA) got 56 mm+ // of 0.70 mm track (2026-11-09). Only the trunk between two clusters is the rail.+ (true, true) if t.a == t.b && !self.pour_fed.contains(&t.net) => (r.rail_width(through, false).max(plain).max(tap_drop), stub),+ (true, true) => (main, stub),+ (true, false) => (r.rail_width(through, false).max(plain).max(tap_drop), stub),+ _ => (stub.max(plain), r.neck.max(stub)),+ }+ }+ Level::Power => {+ // Net names differ in case between a spec and a board (+3v3, +3V3).+ let w = r.wide.get(name).or_else(|| r.wide.iter().find(|(k, _)| k.eq_ignore_ascii_case(name)).map(|(_, v)| v));+ // A class wider than 4.2's data band describes the copper that carries the current.+ // Where the net has its own fill, that's the fill (benchmark B's +PS rails: classes of+ // 4 to 10 mm, no track over 1 mm), and a track is only a tie-in to it.+ let fed_by_fill = class > mm(0.3) && self.world.zones.iter().any(|(n, _, _)| *n == t.net);+ let carried = if fed_by_fill { r.power_floor } else { class.max(r.power_floor) };+ let main = w.and_then(|v| v.first().copied()).unwrap_or(carried);+ let plain = if class > mm(0.3) { r.track } else { class };+ let stub = w.filter(|v| v.len() >= 2).and_then(|v| v.last().copied()).unwrap_or(plain);+ let stub = r.neck.max(stub);+ let branch = w.filter(|v| v.len() >= 3).map(|v| v[1]).unwrap_or(main);+ match (t.zone == Some(RAIL_TAP), t.main) {+ (true, true) => (main, stub),+ (true, false) => (branch, stub),+ _ => (stub, r.neck),+ }+ }+ Level::Kelvin => (mm(0.25), r.neck),+ Level::Crystal => (r.neck, r.neck),+ // A class wider than 4.2's data band holds a current width: that's not this net's run.+ _ => (if class > mm(0.3) { r.track } else { class.max(r.neck) }, r.neck),+ }+ }++ /// Every search attempt for one task, cheapest in vias first (RAPTOR-style rounds).+ pub(crate) fn attempts(&self, t: &Task) -> Vec<Profile> {+ let (w, n) = self.widths_for(t);+ let c = self.rules.clearance_for(t.net, t.net);+ let tgt = match t.level {+ Level::Crystal | Level::Kelvin => c,+ _ => (w * 2).max(c),+ };+ let mut out = Vec::new();+ // A bus line stays on its bus's layer while it can: his benchmark B bus has no via in it.+ let on_one_layer = !matches!(t.level, Level::Other) || self.bus.bands.iter().any(|(_, row)| row.contains(&(t.a, t.b)));+ let mk = |width: i64, neck: i64, target_gap: i64, max_vias: u32, margin: f64| Profile { width, neck, clearance: c, target_gap, max_vias, margin, layer_bias: None };+ if t.a == t.b && self.rail_laid.contains(&(t.a, t.b)) && self.power.pads.get(&t.a).map(|p| !p.anywhere).unwrap_or(false) {+ return vec![mk(w, n, tgt, 2, 1.5 * NM), mk(w, n, tgt, 2, 5.0 * NM), mk(w, n, c, 4, 8.0 * NM), mk(w, n, c, 6, 40.0 * NM)];+ }+ if on_one_layer {+ out.push(mk(w, n, tgt, 0, 1.5 * NM));+ out.push(mk(w, n, tgt, 0, 5.0 * NM));+ }+ out.push(mk(w, n, tgt, 2, 1.5 * NM));+ out.push(mk(w, n, tgt, 2, 5.0 * NM));+ // Dense: drop to 2W spacing, then the neck everywhere (never below minimum clearance).+ out.push(mk(w, n, c.max(w), 2, 5.0 * NM));+ // A rail with an approved current keeps its width as far round as it takes before it is+ // ever drawn at its neck: the neck is for the last stretch into a pad. Without these, a run+ // that needed a detour of over 5 mm went down at the neck for its whole length (benchmark C's+ // +5V to U10: 30 mm at 0.40 mm for 1.24 A, 2026-11-09).+ if t.level == Level::Power && self.rules.rail(self.board.net_name(t.net)).is_some() && w > n {+ out.push(mk(w, n, c, 2, 8.0 * NM));+ out.push(mk(w, n, c, 4, 40.0 * NM));+ // Then narrower by thirds, never under what the current alone needs (the chart): a run+ // that can't be as wide as its drop limit asks is still as wide as fits, the stretches+ // with room are widened after, and what is left over the limit is reported.+ let rail = self.rules.rail(self.board.net_name(t.net)).unwrap();+ let amps = if t.a != t.b { rail.amps } else { self.power.pads.get(&t.a).map(|p| p.amps).or_else(|| self.tap_amps.get(&t.a).copied()).or_else(|| crate::current::pad_load(self.board, &self.rules, t.a)).unwrap_or(0.0) };+ let chart = self.rules.rail_width(amps, false).max(n);+ let mut x = w;+ while x > chart {+ x = ((x * 2 / 3) / 10_000 * 10_000).max(chart);+ out.push(mk(x, n, c, 2, 5.0 * NM));+ out.push(mk(x, n, c, 4, 40.0 * NM));+ }+ }+ out.push(mk(n, n, c, 2, 8.0 * NM));+ out.push(mk(n, n, c, 4, 40.0 * NM));+ out+ }++ pub(crate) fn seg_clear(&self, a: P, b: P, layer: usize, width: i64, net: NetId, also: NetId) -> bool {+ let half = width as f64 / 2.0;+ let (va, vb): (V, V) = (a.into(), b.into());+ let reach = half + self.rules.max_clearance() as f64 + self.world.max_local + 1.0;+ let bb = (va.0.min(vb.0) - reach, va.1.min(vb.1) - reach, va.0.max(vb.0) + reach, va.1.max(vb.1) + reach);+ let mut ok = true;+ self.world.layers[layer].visit(bb, |o| {+ if (o.net == net && net != NO_NET) || (o.net == also && also != NO_NET) || self.world.crosses_pour(o, net) {+ return true;+ }+ let local = if let Owner::Pad(i) = o.owner { self.world.pad_clearance[i as usize] } else { 0.0 };+ let pad = if let Owner::Pad(i) = o.owner { Some(i) } else { None };+ let need = half + (self.rules.clearance_at(net, o.net, pad) as f64).max(local) - DRC_TOL;+ if o.dist_within(va, vb, need * 1.01) < need {+ ok = false;+ return false;+ }+ true+ });+ if !ok {+ return false;+ }+ if self.world.mask.seg_hit_also(net, also, layer, va, vb, half).is_some() || !self.world.holes_clear_also(&self.rules, va, vb, half, net, also) {+ return false;+ }+ let e = half + self.rules.edge as f64;+ let bb = (va.0.min(vb.0) - e, va.1.min(vb.1) - e, va.0.max(vb.0) + e, va.1.max(vb.1) + e);+ self.world.edge.visit(bb, |o| {+ if o.shape.dist_seg(va, vb) < e {+ ok = false;+ return false;+ }+ true+ });+ ok+ }++ /// Tidy a path: straighten with fewer bends where copper allows ("as straight as possible,+ /// with the fewest segments"), then cut every remaining 90-degree corner into two 135s.+ pub(crate) fn finish_path(&self, path: &Path, net: NetId, also: NetId, neck: i64) -> Vec<(P, usize)> {+ self.finish_path_on(path, net, also, &|_| neck)+ }++ /// `width_on` gives the width each layer's copper has to clear at: a rail's bus is wider than its+ /// stubs, so a shortcut legal for one may not be for the other.+ pub(crate) fn finish_path_on(&self, path: &Path, net: NetId, also: NetId, width_on: &dyn Fn(usize) -> i64) -> Vec<(P, usize)> {+ let mut pts = path.pts.clone();+ simplify(&mut pts);+ // Pull tight: any stretch of a few segments on one layer that one bend can replace, longest+ // first, so a route that wandered round something now gone straightens in one step.+ for _ in 0..4 {+ let mut changed = false;+ let mut i = 0;+ while i + 3 < pts.len() {+ let mut done = false;+ let reach = (pts.len() - 1 - i).min(8);+ for span in (3..=reach).rev() {+ let j = i + span;+ if !pts[i..=j].iter().all(|x| x.1 == pts[i].1) {+ continue;+ }+ let l = pts[i].1;+ if let Some(q) = self.one_bend(pts[i].0, pts[j].0, l, net, also, width_on(l), if i > 0 { Some(pts[i - 1]) } else { None }, pts.get(j + 1).copied()) {+ pts.splice(i + 1..j, q.into_iter().map(|x| (x, l)));+ simplify(&mut pts);+ changed = true;+ done = true;+ break;+ }+ }+ if !done {+ i += 1;+ }+ }+ if !changed {+ break;+ }+ }+ collapse_jogs(&mut pts);+ // Chamfer 90-degree corners: 135-degree angles are optimal, no 90-degree bends (4.2).+ let mut i = 1;+ while i + 1 < pts.len() {+ let (a, b, c) = (pts[i - 1], pts[i], pts[i + 1]);+ if a.1 == b.1 && b.1 == c.1 && turn_of(a.0, b.0, c.0) == Some(2) {+ let la = (a.0 .0 - b.0 .0).abs().max((a.0 .1 - b.0 .1).abs());+ let lc = (c.0 .0 - b.0 .0).abs().max((c.0 .1 - b.0 .1).abs());+ let mut done = false;+ for size in [0.5, 0.3, 0.2, 0.1, 0.05] {+ let s = (size * NM) as i64;+ let s = s.min(la).min(lc);+ if s < 2_000 {+ continue;+ }+ let s = s & !1;+ let toward = |from: P, to: P| -> P {+ let d = (to.0 - from.0, to.1 - from.1);+ (from.0 + d.0.signum() * s, from.1 + d.1.signum() * s)+ };+ let (q1, q2) = (toward(b.0, a.0), toward(b.0, c.0));+ if self.seg_clear(q1, q2, b.1, width_on(b.1), net, also) {+ pts[i] = (q1, b.1);+ pts.insert(i + 1, (q2, b.1));+ i += 1;+ done = true;+ break;+ }+ }+ let _ = done;+ }+ i += 1;+ }+ simplify(&mut pts);+ pts+ }++ /// One bend between two points, if it is legal, shorter in bends, and doesn't make an acute+ /// corner with what comes before or after.+ #[allow(clippy::too_many_arguments)]+ fn one_bend(&self, a: P, b: P, layer: usize, net: NetId, also: NetId, neck: i64, before: Option<(P, usize)>, after: Option<(P, usize)>) -> Option<Vec<P>> {+ let (dx, dy) = (b.0 - a.0, b.1 - a.1);+ let (adx, ady) = (dx.abs(), dy.abs());+ let mut cands: Vec<Vec<P>> = Vec::new();+ if dir_of(a, b).is_some() {+ cands.push(Vec::new());+ } else {+ let d = adx.min(ady);+ let (sx, sy) = (dx.signum(), dy.signum());+ cands.push(vec![(a.0 + sx * d, a.1 + sy * d)]);+ cands.push(vec![(b.0 - sx * d, b.1 - sy * d)]);+ }+ for c in cands {+ let mut chain = vec![a];+ chain.extend(c.iter().copied());+ chain.push(b);+ let clear = chain.windows(2).all(|w| self.seg_clear(w[0], w[1], layer, neck, net, also));+ if !clear {+ continue;+ }+ let mut ok = chain.windows(3).all(|w| turn_of(w[0], w[1], w[2]).map(|t| t <= 1).unwrap_or(false));+ if let Some(p) = before {+ if p.1 == layer {+ ok &= turn_of(p.0, a, chain[1]).map(|t| t <= 1).unwrap_or(true);+ }+ }+ if let Some(p) = after {+ if p.1 == layer {+ ok &= turn_of(chain[chain.len() - 2], b, p.0).map(|t| t <= 1).unwrap_or(true);+ }+ }+ if ok {+ return Some(c);+ }+ }+ None+ }++ /// Commit a finished point list as copper, one width per straight run. A route that leaves a+ /// fine-pitch part stays at the neck to the edge of that part's escape, then opens up if the run+ /// is long enough to be worth a step (4.2: "leave the MCU at 0.15 mm and neck up for long runs").+ /// Anywhere else a run takes its full width right up to the pad, unless copper beside it forbids+ /// that for the whole run.+ pub(crate) fn commit(&mut self, task: &Task, pts: &[(P, usize)], width: i64, neck: i64, also: NetId) -> u32 {+ if task.zone == Some(RAIL_TAP) {+ return self.commit_rail(task, pts, width, neck);+ }+ let id = self.fresh_id();+ let fine = |pad: usize| crate::classify::is_fine_pitch(self.board, &self.rules, self.board.pads[pad].fp);+ let leaves_fine = fine(task.a) || fine(task.b);+ let mut segs = Vec::new();+ let mut vias = Vec::new();+ for w in pts.windows(2) {+ let ((a, la), (b, lb)) = (w[0], w[1]);+ if la != lb {+ if a == b {+ vias.push(a);+ }+ continue;+ }+ if a == b {+ continue;+ }+ for (p, q, inside) in self.split_at_escape(a, b, leaves_fine && width > neck) {+ let w = if inside || width <= neck || !self.seg_clear(p, q, la, width, task.net, also) { neck.min(width) } else { width };+ segs.push(Seg { a: p, b: q, layer: la, width: w });+ }+ }+ crate::post::smooth_widths(&mut segs, width, neck);+ crate::post::merge_runs(&mut segs);+ self.place(id, task.clone(), segs, vias);+ id+ }++ pub(crate) fn fresh_id(&mut self) -> u32 {+ self.next_id += 1;+ self.next_id - 1+ }++ pub(crate) fn place(&mut self, id: u32, task: Task, segs: Vec<Seg>, vias: Vec<P>) {+ let mut handles = Vec::new();+ let mut via_handles = Vec::new();+ for s in &segs {+ let h = self.world.layers[s.layer].add(Shape::Capsule { a: s.a.into(), b: s.b.into(), r: s.width as f64 / 2.0 }, task.net, Owner::Conn(id));+ handles.push((s.layer, h));+ }+ if self.world.partner.contains_key(&task.net) {+ self.world.pair_segs.borrow_mut().entry(task.net).or_default().extend(segs.iter().map(|s| (id, s.layer, V::from(s.a), V::from(s.b))));+ }+ let (via_dia, via_drill) = self.rules.via_for(task.net);+ let vr = via_dia as f64 / 2.0;+ let dr = via_drill as f64 / 2.0;+ for v in &vias {+ for l in 0..self.world.layers.len() {+ let h = self.world.layers[l].add(Shape::Circle { c: (*v).into(), r: vr }, task.net, Owner::Conn(id));+ handles.push((l, h));+ }+ via_handles.push(self.world.holes.add(Shape::Circle { c: (*v).into(), r: dr }, task.net, Owner::Conn(id)));+ }+ self.lane_off(task.a);+ self.lane_off(task.b);+ self.routes.insert(id, Route { id, task, segs, vias, handles, via_handles });+ }++ /// Take a route's copper off the board without counting it as a conflict.+ pub(crate) fn unplace(&mut self, id: u32) -> Option<Route> {+ let r = self.routes.remove(&id)?;+ if let Some(v) = self.world.pair_segs.borrow_mut().get_mut(&r.task.net) {+ v.retain(|s| s.0 != id);+ }+ for (l, h) in &r.handles {+ self.world.layers[*l].remove(*h);+ }+ for h in &r.via_handles {+ self.world.holes.remove(*h);+ }+ Some(r)+ }++ pub fn rip(&mut self, id: u32) -> Option<Task> {+ let r = self.unplace(id)?;+ *self.rip_count.entry((r.task.a, r.task.b)).or_insert(0) += 1;+ self.lane_on(r.task.a);+ self.lane_on(r.task.b);+ Some(r.task)+ }++ /// Time for a closing pass past the routing limit. Both clocks move: a search also checks the+ /// whole-run limit, and left behind it stops every search here at its first look at the clock+ /// (4096 nodes), which reads as no path.+ pub(crate) fn grant_time(&mut self, secs: f64) {+ self.rules.deadline = Some(Instant::now() + std::time::Duration::from_secs_f64(secs));+ self.rules.time_limit = self.start.elapsed().as_secs_f64() + secs;+ }++ fn find_pour_exits(&mut self) {+ // Working number from the reference boards: benchmark B's VBUS pour is 192 mm2 with a connector in it,+ // and the largest such pours on piprobe_v13 and benchmark C are 35 and 73 mm2, local patches+ // that signals may pass.+ const BIG_POUR_MM2: f64 = 100.0;+ self.pour_exit.clear();+ self.pour_exit_pads.clear();+ let mut avoid: HashMap<NetId, Vec<(usize, Vec<V>)>> = HashMap::new();+ for (zi, z) in self.board.zones.iter().enumerate() {+ if z.keepout_tracks || z.net == NO_NET || looks_ground(self.board.net_name(z.net)) || z.outline.len() < 3 {+ continue;+ }+ let n = z.outline.len();+ let area = (0..n).map(|k| z.outline[k].0 * z.outline[(k + 1) % n].1 - z.outline[(k + 1) % n].0 * z.outline[k].1).sum::<f64>().abs() / 2.0 / (NM * NM);+ if area < BIG_POUR_MM2 {+ continue;+ }+ for &net in self.world.pour_open.get(zi).map(|v| v.as_slice()).unwrap_or(&[]) {+ let inside: Vec<usize> = self.board.pads.iter().enumerate().filter(|(_, p)| p.net == net && p.layers.len() == 1 && z.layers.contains(&p.layers[0]) && in_poly(p.center.into(), &z.outline)).map(|(i, _)| i).collect();+ if !inside.is_empty() {+ self.pour_exit.insert(net);+ self.pour_exit_pads.extend(inside);+ let (layers, outline) = (z.layers.clone(), z.outline.clone());+ let list = avoid.entry(net).or_default();+ list.extend(layers.into_iter().map(|l| (l, outline.clone())));+ }+ }+ }+ self.world.pour_avoid = avoid;+ }++ /// A load of at least this share of its rail has to be on the pour for the pour to be the feed.+ fn find_pour_fed(&mut self) {+ const HEAVY_SHARE: f64 = 0.1;+ self.pour_fed.clear();+ for (name, rail) in &self.rules.rails {+ let Some(net) = self.board.net_ids.iter().find(|(k, _)| k.eq_ignore_ascii_case(name)).map(|(_, v)| *v) else { continue };+ let refs_of = |group: &Vec<usize>| -> HashSet<&str> { group.iter().map(|&p| self.board.footprints[self.board.pads[p].fp].reference.as_str()).collect() };+ let fed = crate::conn::net_groups(self.board, net, &[]).iter().any(|g| {+ let refs = refs_of(g);+ rail.sources.iter().any(|s| refs.contains(s.as_str())) && rail.loads.iter().filter(|(_, a)| **a >= HEAVY_SHARE * rail.amps).all(|(k, _)| refs.contains(k.as_str()))+ });+ if fed {+ self.pour_fed.insert(net);+ }+ }+ self.tap_amps.clear();+ for (name, rail) in &self.rules.rails {+ let Some(net) = self.board.net_ids.iter().find(|(k, _)| k.eq_ignore_ascii_case(name)).map(|(_, v)| *v) else { continue };+ if !self.pour_fed.contains(&net) {+ self.tap_amps.extend(crate::current::branch_amps(self.board, net, rail));+ }+ }+ self.power = if std::env::var_os("ADOM_ROUTE_NO_POWER_PLAN").is_some() { Default::default() } else { crate::power::plan(self.board, &self.rules, &self.pour_fed) };+ for n in self.power.notes.clone() {+ self.log.push(n);+ }+ if self.trace {+ let mut v: Vec<(String, f64)> = self.tap_amps.iter().map(|(p, a)| (format!("{} {}", self.board.net_name(self.board.pads[*p].net), self.board.pad_name(*p)), *a)).collect();+ v.sort_by(|x, y| x.0.cmp(&y.0));+ for (n, a) in v {+ eprintln!(" branch {n}: {a:.4} A");+ }+ eprintln!(" pour-fed: {:?}", self.pour_fed.iter().map(|n| self.board.net_name(*n)).collect::<Vec<_>>());+ }+ }++ /// See `pour_plane`. A pour counts when its outline's bounding box covers at least `SHARE` of+ /// the board's; with one on each outer layer, the one opposite most of the net's pads.+ fn find_pour_planes(&mut self) {+ const SHARE: f64 = 0.4;+ self.pour_plane.clear();+ if std::env::var_os("ADOM_ROUTE_NO_POUR_PLANES").is_some() {+ return;+ }+ let bb = self.board.bbox;+ let board_area = ((bb.2 - bb.0) * (bb.3 - bb.1)).max(1.0);+ let last = self.board.layers.len() - 1;+ let inner = self.board.plane_nets();+ let mut big: std::collections::BTreeMap<NetId, Vec<usize>> = Default::default();+ for z in self.board.zones.iter().filter(|z| z.net != NO_NET && !z.keepout_tracks && z.outline.len() >= 3 && !inner.contains_key(&z.net)) {+ let b = z.outline.iter().fold((f64::MAX, f64::MAX, f64::MIN, f64::MIN), |m, v| (m.0.min(v.0), m.1.min(v.1), m.2.max(v.0), m.3.max(v.1)));+ if (b.2 - b.0) * (b.3 - b.1) >= SHARE * board_area {+ for &l in z.layers.iter().filter(|&&l| l == 0 || l == last) {+ let e = big.entry(z.net).or_default();+ if !e.contains(&l) {+ e.push(l);+ }+ }+ }+ }+ for (net, layers) in big {+ let on = |l: usize| self.board.pads.iter().filter(|p| p.net == net && p.drill.is_none() && p.layers.contains(&l)).count();+ let layer = *layers.iter().min_by_key(|&&l| (on(l), std::cmp::Reverse(l))).unwrap();+ self.pour_plane.insert(net, layer);+ }+ }++ pub(crate) fn pour_fed_net(&self, name: &str) -> bool {+ self.board.net_ids.iter().find(|(k, _)| k.eq_ignore_ascii_case(name)).map(|(_, v)| self.pour_fed.contains(v)).unwrap_or(false)+ }++ pub(crate) fn over_time(&self) -> bool {+ self.start.elapsed().as_secs_f64() > self.rules.time_limit+ }++ /// What this bus member should look like: its routed neighbour's shape, carried over to each of+ /// its own two pins. The two copies differ where the pin rows have different pitches, and the+ /// search jogs between them.+ fn bus_hint(&self, task: &Task, m: &crate::bus::Member) -> crate::global::Hint {+ let mut lanes = Vec::new();+ if let Some(((na, nb), swapped)) = m.follow {+ if let Some(r) = self.routes.values().find(|r| r.task.a == na && r.task.b == nb) {+ let (pa, pb) = if swapped { (nb, na) } else { (na, nb) };+ let shift = |own: usize, theirs: usize| {+ let (o, t) = (self.board.pads[own].center, self.board.pads[theirs].center);+ (o.0 - t.0, o.1 - t.1)+ };+ for d in [shift(task.a, pa), shift(task.b, pb)] {+ for s in &r.segs {+ lanes.push((V((s.a.0 + d.0) as f64, (s.a.1 + d.1) as f64), V((s.b.0 + d.0) as f64, (s.b.1 + d.1) as f64), s.layer));+ }+ }+ }+ }+ crate::global::Hint { net: task.net, lanes, layer: Some(m.layer) }+ }++ fn try_route(&mut self, task: &Task) -> bool {+ let t0 = Instant::now();+ let mut r = self.try_route_inner(task);+ // No way off a big pour under the class: a local rule at the pad, for his review (reference choice+ // for J1 on benchmark B). If there is still none, an open join is worse than a long run.+ if !r && !self.pour_relax && self.pour_exit.contains(&task.net) {+ if self.propose_exit_rule(task.a) || (task.b != task.a && self.propose_exit_rule(task.b)) {+ r = self.try_route_inner(task);+ }+ if !r {+ self.pour_relax = true;+ r = self.try_route_inner(task);+ self.pour_relax = false;+ }+ }+ if self.trace {+ eprintln!(" {:<7.2}s {} {} {} -> {} {}", t0.elapsed().as_secs_f64(), if r { "ok " } else { "FAIL" }, task.level.name(), self.board.pad_name(task.a), self.board.pad_name(task.b), self.board.net_name(task.net));+ }+ // Debug: the copper as it stood at the first failed connection, to see what walls it in.+ if !r {+ if let Some(path) = std::env::var_os("ADOM_ROUTE_SNAP_FAIL") {+ let want = std::env::var("ADOM_ROUTE_SNAP_PAD").ok();+ let hit = want.as_ref().map_or(true, |w| *w == self.board.pad_name(task.a) || *w == self.board.pad_name(task.b));+ if hit && !std::path::Path::new(&path).exists() {+ let mut routes: Vec<&Route> = self.routes.values().collect();+ routes.sort_by_key(|r| r.id);+ let _ = std::fs::write(&path, crate::write::render(self.board, &routes, &self.rules));+ eprintln!(" snapshot at the first failure ({} -> {}) written", self.board.pad_name(task.a), self.board.pad_name(task.b));+ }+ }+ }+ r+ }++ pub(crate) fn targets_of(&self, task: &Task) -> Vec<Terminal> {+ match task.zone {+ Some(RAIL_TAP) => self.rail_targets(task, 0.0),+ Some(z) => {+ let (_, layer, poly) = &self.world.zones[z];+ vec![Terminal::Zone { layer: *layer, poly: poly.clone() }]+ }+ None if self.joins_copper(task) => self.join_targets(task),+ None => vec![self.pad_terminal(task.b)],+ }+ }++ fn try_route_inner(&mut self, task: &Task) -> bool {+ if task.zone == Some(RAIL_TAP) {+ return self.try_rail(task);+ }+ let sources = [self.pad_terminal(task.a)];+ let targets = self.targets_of(task);+ let mut profiles = self.attempts(task);+ // A net being moved off a pour leaves the plan's layer behind: that layer is what cut it.+ let member = if self.pour_strict.contains(&task.net) { None } else { self.bus.members.get(&(task.a, task.b)).cloned() };+ self.world.guide.borrow_mut().hint = member.as_ref().map(|m| self.bus_hint(task, m));+ // A bus runs at 2W from the first try (4.2: non-high-speed signals may drop to 2W in dense+ // areas, and a bus is what makes the area dense).+ if member.is_some() {+ for pf in &mut profiles {+ pf.target_gap = pf.target_gap.min(pf.width.max(pf.clearance));+ }+ }+ // First tries on the layer the bus plan chose, where that costs no via: the line that would+ // cross the rest takes the other layer on purpose rather than by finding out.+ let held_sources: Vec<Terminal> = match &member {+ Some(m) if m.free => vec![Terminal::Point { p: self.board.pads[task.a].center, layers: vec![m.layer] }],+ _ => Vec::new(),+ };+ let held_targets: Vec<Terminal> = match &member {+ Some(m) if m.free => vec![Terminal::Point { p: self.board.pads[task.b].center, layers: vec![m.layer] }],+ _ => Vec::new(),+ };+ // Past a third of the time a first try turns cheap, so one hard connection can't spend what+ // the tasks behind it need (benchmark B: 31 failures took 79 of 120 s, and the last 43+ // tasks never got a try). Early on it keeps the full budget: capping it from the start+ // cost piprobe_v13 five joins (91 against 86 of 92).+ let mut rules = self.rules.clone();+ // A move off a pour runs last and has its own clock: nothing is waiting behind it.+ if self.start.elapsed().as_secs_f64() > self.rules.time_limit / 3.0 && !self.pour_strict.contains(&task.net) && !self.pour_exit.contains(&task.net) {+ rules.node_limit = rules.node_limit.min(100_000);+ }+ // Bus members get a real effort on the layer the plan chose first: a member that gives up its+ // layer here crosses the rest later (bus.rs). Nesting round a neighbour's pin on its own layer+ // is the plan working: benchmark B's bottom row does this, at up to 1.7 times the straight+ // distance; beyond 2 it falls through to the weighed attempts.+ if !held_sources.is_empty() {+ let (w, n) = self.widths_for(task);+ let c = self.rules.clearance_for(task.net, task.net);+ let straight = hypot(sub(self.board.pads[task.a].center.into(), self.board.pads[task.b].center.into()));+ for (width, neck, gap, margin) in [(w, n, w.max(c), 1.5 * NM), (w, n, w.max(c), 5.0 * NM), (n, n, c, 8.0 * NM)] {+ let pf = Profile { width, neck, clearance: c, target_gap: gap, max_vias: 0, margin, layer_bias: None };+ self.searches += 1;+ let q = Query { net: task.net, also: None, sources: &held_sources, targets: &held_targets, profile: pf, rules: &rules, soft: None, via_in: None, history: Some(&self.history), free: &[], arrive: None };+ if let Some(path) = search(&self.world, &q) {+ self.nodes += path.nodes as u64;+ let len: f64 = path.pts.windows(2).map(|w| hypot(sub(w[0].0.into(), w[1].0.into()))).sum();+ if len <= 2.0 * straight {+ let pts = self.finish_path(&path, task.net, NO_NET, pf.neck);+ self.commit(task, &pts, pf.width, pf.neck, NO_NET);+ return true;+ }+ }+ }+ }+ let Some((path, pf)) = self.weigh(task, &sources, &targets, &profiles, &rules) else { return false };+ let pts = self.finish_path(&path, task.net, NO_NET, pf.neck);+ self.commit(task, &pts, pf.width, pf.neck, NO_NET);+ true+ }++ /// Plane nets: each pad gets its own via just clear of the pad, on a short stub (4.2, 4.4).+ fn fan_out_pad(&mut self, pad: usize, task: &Task) -> bool {+ // In a big pour a via beside a connector's pad walls off the pads next to it. He ties such a pad+ // straight to its part's own through-hole pad of the net (J1's A1 and B12 to the shell), which+ // reaches the plane already.+ if self.pour_exit_pads.contains(&pad) && self.tie_to_sibling(pad, task) {+ return true;+ }+ let p = &self.board.pads[pad];+ let f = &self.board.footprints[p.fp];+ let exposed = f.pads.len() > 4 && p.size.0 >= 1.0 * NM && p.size.1 >= 1.0 * NM;+ let center = p.center;+ let net = p.net;+ let layer = p.layers.iter().copied().find(|l| self.world.route_layers.contains(l));+ let Some(layer) = layer else { return true };+ if exposed {+ // Thermal vias are placed in the pad; the count must be confirmed for the part's heat.+ let mut placed = Vec::new();+ let (w, h) = (p.size.0, p.size.1);+ let step = 1.0 * NM;+ let nx = ((w - self.rules.via as f64) / step).floor().max(0.0) as i64;+ let ny = ((h - self.rules.via as f64) / step).floor().max(0.0) as i64;+ for j in 0..=ny {+ for i in 0..=nx {+ let v = snap((center.0 + ((i as f64 - nx as f64 / 2.0) * step) as i64, center.1 + ((j as f64 - ny as f64 / 2.0) * step) as i64));+ let q = Query { net, also: None, sources: &[], targets: &[], profile: Profile { width: 0, neck: 0, clearance: self.rules.clearance, target_gap: 0, max_vias: 0, margin: 0.0, layer_bias: None }, rules: &self.rules, soft: None, via_in: Some(pad), history: None, free: &[], arrive: None };+ let (mut b, mut c) = (Vec::new(), 0.0);+ if self.world.via_ok(v, &q, None, &mut b, &mut c) {+ placed.push(v);+ }+ }+ }+ if placed.is_empty() {+ return false;+ }+ self.notes.push(format!("{}: {} thermal vias in the exposed pad; confirm the count against the part's heat (4.4)", self.board.pad_name(pad), placed.len()));+ let id = self.next_id;+ self.next_id += 1;+ self.place(id, task.clone(), Vec::new(), placed);+ return true;+ }+ let vr = self.rules.via as f64 / 2.0;+ let gap = self.rules.pad_via_gap as f64;+ let mut best: Option<(f64, P, i64)> = None;+ let widths = [self.rules.track, self.rules.neck];+ for d in 0..8usize {+ let (dx, dy) = DIRS[d];+ let unit = if dx != 0 && dy != 0 { SQRT2 } else { 1.0 };+ // Walk outward along this direction until the via clears the pad.+ let mut t = 0.0f64;+ while t < 3.0 * NM {+ t += 0.02 * NM;+ let v = snap((center.0 + (dx as f64 * t / unit) as i64, center.1 + (dy as f64 * t / unit) as i64));+ if p.shape.dist_pt(v.into()) < vr + gap {+ continue;+ }+ let q = Query { net, also: None, sources: &[], targets: &[], profile: Profile { width: 0, neck: 0, clearance: self.rules.clearance, target_gap: 0, max_vias: 0, margin: 0.0, layer_bias: None }, rules: &self.rules, soft: None, via_in: None, history: None, free: &[], arrive: None };+ let (mut b, mut c) = (Vec::new(), 0.0);+ if !self.world.via_ok(v, &q, None, &mut b, &mut c) {+ continue;+ }+ let mut w_ok = None;+ for w in widths {+ if self.seg_clear(center, v, layer, w, net, NO_NET) {+ w_ok = Some(w);+ break;+ }+ }+ if let Some(w) = w_ok {+ let score = t + if w == self.rules.neck { 0.05 * NM } else { 0.0 };+ if best.map(|x| score < x.0).unwrap_or(true) {+ best = Some((score, v, w));+ }+ }+ break;+ }+ }+ let Some((_, v, w)) = best else { return false };+ let id = self.next_id;+ self.next_id += 1;+ let segs = vec![Seg { a: snap(center), b: v, layer, width: w }];+ self.place(id, task.clone(), segs, vec![v]);+ true+ }++ /// A pad with nowhere to put its via: the nearest site whose only blockers can move.+ fn fan_out_site(&self, pad: usize, prices: &HashMap<u32, f64>) -> Option<(P, usize, Vec<u32>)> {+ let p = &self.board.pads[pad];+ let (center, net, shape) = (p.center, p.net, p.shape.clone());+ let layer = p.layers.iter().copied().find(|l| self.world.route_layers.contains(l))?;+ let vr = self.rules.via as f64 / 2.0;+ let gap = self.rules.pad_via_gap as f64;+ let price = |c: u32| prices.get(&c).copied().unwrap_or(f64::INFINITY);+ let neck = self.rules.neck;+ let mut best: Option<(f64, P, Vec<u32>)> = None;+ for d in 0..8usize {+ let (dx, dy) = DIRS[d];+ let unit = if dx != 0 && dy != 0 { SQRT2 } else { 1.0 };+ let mut t = 0.0f64;+ while t < 2.0 * NM {+ t += 0.02 * NM;+ let v = snap((center.0 + (dx as f64 * t / unit) as i64, center.1 + (dy as f64 * t / unit) as i64));+ if shape.dist_pt(v.into()) < vr + gap {+ continue;+ }+ let q = Query { net, also: None, sources: &[], targets: &[], profile: Profile { width: 0, neck: 0, clearance: self.rules.clearance, target_gap: 0, max_vias: 0, margin: 0.0, layer_bias: None }, rules: &self.rules, soft: None, via_in: None, history: None, free: &[], arrive: None };+ let (mut blockers, mut c) = (Vec::new(), 0.0);+ if self.world.via_ok(v, &q, Some(&price), &mut blockers, &mut c) && self.stub_clear_soft(center, v, layer, neck, net, &price, &mut blockers) {+ if !blockers.is_empty() {+ let score = t + blockers.len() as f64 * NM;+ if best.as_ref().map(|x| score < x.0).unwrap_or(true) {+ best = Some((score, v, blockers));+ }+ }+ break;+ }+ }+ }+ best.map(|(_, v, b)| (v, layer, b))+ }++ #[allow(clippy::too_many_arguments)]+ fn stub_clear_soft(&self, a: P, b: P, layer: usize, width: i64, net: NetId, price: &dyn Fn(u32) -> f64, blockers: &mut Vec<u32>) -> bool {+ let (va, vb): (V, V) = (a.into(), b.into());+ let half = width as f64 / 2.0;+ let need = half + self.rules.max_clearance() as f64;+ let bb = (va.0.min(vb.0) - need, va.1.min(vb.1) - need, va.0.max(vb.0) + need, va.1.max(vb.1) + need);+ let mut ok = true;+ self.world.layers[layer].visit(bb, |o| {+ if o.net == net {+ return true;+ }+ if o.shape.dist_seg(va, vb) < half + self.rules.clearance_for(net, o.net) as f64 {+ if let Owner::Conn(c) = o.owner {+ if price(c).is_finite() {+ if !blockers.contains(&c) {+ blockers.push(c);+ }+ return true;+ }+ }+ ok = false;+ return false;+ }+ true+ });+ ok+ }++ /// A pair: route one thick centreline, then split it into two parallel tracks at the pair's+ /// width and gap (4.2). The fan-in is only as long as the pads' spread needs, so the coupled run+ /// covers all it can; each end runs straight into its pad's side where the line meets the pad,+ /// which is how a pair keeps its spacing on entry, and otherwise takes a short stub. Falls back+ /// to two separate routes, and says so.+ fn route_pair(&mut self, pn: NetId, nn: NetId, width: i64, gap: i64) -> Vec<Task> {+ let pads_of = |net: NetId| -> Vec<usize> { self.board.pads.iter().enumerate().filter(|(_, p)| p.net == net && !p.layers.is_empty()).map(|(i, _)| i).collect() };+ let (pp, np) = (pads_of(pn), pads_of(nn));+ let mk = |net, a, b| Task { net, level: Level::Pair, a, b, main: false, zone: None };+ if pp.len() < 2 || np.len() < 2 {+ return Vec::new();+ }+ let d = |a: usize, b: usize| hypot(sub(self.board.pads[a].center.into(), self.board.pads[b].center.into()));+ // A net with more pads than two ends (a USB-C connector's two rows, a protection part): the+ // coupled run goes between the pads farthest apart, and the rest join it afterwards.+ let far = |pads: &[usize]| -> (usize, usize) {+ let mut best = (pads[0], pads[1], -1.0);+ for (i, &a) in pads.iter().enumerate() {+ for &b in &pads[i + 1..] {+ if d(a, b) > best.2 {+ best = (a, b, d(a, b));+ }+ }+ }+ (best.0, best.1)+ };+ let (p1, p2) = far(&pp);+ let nearest = |to: usize| -> usize { np.iter().copied().min_by(|&x, &y| d(to, x).total_cmp(&d(to, y))).unwrap() };+ let (n1, n2) = (nearest(p1), nearest(p2));+ if n1 == n2 {+ let mut out = Vec::new();+ for (net, pads) in [(pn, &pp), (nn, &np)] {+ for w in chain_pads(self.board, pads).windows(2) {+ out.push(mk(net, w[1], w[0]));+ }+ }+ return out;+ }+ // Side pads, each to the end nearest it; `targets_of` lets it land on the run instead (4.2).+ let mut rest = Vec::new();+ for (net, pads, e1, e2) in [(pn, &pp, p1, p2), (nn, &np, n1, n2)] {+ for &o in pads.iter().filter(|&&o| o != e1 && o != e2) {+ rest.push(mk(net, o, if d(o, e1) <= d(o, e2) { e1 } else { e2 }));+ }+ }+ let names = format!("{}/{}", self.board.net_name(pn), self.board.net_name(nn));+ // A pair that could not be coupled is two plain long tracks. They keep their place at the head+ // of the queue but lose the pair's protection: repair may move them like any data line. Held+ // as a pair on adom/carrier-fc-rp2350a-mini they sealed a module's pad column and nothing was+ // allowed to push them aside.+ let loose = |t: Task| Task { level: Level::Data, ..t };+ let mut fallback = vec![loose(mk(pn, p1, p2)), loose(mk(nn, n1, n2))];+ fallback.extend(rest.iter().cloned().map(loose));+ // Every layer both far ends reach, those clear of another net's pour first: on benchmark B+ // both ends are through-hole, F.Cu holds the VBUS pour round the connector, and his own pair+ // runs coupled on B.Cu. Taking only the first shared layer gave up with "no room".+ let mut layers: Vec<usize> = self.world.route_layers.iter().copied().filter(|l| self.board.pads[p1].layers.contains(l) && self.board.pads[p2].layers.contains(l)).collect();+ if layers.is_empty() {+ return fallback;+ }+ let in_pour = |l: usize, at: P, zones: &[(NetId, usize, Vec<V>)]| zones.iter().any(|(n, zl, poly)| *zl == l && *n != pn && *n != nn && in_poly(at.into(), poly));+ let (e1, e2) = (self.board.pads[p1].center, self.board.pads[p2].center);+ layers.sort_by_key(|&l| in_pour(l, e1, &self.world.zones) as u8 + in_pour(l, e2, &self.world.zones) as u8);+ // Tracks at pitch need (spread - pitch) / 2 of 45-degree run to come together, plus a little.+ let pitch = (width + gap) as f64;+ let fan = |s: f64| (s - pitch).max(0.0) / 2.0 + 0.3 * NM;+ let (f1, f2) = (fan(d(p1, n1)), fan(d(p2, n2)));+ // Each end opens on its own: a pair's width often only breaks clearance at one fine-pitch end,+ // and growing both would use up a short pair (PCB_Troubleshooting: necked for the last 0.66 mm+ // at 0.4 mm pins). The least fan-in that works wins, keeping the most coupled.+ let mut why: Vec<String> = Vec::new();+ let mut grow: Vec<(f64, f64)> = Vec::new();+ for x in [0.0, 0.2, 0.4, 0.66, 0.9] {+ for y in [0.0, 0.2, 0.4, 0.66, 0.9] {+ grow.push((x, y));+ }+ }+ grow.sort_by(|a, b| (a.0 + a.1).total_cmp(&(b.0 + b.1)));+ for &layer in &layers {+ for &(x, y) in &grow {+ let (r1, r2) = (f1 + x * NM, f2 + y * NM);+ // Straight out from each pad pair first, as he leaves them, in the directions that+ // keep polarity without a crossing. The free search stays as the last try.+ let mut tries: Vec<Option<(P, P)>> = self.pair_run_ins([p1, n1, p2, n2], r1, r2).into_iter().map(Some).collect();+ tries.push(None);+ // Through the connector its side pads belong to first, as he runs it, with all of its+ // exits planned as one group. His own run stands about 0.35 mm off the connector's+ // centre, which is what makes room for the vias, so a few offsets are tried; the+ // first where every exit fits wins. If none does, the plain run below still stands.+ for &offset in &[0i64, -350_000, 350_000, -175_000, 175_000, -525_000, 525_000] {+ let Some((fp, stops)) = self.pair_through([p1, n1, p2, n2], [pn, nn], layer, offset) else { break };+ for ends in &tries {+ match self.try_pair([p1, n1, p2, n2], [pn, nn], layer, width, gap, r1, r2, *ends, &stops) {+ Ok(note) => {+ if self.plan_exits(fp, [pn, nn], layer) {+ // The way out is laid: from here on nothing needs more than a stub in the pour.+ self.pour_limit = crate::router::repair::STUB_RUN;+ self.notes.push(format!("{names}: on {} through its connector with planned exits, {note}", self.board.layers[layer].name));+ return rest;+ }+ let ids: Vec<u32> = self.routes.values().filter(|r| r.task.net == pn || r.task.net == nn).map(|r| r.id).collect();+ for id in ids {+ self.unplace(id);+ }+ break;+ }+ Err(w) => {+ if !why.contains(&w) {+ why.push(w);+ }+ }+ }+ }+ }+ for ends in &tries {+ match self.try_pair([p1, n1, p2, n2], [pn, nn], layer, width, gap, r1, r2, *ends, &[]) {+ Ok(note) => {+ self.notes.push(format!("{names}: on {}, {note}", self.board.layers[layer].name));+ return rest;+ }+ Err(w) => {+ if !why.contains(&w) {+ why.push(w);+ }+ }+ }+ }+ }+ }+ self.notes.push(format!("{names}: {}; routed as two separate tracks, so check impedance and skew", why.join(", then ")));+ fallback+ }++ /// One attempt at a pair with the given fan-in radius at each end.+ #[allow(clippy::too_many_arguments)]+ /// Stops that carry a run straight through a connector holding side pads of both nets, on a layer+ /// those pads don't use. A part with two rows of them (a USB-C receptacle's A and B rows) qualifies.+ /// The run crosses the rows square, a little past each. Nearest the run's start first.+ fn pair_through(&self, pads: [usize; 4], nets: [NetId; 2], layer: usize, offset: i64) -> Option<(usize, Vec<P>)> {+ let [p1, n1, ..] = pads;+ let start = self.board.pads[p1].center;+ let start2 = self.board.pads[n1].center;+ let mid = ((start.0 + start2.0) / 2, (start.1 + start2.1) / 2);+ for (fi, f) in self.board.footprints.iter().enumerate() {+ let mine: Vec<&Pad> = f.pads.iter().map(|&i| &self.board.pads[i]).filter(|p| (p.net == nets[0] || p.net == nets[1]) && !p.layers.contains(&layer)).collect();+ if mine.len() < 4 || !nets.iter().all(|n| mine.iter().any(|p| p.net == *n)) || pads.iter().any(|&i| f.pads.contains(&i)) {+ continue;+ }+ let same = |a: i64, b: i64| (a - b).abs() < 100_000;+ let rows_along_x = mine.iter().any(|p| mine.iter().any(|q| !std::ptr::eq(*p, *q) && same(p.center.1, q.center.1)));+ let b = mine.iter().fold((i64::MAX, i64::MAX, i64::MIN, i64::MIN), |m, p| (m.0.min(p.center.0), m.1.min(p.center.1), m.2.max(p.center.0), m.3.max(p.center.1)));+ let c = ((b.0 + b.2) / 2, (b.1 + b.3) / 2);+ const PAST_THE_ROW: i64 = 2_000_000;+ let (a1, a2) = if rows_along_x { ((c.0 + offset, b.1 - PAST_THE_ROW), (c.0 + offset, b.3 + PAST_THE_ROW)) } else { ((b.0 - PAST_THE_ROW, c.1 + offset), (b.2 + PAST_THE_ROW, c.1 + offset)) };+ let d = |q: P| hypot(sub(q.into(), mid.into()));+ return Some((fi, if d(a1) <= d(a2) { vec![snap(a1), snap(a2)] } else { vec![snap(a2), snap(a1)] }));+ }+ None+ }++ /// A bus as he routes one: its lines in bands of up to `BAND_MAX` neighbours, each band one shared+ /// path at 3W pitch, every line an offset copy of it with a short fan-in to its pins at each end.+ /// A band that doesn't fit whole is taken up again and its lines go one at a time as before.+ /// A bus line's neighbours in its row that are already down, on the bus's layer, for the search+ /// to run beside (search.rs FOLLOW_COST). Cleared for anything not in a bus.+ /// Whether this connection is a line of a planned bus row.+ pub(crate) fn in_bus_row(&self, task: &Task) -> bool {+ std::env::var_os("ADOM_ROUTE_NO_BANDS").is_none() && self.bus.bands.iter().any(|(_, row)| row.contains(&(task.a, task.b)))+ }++ pub(crate) fn set_follow(&self, task: &Task) {+ *self.world.follow.borrow_mut() = None;+ if std::env::var_os("ADOM_ROUTE_NO_BANDS").is_some() {+ return;+ }+ let key = (task.a, task.b);+ let Some((layer, row)) = self.bus.bands.iter().find(|(_, row)| row.contains(&key)) else { return };+ let k = row.iter().position(|x| *x == key).unwrap();+ let near: Vec<(usize, usize)> = [k.checked_sub(1), Some(k + 1)].into_iter().flatten().filter_map(|i| row.get(i).copied()).collect();+ let segs: Vec<(V, V)> = self.routes.values().filter(|r| near.contains(&(r.task.a, r.task.b))).flat_map(|r| r.segs.iter().filter(|s| s.layer == *layer).map(|s| (V::from(s.a), V::from(s.b)))).collect();+ if segs.is_empty() {+ return;+ }+ // Routing packs the row at the pitch so it fits; spread() gives it its room once all is down.+ let w = self.widths_for(task).0;+ let pitch = (3 * w).max(w + self.rules.clearance_for(task.net, task.net)) as f64;+ *self.world.follow.borrow_mut() = Some((*layer, segs, pitch));+ }++ /// Where a coupled run may start and end so it leaves each pad pair square to the pads' axis,+ /// keeping the same net on the same side. Empty if a pair's pads don't line up on the 45-degree+ /// grid, or no combination keeps polarity.+ fn pair_run_ins(&self, pads: [usize; 4], r1: f64, r2: f64) -> Vec<(P, P)> {+ let [p1, n1, p2, n2] = pads;+ let c = |i: usize| self.board.pads[i].center;+ let square = |a: P, b: P, r: f64| -> Vec<(P, (f64, f64))> {+ let Some(d) = dir_of(a, b) else { return Vec::new() };+ let m = ((a.0 + b.0) / 2, (a.1 + b.1) / 2);+ let mut out = Vec::new();+ for k in [2u8, 6] {+ let (dx, dy) = DIRS[((d + k) % 8) as usize];+ let n = if dx != 0 && dy != 0 { (r + PAIR_RUN_IN) / SQRT2 } else { r + PAIR_RUN_IN } as i64;+ out.push((snap((m.0 + dx * n, m.1 + dy * n)), (dx as f64, dy as f64)));+ }+ out+ };+ let side = |h: (f64, f64), m: P, pad: P| h.0 * (pad.1 - m.1) as f64 - h.1 * (pad.0 - m.0) as f64;+ let (m1, m2) = (((c(p1).0 + c(n1).0) / 2, (c(p1).1 + c(n1).1) / 2), ((c(p2).0 + c(n2).0) / 2, (c(p2).1 + c(n2).1) / 2));+ let mut out = Vec::new();+ for (s, h1) in square(c(p1), c(n1), r1) {+ for (e, h2) in square(c(p2), c(n2), r2) {+ // h2 points away from the end; arriving, the run heads the other way.+ if side(h1, m1, c(p1)) * side((-h2.0, -h2.1), m2, c(p2)) > 0.0 {+ out.push((s, e));+ }+ }+ }+ // The shorter way first: the two ends facing each other.+ out.sort_by(|a, b| hypot(sub(a.0.into(), a.1.into())).total_cmp(&hypot(sub(b.0.into(), b.1.into()))));+ out+ }++ #[allow(clippy::too_many_arguments)]+ fn try_pair(&mut self, pads: [usize; 4], nets: [NetId; 2], layer: usize, width: i64, gap: i64, r1: f64, r2: f64, ends: Option<(P, P)>, through: &[P]) -> Result<String, String> {+ let [p1, n1, p2, n2] = pads;+ let [pn, nn] = nets;+ let mid = |a: usize, b: usize| {+ let (x, y) = (self.board.pads[a].center, self.board.pads[b].center);+ snap(((x.0 + y.0) / 2, (x.1 + y.1) / 2))+ };+ let (m1, m2) = (mid(p1, n1), mid(p2, n2));+ let total = width * 2 + gap;+ let (from, to) = ends.unwrap_or((m1, m2));+ let pf = Profile { width: total, neck: total, clearance: self.rules.clearance, target_gap: self.rules.clearance, max_vias: 0, margin: 4.0 * NM, layer_bias: None };+ let free_zones = [(m1, r1 + PAIR_RUN_IN + 10_000.0), (m2, r2 + PAIR_RUN_IN + 10_000.0)];+ let mut stops = vec![from];+ stops.extend_from_slice(through);+ stops.push(to);+ let mut line: Vec<P> = Vec::new();+ for w in stops.windows(2) {+ let sources = [Terminal::Point { p: w[0], layers: vec![layer] }];+ let targets = [Terminal::Point { p: w[1], layers: vec![layer] }];+ let q = Query { net: pn, also: Some(nn), sources: &sources, targets: &targets, profile: pf, rules: &self.rules, soft: None, via_in: None, history: None, free: &free_zones, arrive: None };+ let path = search(&self.world, &q).ok_or("no room for a coupled run")?;+ let pts = self.finish_path(&path, pn, nn, total);+ line.extend(pts.iter().map(|x| x.0));+ }+ line.dedup();+ // Run in from the pad pair straight, so the body keeps that direction past the fan-in.+ if ends.is_some() {+ line.insert(0, m1);+ line.push(m2);+ line.dedup();+ }+ let center = trim_each(&line, r1, r2);+ if center.len() < 2 {+ return Err("the two ends are too close for a coupled run".to_string());+ }+ let off = (width + gap) as f64 / 2.0;+ let side_at = |a: P, b: P, c: P| -> f64 {+ let (ax, ay) = ((b.0 - a.0) as f64, (b.1 - a.1) as f64);+ let (bx, by) = ((c.0 - a.0) as f64, (c.1 - a.1) as f64);+ ax * by - ay * bx+ };+ let last = center.len() - 1;+ let side = if side_at(center[0], center[1], self.board.pads[p1].center) >= 0.0 { 1.0 } else { -1.0 };+ if (side_at(center[last - 1], center[last], self.board.pads[p2].center) >= 0.0) != (side > 0.0) {+ return Err("the pair would have to cross. Consider swapping polarity if the part allows it".to_string());+ }+ let body_p = offset_polyline(¢er, side * off);+ let body_n = offset_polyline(¢er, -side * off);+ for b in [&body_p, &body_n] {+ for w in b.windows(2) {+ if !self.seg_clear(w[0], w[1], layer, width, pn, nn) {+ return Err("the split pair fails clearance".to_string());+ }+ }+ }+ // Searched as one, each track still answers for itself: D+ across D-'s mask opening is a+ // bridge, whichever net the search was carrying.+ for (net, b) in [(pn, &body_p), (nn, &body_n)] {+ for w in b.windows(2) {+ let (va, vb): (V, V) = (w[0].into(), w[1].into());+ if self.world.mask.seg_hit(net, layer, va, vb, width as f64 / 2.0).is_some() || !self.world.holes_clear(&self.rules, va, vb, width as f64 / 2.0, net) {+ return Err("a split track crosses its partner's mask opening or hole".to_string());+ }+ }+ }+ let mut placed: Vec<u32> = Vec::new();+ let mut lens = [0.0f64; 2];+ let mut stubs: [Vec<u32>; 2] = [Vec::new(), Vec::new()];+ let len_of = |b: &[P]| -> f64 { b.windows(2).map(|w| hypot(sub(w[0].into(), w[1].into()))).sum() };+ let bodies: [(NetId, &Vec<P>, usize, usize); 2] = [(pn, &body_p, p1, p2), (nn, &body_n, n1, n2)];+ for (k, (net, body, a, b)) in bodies.into_iter().enumerate() {+ let task = Task { net, level: Level::Pair, a, b, main: false, zone: None };+ let id = self.fresh_id();+ let segs: Vec<Seg> = body.windows(2).map(|w| Seg { a: w[0], b: w[1], layer, width }).collect();+ lens[k] += len_of(body);+ self.place(id, task.clone(), segs, Vec::new());+ placed.push(id);+ let n = body.len();+ let partner = |pad: usize| -> usize { if pad == a { if k == 0 { n1 } else { p1 } } else if k == 0 { n2 } else { p2 } };+ for (pad, end, next) in [(a, body[0], body[1]), (b, body[n - 1], body[n - 2])] {+ let other = nets[1 - k];+ match self.pair_entry(&task, pad, partner(pad), end, next, layer, width, other) {+ Some((sid, l)) => {+ placed.push(sid);+ stubs[k].push(sid);+ lens[k] += l;+ }+ None => {+ for id in placed {+ self.unplace(id);+ }+ return Err("fan-in failed".to_string());+ }+ }+ }+ }+ // The two tracks answer to each other as well. Each was searched with its partner's copper as+ // its own, so nothing above has measured one against the other: where the run leaves the+ // pads away from the way the body goes, a pad entry doubles back beside its partner's (the designer's+ // drone-motor-carrier, 2026-11-10: D+ 0.078 mm from D-, where KiCad wants 0.2).+ {+ let floor = (gap.min(self.rules.clearance_for(pn, nn)) + width) as f64 - 2_000.0;+ let of = |net: NetId| -> Vec<Seg> { placed.iter().filter_map(|id| self.routes.get(id)).filter(|r| r.task.net == net).flat_map(|r| r.segs.iter().cloned()).collect() };+ let (ps, ns) = (of(pn), of(nn));+ let close = ps.iter().any(|a| ns.iter().any(|b| a.layer == b.layer && seg_seg(a.a.into(), a.b.into(), b.a.into(), b.b.into()) < floor));+ if close {+ for id in placed {+ self.unplace(id);+ }+ return Err("its two tracks come closer than their gap at a pad entry".to_string());+ }+ // Nor has either track been measured against its partner's PADS: a part sitting on the pair+ // (a protection diode with one pad on each line) lies in the coupled run's way, and the+ // partner's track went straight through its pad, a short (a large Ethernet switch board,+ // D301.2, 2026-11-10). Full clearance to every pad of the other net.+ let clr = self.rules.clearance_for(pn, nn) as f64 - 2_000.0;+ let hits = |segs: &[Seg], other: NetId| -> bool {+ self.board.pads.iter().filter(|p| p.net == other && !p.layers.is_empty()).any(|p| segs.iter().any(|s| p.layers.contains(&s.layer) && p.shape.dist_seg(s.a.into(), s.b.into()) - s.width as f64 / 2.0 < clr))+ };+ if hits(&ps, nn) || hits(&ns, pn) {+ for id in placed {+ self.unplace(id);+ }+ return Err("one track would touch a pad of its partner's net".to_string());+ }+ }+ // Skew: report it, and make up a real difference with a 45-degree dip on the short net before+ // the coupled run (PCB_Troubleshooting, "A USB pair fails"; 0.5 mm is the limit noted there).+ let mut skew = (lens[0] - lens[1]).abs();+ let mut dip = String::new();+ if skew > 0.5 * NM {+ let short = if lens[0] < lens[1] { 0 } else { 1 };+ if self.skew_dip(&stubs[short], skew) {+ dip = format!("; {:.2} mm made up with a 45-degree dip", skew / NM);+ skew = 0.0;+ } else {+ dip = "; no straight stub long enough to dip, so the skew stands".to_string();+ }+ }+ // Measured on what was laid, pad entries included, not just the body.+ let segs_of = |net: NetId| -> Vec<Seg> { placed.iter().filter_map(|id| self.routes.get(id)).filter(|r| r.task.net == net).flat_map(|r| r.segs.iter().cloned()).collect() };+ let total = crate::post::coupled_len(&segs_of(pn), &segs_of(nn), (width + gap) as f64 + 2_000.0);+ Ok(format!("coupled {:.2} of {:.2} mm at {:.3}/{:.3} mm; skew {:.3} mm{dip}", total / NM, lens[0] / NM, width as f64 / NM, gap as f64 / NM, skew / NM))+ }++ /// Join a pair's body end to its pad. Straight into the pad's side when the body's own line meets+ /// it: that keeps the spacing right up to the pad (4.2 pad entry). Otherwise a short search to+ /// the side of the pad nearest the partner, then to its centre. At the pair's width if it clears,+ /// else necked (at 0.4 mm pins the pair width breaks clearance to the next pin).+ #[allow(clippy::too_many_arguments)]+ fn pair_entry(&mut self, task: &Task, pad: usize, partner: usize, end: P, next: P, layer: usize, width: i64, other: NetId) -> Option<(u32, f64)> {+ let neck = self.rules.neck;+ let shape = self.board.pads[pad].shape.clone();+ let center = self.board.pads[pad].center;+ if shape.dist_pt(end.into()) <= 0.0 {+ let id = self.fresh_id();+ self.place(id, task.clone(), Vec::new(), Vec::new());+ return Some((id, 0.0));+ }+ if let Some(dir) = dir_of(next, end) {+ let (dx, dy) = DIRS[dir as usize];+ let mut t = 0i64;+ while (t as f64) < 1.5 * NM {+ t += 20_000;+ let q = (end.0 + dx * t, end.1 + dy * t);+ if shape.dist_pt(q.into()) > 0.0 {+ continue;+ }+ // Well inside the pad, so the track's end is covered by it.+ let q = (q.0 + dx * 20_000, q.1 + dy * 20_000);+ let q = if shape.dist_pt(q.into()) <= 0.0 { snap(q) } else { snap((q.0 - dx * 20_000, q.1 - dy * 20_000)) };+ for w in [width, neck] {+ if self.seg_clear(end, q, layer, w, task.net, other) {+ let id = self.fresh_id();+ let l = hypot(sub(end.into(), q.into()));+ self.place(id, task.clone(), vec![Seg { a: end, b: q, layer, width: w }], Vec::new());+ return Some((id, l));+ }+ }+ break;+ }+ }+ // Toward the partner by as much as the pad allows: the side of the pad.+ let u = sub(self.board.pads[partner].center.into(), center.into());+ let ul = hypot(u).max(1.0);+ let (ux, uy) = (u.0 / ul, u.1 / ul);+ let c: V = center.into();+ let reach = (shape.support(ux, uy) - (c.0 * ux + c.1 * uy) - neck as f64 / 2.0).max(0.0);+ let side = snap(((c.0 + ux * reach) as i64, (c.1 + uy * reach) as i64));+ for from in [side, center] {+ if shape.dist_pt(from.into()) > 0.0 {+ continue;+ }+ let layers: Vec<usize> = vec![layer];+ let s = [Terminal::Point { p: from, layers: layers.clone() }];+ let t = [Terminal::Point { p: end, layers }];+ for w in [width.min(mm(0.3)), neck] {+ let pf = Profile { width: w, neck: w, clearance: self.rules.clearance, target_gap: self.rules.clearance, max_vias: 0, margin: 1.5 * NM, layer_bias: None };+ let q = Query { net: task.net, also: None, sources: &s, targets: &t, profile: pf, rules: &self.rules, soft: None, via_in: None, history: None, free: &[], arrive: dir_of(end, next) };+ if let Some(path) = search(&self.world, &q) {+ let pts = self.finish_path(&path, task.net, NO_NET, w);+ let segs: Vec<Seg> = pts.windows(2).filter(|w2| w2[0].1 == w2[1].1 && w2[0].0 != w2[1].0).map(|w2| Seg { a: w2[0].0, b: w2[1].0, layer, width: w }).collect();+ let l = segs.iter().map(|s| hypot(sub(s.a.into(), s.b.into()))).sum();+ let id = self.fresh_id();+ self.place(id, task.clone(), segs, Vec::new());+ return Some((id, l));+ }+ }+ }+ None+ }++ /// A 45-degree dip on one of these stub routes, deep enough to add `need` of length: down, along+ /// and back adds 2 d (sqrt 2 - 1). Wherever a straight run is long enough and copper allows.+ fn skew_dip(&mut self, stubs: &[u32], need: f64) -> bool {+ let depth = ((need / (2.0 * (SQRT2 - 1.0))) as i64 + 2_000) & !1;+ for &id in stubs {+ let Some(r) = self.routes.get(&id) else { continue };+ let (task, segs) = (r.task.clone(), r.segs.clone());+ for (i, s) in segs.iter().enumerate() {+ let Some(d) = dir_of(s.a, s.b) else { continue };+ let len = (s.a.0 - s.b.0).abs().max((s.a.1 - s.b.1).abs());+ if len < depth * 2 + 2_000 {+ continue;+ }+ let (dx, dy) = DIRS[d as usize];+ let mid = len / 2;+ for turn in [2i32, -2] {+ // The two legs turn off the run by 45 degrees toward this side.+ let down = DIRS[((d as i32 + turn / 2).rem_euclid(8)) as usize];+ let up = DIRS[((d as i32 - turn / 2).rem_euclid(8)) as usize];+ let start = (s.a.0 + dx * (mid - depth), s.a.1 + dy * (mid - depth));+ let q1 = (start.0 + down.0 * depth, start.1 + down.1 * depth);+ let q2 = (q1.0 + up.0 * depth, q1.1 + up.1 * depth);+ let chain = [s.a, start, q1, q2, s.b];+ let clear = chain.windows(2).all(|w| self.seg_clear(w[0], w[1], s.layer, s.width, task.net, NO_NET));+ if clear && dir_of(q2, s.b) == Some(d) {+ let mut out = segs.clone();+ let repl: Vec<Seg> = chain.windows(2).filter(|w| w[0] != w[1]).map(|w| Seg { a: w[0], b: w[1], layer: s.layer, width: s.width }).collect();+ out.splice(i..=i, repl);+ let vias = self.routes.get(&id).map(|r| r.vias.clone()).unwrap_or_default();+ self.unplace(id);+ self.place(id, task, out, vias);+ return true;+ }+ }+ }+ }+ false+ }++ /// Route the whole board in the hierarchy, repair what's left, and go again with the failures+ /// first while that keeps finding something new. Always returns the fullest result seen, and of+ /// those the cleanest. `tasks` order is level, then listed priority, then shortest first, with+ /// earlier runs' failures first inside their level.+ pub fn run(&mut self, plan: &Plan) -> Outcome {+ let wall0 = std::time::Instant::now();+ let t0 = Instant::now();+ self.start = t0;+ // Part 3 step 8 is part of the job: routing gets three quarters of the limit, and whatever it+ // doesn't use goes to the optimisation pass.+ let total = self.rules.time_limit;+ self.rules.time_limit = total * 0.75;+ self.rules.deadline = Some(t0 + std::time::Duration::from_secs_f64(self.rules.time_limit));+ self.bus = crate::bus::plan(self.board, &self.world, &self.rules, plan);+ self.log.extend(self.bus.notes.iter().cloned());+ self.find_pour_fed();+ self.find_pour_planes();+ self.find_pour_exits();+ self.find_pad_entries(plan);+ self.lay_lanes(plan);+ let mut failed_first: HashSet<(usize, usize)> = HashSet::new();+ let mut best: Option<repair::Best> = None;+ let mut seen: Vec<Vec<(usize, usize)>> = Vec::new();+ for attempt in 0..8 {+ if attempt > 0 && self.over_time() {+ break;+ }+ let run_start = t0.elapsed().as_secs_f64();+ if attempt > 0 {+ let ids: Vec<u32> = self.routes.keys().copied().collect();+ for id in ids {+ self.rip(id);+ }+ self.notes.clear();+ self.history.decay();+ // Rerunning the same plan with the failures first has never beaten the first run+ // here (piprobe_v13: 6 then 6, 4 then 5). A different lane length gives a different+ // board: 0.4 mm and 0.5 mm leave different holes, so try the other one.+ self.lane_len = LANES[if attempt == 1 { 0 } else { attempt % LANES.len() }] * NM;+ // Loose first, on both the bus run and the plain run: it closed piprobe_v13 where tight didn't.+ self.loose = attempt % 3 != 2;+ self.lay_lanes(plan);+ // The bus plan makes the better board, but it can hold a line to a layer that leaves+ // another pin shut (piprobe_v13: 95 of 96 with it at 324 mm, 96 of 96 without at 339+ // mm, his own 330 mm). So the second go is the plain one, with nothing else changed,+ // and `Best` prefers the fullest board.+ self.plain_go = attempt == 1;+ self.bus = if attempt == 1 { Default::default() } else { crate::bus::plan(self.board, &self.world, &self.rules, plan) };+ if attempt == 1 {+ // Nothing carried over either, or it isn't the plain one.+ failed_first.clear();+ self.history = History::new(self.board.bbox);+ self.memory.clear();+ self.rip_count.clear();+ }+ }+ let failed = self.pass(plan, &failed_first);+ let first = failed.len();+ let open = self.repair(failed, &mut best);+ self.log.push(format!("run {}: {} open after the pass, {} after repair, {} rips ({} splices), {:.1} s so far", attempt + 1, first, open.len(), self.rips, self.splices, t0.elapsed().as_secs_f64()));+ if open.is_empty() {+ break;+ }+ // Not enough time left for another whole run to get as far as this one did: a fresh run+ // would be cut short and thrown away (a motor-controller board: run 1 took 524 s and left 3+ // open, run 2 had 65 s and left 24). The time goes to the best board's open joins+ // instead, searched deeper than a normal repair allows.+ let run_secs = t0.elapsed().as_secs_f64() - run_start;+ let left = self.rules.time_limit - self.start.elapsed().as_secs_f64();+ if left > 1.0 && left < 0.5 * run_secs && std::env::var_os("ADOM_ROUTE_NO_LAST_MILE").is_none() {+ if let Some(b) = best.as_ref() {+ let (routes, failed, notes): (Vec<Route>, Vec<Task>, Vec<String>) = (b.routes.values().cloned().collect(), b.failed.clone(), b.notes.clone());+ let ids: Vec<u32> = self.routes.keys().copied().collect();+ for id in ids {+ self.rip(id);+ }+ let mut routes = routes;+ routes.sort_by_key(|r| r.id);+ for r in routes {+ self.place(r.id, r.task, r.segs, r.vias);+ }+ self.rebuild_exit_vias();+ self.notes = notes;+ let before = failed.len();+ self.deep = true;+ let after = self.repair(failed, &mut best);+ self.deep = false;+ self.log.push(format!("last mile: {before} open on the best board, {} after deeper searches, {:.1} s so far", after.len(), t0.elapsed().as_secs_f64()));+ }+ break;+ }+ // The same holes twice means this order has nothing more to give: stop, don't spin.+ let mut key: Vec<(usize, usize)> = open.iter().map(|t| (t.a, t.b)).collect();+ key.sort();+ if seen.contains(&key) {+ break;+ }+ seen.push(key);+ failed_first.extend(open.iter().map(|t| (t.a, t.b)));+ }+ let best = best.expect("repair always records a state");+ let ids: Vec<u32> = self.routes.keys().copied().collect();+ for id in ids {+ self.rip(id);+ }+ for r in best.routes.into_values() {+ self.place(r.id, r.task, r.segs, r.vias);+ }+ self.rebuild_exit_vias();+ self.notes = best.notes;+ self.rules.time_limit = total;+ self.rules.deadline = Some(t0 + std::time::Duration::from_secs_f64(total));+ let count = |t: &Self| -> usize { t.net_info.keys().map(|&n| t.islands(n).len().saturating_sub(1)).sum() };+ let c0 = count(self);+ self.optimize();+ let c1 = count(self);+ self.trim_overlaps();+ let c2 = count(self);+ self.second_vias();+ let c3 = count(self);+ self.log.push(format!("islands: {c0} before optimise, {c1} after, {c2} after trim, {c3} after second vias"));+ self.widen_rails();+ self.heal_pours();+ if std::env::var_os("ADOM_ROUTE_NO_STITCH").is_none() {+ self.grant_time(20.0);+ self.stitch_pours();+ // A join laid by stitching can run over the stub that used to end in the pour.+ self.trim_overlaps();+ }+ self.settle_pad_rules();+ // Tidying re-routes things, and a re-route can lose a chamfer to its new neighbours. These+ // run past the routing deadline on a short one of their own: an unfinished corner or a+ // starved fill is worse than 15 s.+ self.grant_time(15.0);+ self.fix_square_corners();+ // Breathing room last among the moves: everything is joined, rails are at width.+ if std::env::var_os("ADOM_ROUTE_NO_SPREAD").is_none() {+ self.grant_time(20.0);+ self.spread();+ }+ self.stitch_fill_pads();+ self.fix_teardrops();+ self.return_vias();+ // The passes since the rails were sized (spacing, corners, vias) move copper a little, and a+ // rail right at its drop limit can end a hair over it: size once more, as the last thing.+ self.rate_rails_last();+ self.rail_notes();+ self.drop_shorts();+ for t in &best.failed {+ let why = match self.boxed_pads.get(&(t.a, t.b)) {+ Some(&p) => format!("no track can leave {} under the rules: its pad sits closer to a neighbour than the clearance the net classes ask for", self.board.pad_name(p)),+ None => self.why.get(&(t.a, t.b)).cloned().unwrap_or_else(|| "no legal path found within the time limit".to_string()),+ };+ self.notes.push(format!("for the designer: {} {} to {}: {}", self.board.net_name(t.net), self.board.pad_name(t.a), self.board.pad_name(t.b), why));+ }+ self.log.push(format!("clock: {} work units, {:.1} s of the work budget", crate::clock::units(), t0.elapsed().as_secs_f64()));+ // The seconds reported are real ones; budgets run on the work clock (clock.rs).+ Outcome { failed: best.failed, seconds: wall0.elapsed().as_secs_f64() }+ }++ fn pass(&mut self, plan: &Plan, failed_first: &HashSet<(usize, usize)>) -> Vec<Task> {+ let mut queue: Vec<Task> = Vec::new();+ self.exit_via.clear();+ self.exit_tasks.clear();+ self.pour_limit = POUR_RUN;+ self.rail_override.clear();+ self.rail_laid.clear();+ self.rail_tries.clear();+ for (pn, nn, w, g) in &plan.pairs {+ // make_plan holds the gap to what DRC allows between these two nets (2.2).+ let rest = self.route_pair(*pn, *nn, *w, *g);+ queue.extend(rest);+ }+ queue.extend(plan.tasks.iter().cloned());+ // The power stage's rails: its taps in its order, in place of the classifier's trunk and taps.+ // Only for rails this run was asked to route (a fixture routes a few nets and no others).+ let asked: HashSet<NetId> = queue.iter().map(|t| t.net).collect();+ // A via for each pad of a poured net that is not on the pour's layer (`pour_plane`).+ self.pour_pads.clear();+ let mut planes: Vec<(NetId, usize)> = self.pour_plane.iter().map(|(n, l)| (*n, *l)).filter(|(n, _)| asked.contains(n)).collect();+ planes.sort_unstable();+ for (net, layer) in planes {+ for (i, p) in self.board.pads.iter().enumerate().filter(|(_, p)| p.net == net && !p.layers.is_empty() && p.drill.is_none() && !p.layers.contains(&layer)) {+ let _ = p;+ queue.push(Task { net, level: Level::PlaneFanout, a: i, b: i, main: false, zone: None });+ self.pour_pads.insert(i);+ }+ }+ let planned: HashSet<NetId> = self.power.order.iter().map(|(n, _)| *n).filter(|n| asked.contains(n)).collect();+ if !planned.is_empty() {+ queue.retain(|t| !planned.contains(&t.net));+ for (net, taps) in self.power.order.iter().filter(|(n, _)| planned.contains(n)) {+ queue.extend(taps.iter().map(|&p| Task { net: *net, level: Level::Power, a: p, b: p, main: false, zone: Some(RAIL_TAP) }));+ }+ }+ let priority = &self.rules.priority;+ let rank = |t: &Task| -> usize { priority.iter().position(|n| n == self.board.net_name(t.net)).unwrap_or(usize::MAX) };+ // Chains and rails keep the plan's order inside their net: a pad joins what is already laid.+ let mut net_len: HashMap<NetId, i64> = HashMap::new();+ for t in queue.iter().filter(|t| t.a != t.b) {+ let (a, b) = (self.board.pads[t.a].center, self.board.pads[t.b].center);+ net_len.entry(t.net).or_insert((a.0 - b.0).abs().max((a.1 - b.1).abs()));+ }+ let ln = |t: &Task| -> i64 {+ // Rail tasks go after the shorter connections of their level: trunk, then nearest first.+ if t.zone == Some(RAIL_TAP) { return i64::MAX; }+ net_len.get(&t.net).copied().unwrap_or(0)+ };+ // GND via sites are staked out right after the pairs, so a pad doesn't get walled in, but+ // they stay the lowest priority: any route may take one back and it moves (4.4).+ // Rails that need 0.6 mm or more go straight after the pairs: laid after data they have no room+ // (reference choice 2026-11-06, on benchmark C: 301 of 302 and 33.6 mm under width, against 299 and+ // 61.8 mm). Everything else keeps 4.2's order. ADOM_ROUTE_RAILS_LAST restores it, to compare.+ let heavy_first = std::env::var_os("ADOM_ROUTE_RAILS_LAST").is_none();+ let heavy = |t: &Task| heavy_first && t.level == Level::Power && !self.pour_fed.contains(&t.net) && (self.power.pads.contains_key(&t.a) || self.rules.rail(self.board.net_name(t.net)).map(|r| self.rules.rail_width(r.amps, true) >= mm(0.6) || self.widths_for(t).0 >= mm(0.4)).unwrap_or(false));+ // A supply net that lives within a few millimetres (an MCU's VCAP pin and its capacitor, VDDA+ // behind its ferrite) is laid with the first group too: it needs almost no room, its place is+ // fixed by the parts, and laid after the data lines it is walled in. a flight controller board:+ // U1.31 to its VCAP capacitor, 1 mm apart, was one of nine such joins left open.+ let mut extent: HashMap<NetId, (i64, i64, i64, i64)> = HashMap::new();+ for p in self.board.pads.iter().filter(|p| p.net != NO_NET && !p.layers.is_empty()) {+ let e = extent.entry(p.net).or_insert((p.center.0, p.center.1, p.center.0, p.center.1));+ *e = (e.0.min(p.center.0), e.1.min(p.center.1), e.2.max(p.center.0), e.3.max(p.center.1));+ }+ // And on any rail, a supply pin's stub to its own capacitor (the classifier's cap-first task,+ // 4.2): a millimetre or two beside the pin, laid before the pin's neighbours fan out past it.+ // adom/rp2350a-ads1220: the RP2350's three 1.1 V pins were walled in by the data lines.+ let pin_cap = |t: &Task| t.zone == Some(RAIL_TAP) && t.a != t.b && !t.main && {+ let (a, b) = (self.board.pads[t.a].center, self.board.pads[t.b].center);+ (a.0 - b.0).abs().max((a.1 - b.1).abs()) <= mm(PIN_CAP_MM)+ };+ let local = |t: &Task| std::env::var_os("ADOM_ROUTE_NO_LOCAL_FIRST").is_none() && t.level == Level::Power && (pin_cap(t) || extent.get(&t.net).map_or(false, |e| (e.2 - e.0).max(e.3 - e.1) <= mm(LOCAL_NET_MM)));+ let order = |t: &Task| -> Level { if t.level == Level::PlaneFanout || heavy(t) || local(t) { Level::Pair } else { t.level } };+ // The whole-board plan only guides: level order is fixed by the hierarchy (4.2).+ let bus = &self.bus;+ let held_to = |t: &Task| bus.members.get(&(t.a, t.b)).filter(|m| m.free).map(|m| m.layer);+ let global = crate::global::plan(&self.world, &self.rules, self.board, &queue, &held_to);+ queue.sort_by_key(|t| (order(t), !failed_first.contains(&(t.a, t.b)), rank(t), self.bus.order_of(t), ln(t)));+ // Where the rails laid first end: the first task that is neither a pair, a plane fan-out nor one of them.+ let early: HashSet<(usize, usize)> = queue.iter().filter(|t| heavy(t) || local(t) || matches!(t.level, Level::Pair | Level::PlaneFanout)).map(|t| (t.a, t.b)).collect();+ let mut rails_rated = !queue.iter().any(|t| heavy(t));+ self.log.push(global.note.clone());+ let mut failed: Vec<Task> = Vec::new();+ let patience = self.rules.time_limit * 0.5;+ // A bus goes down line after line when its first line's turn comes, each following the one+ // before at the bus pitch: parallel lines, as his benchmark B bus is drawn.+ let follow_on = std::env::var_os("ADOM_ROUTE_NO_BANDS").is_none();+ let bands = self.bus.bands.clone();+ let band_of: HashMap<(usize, usize), (usize, usize)> = bands.iter().enumerate().flat_map(|(i, (_, row))| row.iter().enumerate().map(move |(k, key)| (*key, (i, k)))).collect();+ if follow_on {+ let by_key: HashMap<(usize, usize), Task> = queue.iter().map(|t| ((t.a, t.b), t.clone())).collect();+ let mut emitted: HashSet<usize> = HashSet::new();+ let mut grouped: Vec<Task> = Vec::with_capacity(queue.len());+ for t in queue.drain(..) {+ match band_of.get(&(t.a, t.b)) {+ Some(&(bi, _)) => {+ if emitted.insert(bi) {+ grouped.extend(bands[bi].1.iter().filter_map(|k| by_key.get(k).cloned()));+ }+ }+ None => grouped.push(t),+ }+ }+ queue = grouped;+ }+ let queued = queue.len();+ for (turn_no, task) in queue.into_iter().enumerate() {+ if !rails_rated && !early.contains(&(task.a, task.b)) {+ rails_rated = true;+ self.rate_rails_now();+ }+ self.set_follow(&task);+ if task.level == Level::PlaneFanout && task.a == task.b && task.zone.is_none() {+ if !self.fan_out_pad(task.a, &task) && !self.pour_pads.contains(&task.a) {+ failed.push(task);+ }+ continue;+ }+ if self.over_time() {+ // Out of time, but a join that was never tried is not given up unseen: one short+ // try each (half a second of work, a small search). On adom/esc-g431 21 joins ended+ // as "no legal path found within the time limit" without one search, among them an+ // LED to its resistor.+ let (full, limit, deadline) = (self.rules.node_limit, self.rules.time_limit, self.rules.deadline);+ self.rules.node_limit = full.min(30_000);+ self.rules.time_limit = self.start.elapsed().as_secs_f64() + 0.5;+ self.rules.deadline = Some(Instant::now() + std::time::Duration::from_secs_f64(0.5));+ let done = self.joined(&task) || self.try_route(&task);+ self.rules.node_limit = full;+ self.rules.time_limit = limit;+ self.rules.deadline = deadline;+ if !done {+ failed.push(task);+ }+ continue;+ }+ // Past a third of the time, first tries turn cheap, rails included (try_route_inner says+ // why): on benchmark C rails alone took 196 of 240 s and repair never got a turn.+ let full = self.rules.node_limit;+ if self.start.elapsed().as_secs_f64() > self.rules.time_limit / 3.0 {+ self.rules.node_limit = full.min(100_000);+ }+ // One first try may not take much of the run either: on benchmark B a USB line in its+ // connector took 100 of 240 s to fail, and the last tasks never had a turn.+ // Nor may the hard ones together: each turn gets a share of what is left, four times the+ // even split, so easy tasks leave their time to the hard ones but the last tasks always+ // have a turn. a flight controller board: a dozen stuck data lines took the whole pass, and+ // nine joins of a millimetre or two (an MCU's VCAP pin to its capacitor) were never tried.+ let left = (self.rules.time_limit - self.start.elapsed().as_secs_f64()).max(0.0);+ // OFF unless ADOM_ROUTE_SHARE is set. Measured 2026-11-10: on benchmark C the share cut the+ // in-level repairs short and the run ended at 293 of 302 instead of 300, and on the+ // flight controller it changed nothing (its time went to rail taps, fixed in post.rs).+ let share = if std::env::var_os("ADOM_ROUTE_SHARE").is_none() { f64::INFINITY } else { (left / (queued - turn_no).max(1) as f64 * 4.0).max(1.0) };+ let deadline = self.rules.deadline;+ let turn = Instant::now() + std::time::Duration::from_secs_f64((self.rules.time_limit / 30.0).min(share));+ self.rules.deadline = deadline.map(|d| d.min(turn));+ let done = self.joined(&task) || self.try_route(&task);+ self.rules.node_limit = full;+ self.rules.deadline = deadline;+ if done {+ continue;+ }+ // Repair inside the level, so the hierarchy still decides who gets the room: only+ // equal or lower priority may move here. Everything else waits for the final rounds,+ // and past half the time so does this: every task gets its first try before any task+ // gets a second.+ if self.start.elapsed().as_secs_f64() > patience {+ failed.push(task);+ continue;+ }+ let turn = Instant::now() + std::time::Duration::from_secs_f64((self.rules.time_limit / 10.0).min(share));+ self.rules.deadline = deadline.map(|d| d.min(turn));+ let r = self.repair_one(&task, repair::Mode { escalate: false, swap: false });+ self.rules.deadline = deadline;+ match r {+ repair::Repair::Fixed => {}+ _ => failed.push(task),+ }+ }+ *self.world.follow.borrow_mut() = None;+ self.repair_rails(plan, failed)+ }+}++/// A segment of a few microns between two others is a square corner in disguise, too small to be seen+/// as a chamfer or to be one. Put the real corner back, so the chamfer below can do its job.+fn collapse_jogs(pts: &mut Vec<(P, usize)>) {+ let jog = (0.03 * NM) as i64;+ let mut i = 1;+ while i + 2 < pts.len() {+ let (a, b, c, d) = (pts[i - 1], pts[i], pts[i + 1], pts[i + 2]);+ let short = (b.0 .0 - c.0 .0).abs().max((b.0 .1 - c.0 .1).abs()) < jog;+ if short && a.1 == b.1 && b.1 == c.1 && c.1 == d.1 && b.0 != c.0 {+ if let (Some(d1), Some(d2)) = (dir_of(a.0, b.0), dir_of(c.0, d.0)) {+ if let Some(x) = line_meet(b.0, d1, c.0, d2) {+ pts[i] = (x, b.1);+ pts.remove(i + 1);+ continue;+ }+ }+ }+ i += 1;+ }+}++/// Where the octilinear line through `p` in direction `dp` meets the one through `q` in direction `dq`.+fn line_meet(p: P, dp: u8, q: P, dq: u8) -> Option<P> {+ let (ax, ay) = DIRS[dp as usize];+ let (bx, by) = DIRS[dq as usize];+ let det = ax * by - ay * bx;+ if det == 0 {+ return None;+ }+ let t = ((q.0 - p.0) * by - (q.1 - p.1) * bx) / det;+ Some((p.0 + ax * t, p.1 + ay * t))+}++fn chain_pads(board: &Board, pads: &[usize]) -> Vec<usize> {+ let mut left: Vec<usize> = pads.to_vec();+ let Some(first) = left.pop() else { return Vec::new() };+ let mut seq = vec![first];+ while !left.is_empty() {+ let c: V = board.pads[*seq.last().unwrap()].center.into();+ let (i, _) = left.iter().enumerate().map(|(i, &p)| (i, hypot(sub(c, board.pads[p].center.into())))).min_by(|a, b| a.1.total_cmp(&b.1)).unwrap();+ seq.push(left.swap_remove(i));+ }+ seq+}++/// Remove a length from each end of a polyline.+fn trim_each(pts: &[P], from_start: f64, from_end: f64) -> Vec<P> {+ let cut = |v: Vec<P>, amount: f64| -> Vec<P> {+ let mut left = amount;+ let mut out = v;+ while out.len() >= 2 {+ let l = hypot(sub(out[0].into(), out[1].into()));+ if l <= left {+ left -= l;+ out.remove(0);+ } else {+ let t = left / l;+ let a = out[0];+ out[0] = snap(((a.0 as f64 + (out[1].0 - a.0) as f64 * t) as i64, (a.1 as f64 + (out[1].1 - a.1) as f64 * t) as i64));+ break;+ }+ }+ out+ };+ let mut f = cut(pts.to_vec(), from_start);+ f.reverse();+ let mut g = cut(f, from_end);+ g.reverse();+ g+}++/// Parallel copy of a polyline at a signed distance, mitred at each bend.+fn offset_polyline(pts: &[P], d: f64) -> Vec<P> {+ let n = pts.len();+ let mut out = Vec::with_capacity(n);+ let normal = |a: P, b: P| -> V {+ let (x, y) = ((b.0 - a.0) as f64, (b.1 - a.1) as f64);+ let l = (x * x + y * y).sqrt().max(1.0);+ V(-y / l, x / l)+ };+ for i in 0..n {+ let p: V = pts[i].into();+ let q = if i == 0 {+ let m = normal(pts[0], pts[1]);+ V(p.0 + m.0 * d, p.1 + m.1 * d)+ } else if i == n - 1 {+ let m = normal(pts[n - 2], pts[n - 1]);+ V(p.0 + m.0 * d, p.1 + m.1 * d)+ } else {+ let (m1, m2) = (normal(pts[i - 1], pts[i]), normal(pts[i], pts[i + 1]));+ let bis = V(m1.0 + m2.0, m1.1 + m2.1);+ let l = hypot(bis).max(1e-9);+ let u = V(bis.0 / l, bis.1 / l);+ let scale = d / dot(u, m1).max(0.2);+ V(p.0 + u.0 * scale, p.1 + u.1 * scale)+ };+ out.push(snap(to_p(q)));+ }+ out+}++/// Whether a straight stub from a pad to the nearest point of its own fill meets no other net.+fn zone_stub_clear(board: &Board, rules: &Rules, world: &World, pad: usize, z: usize) -> bool {+ let p = &board.pads[pad];+ let (net, layer, poly) = (p.net, world.zones[z].1, &world.zones[z].2);+ let c: V = p.center.into();+ let n = poly.len();+ let mut near = (f64::INFINITY, c);+ for i in 0..n {+ let (a, b) = (poly[i], poly[(i + 1) % n]);+ let ab = sub(b, a);+ let t = (dot(sub(c, a), ab) / dot(ab, ab).max(1.0)).clamp(0.0, 1.0);+ let q = V(a.0 + ab.0 * t, a.1 + ab.1 * t);+ let d = hypot(sub(c, q));+ if d < near.0 {+ near = (d, q);+ }+ }+ let need = rules.neck as f64 / 2.0 + rules.clearance as f64;+ let bb = (c.0.min(near.1 .0) - need, c.1.min(near.1 .1) - need, c.0.max(near.1 .0) + need, c.1.max(near.1 .1) + need);+ let mut clear = true;+ world.layers[layer].visit(bb, |o| {+ if o.net != net && o.shape.dist_seg(c, near.1) < need {+ clear = false;+ }+ clear+ });+ clear+}++pub struct Plan {+ pub tasks: Vec<Task>,+ pub pairs: Vec<(NetId, NetId, i64, i64)>,+ pub skipped_pads: usize,+}++/// Turn the board into a list of work: what the copper already joins is not routed again.+pub fn make_plan(board: &Board, rules: &Rules) -> Plan {+ let groups = conn::pad_groups(board, &[]);+ let infos = classify(board, rules);+ let world = build_world(board, rules);+ let mut tasks = Vec::new();+ let mut pairs = Vec::new();+ let planes = board.plane_nets();+ let mut skipped = 0;+ for pr in &rules.pairs {+ let (Some(&p), Some(&n)) = (board.net_ids.get(&pr.p), board.net_ids.get(&pr.n)) else { continue };+ let open = |net: NetId| groups.get(&net).map(|g| g.len() > 1).unwrap_or(false);+ if open(p) && open(n) {+ // A pair is never tighter than DRC allows between its own two nets.+ pairs.push((p, n, pr.width, pr.gap.max(rules.clearance_for(p, n))));+ }+ }+ let in_pair: HashSet<NetId> = pairs.iter().flat_map(|x| [x.0, x.1]).collect();+ for info in &infos {+ if in_pair.contains(&info.net) {+ continue;+ }+ let g = groups.get(&info.net).cloned().unwrap_or_default();+ if info.level == Level::PlaneFanout && planes.contains_key(&info.net) {+ // Pads the plane doesn't already reach each get their own via.+ let plane_layers = &planes[&info.net];+ for &pad in &info.pads {+ let p = &board.pads[pad];+ if p.layers.iter().any(|l| plane_layers.contains(l)) {+ continue;+ }+ let joined_to_plane = g.iter().any(|grp| grp.contains(&pad) && grp.iter().any(|&o| board.pads[o].layers.iter().any(|l| plane_layers.contains(l))));+ if joined_to_plane {+ skipped += 1;+ continue;+ }+ tasks.push(Task { net: info.net, level: Level::PlaneFanout, a: pad, b: pad, main: false, zone: None });+ }+ continue;+ }+ // A Kelvin tap is its own dedicated copper, routed first (4.2); the rest of the net is+ // planned without the amplifier's pins, so nothing else can land on them.+ let sense: HashSet<usize> = info.taps.iter().flat_map(|t| t[..t.len() - 1].iter().copied()).collect();+ for t in &info.taps {+ tasks.extend(t.windows(2).map(|w| Task { net: info.net, level: Level::Kelvin, a: w[0], b: w[1], main: false, zone: None }));+ }+ let rest = NetInfo { net: info.net, level: info.level, pads: info.pads.iter().copied().filter(|p| !sense.contains(p)).collect(), why: String::new(), switching: info.switching, taps: Vec::new() };+ let info = &rest;+ let planned = if info.level == Level::Power {+ let zones: Vec<(usize, usize, &Vec<V>)> = (0..world.zones.len()).filter(|&z| world.zones[z].0 == info.net && world.route_layers.contains(&world.zones[z].1)).map(|z| (z, world.zones[z].1, &world.zones[z].2)).collect();+ let live: Vec<Vec<usize>> = g.iter().map(|grp| grp.iter().copied().filter(|p| info.pads.contains(p)).collect::<Vec<_>>()).filter(|grp: &Vec<usize>| !grp.is_empty()).collect();+ if live.len() < 2 && zones.is_empty() { Vec::new() } else { plan_rail(board, info, &live, &zones, &|pad, z| zone_stub_clear(board, rules, &world, pad, z), crate::post::cap_reach(rules)) }+ } else {+ plan_net(board, info, &g)+ };+ tasks.extend(planned);+ }+ Plan { tasks, pairs, skipped_pads: skipped }+}++#[cfg(test)]+mod finish_tests {+ use super::*;++ #[test]+ fn a_micron_jog_is_a_square_corner_again() {+ let n = |x: f64, y: f64| (mm(x), mm(y));+ let mut pts = vec![(n(0.0, 0.0), 0), (n(1.0, 0.0), 0), (n(1.01, 0.01), 0), (n(1.01, 1.0), 0)];+ collapse_jogs(&mut pts);+ assert_eq!(pts, vec![(n(0.0, 0.0), 0), (n(1.01, 0.0), 0), (n(1.01, 1.0), 0)]);+ }+}
src/rules.rsadded+589@@ -0,0 +1,589 @@+//! The designer's routing standards as numbers (PCB_Design_Process.md Part 2.2, 4.2, 4.4). These are the+//! defaults; a spec file may override them, with the same key names the reference board specs already use.++use crate::board::{Board, NetId};+use crate::geom::*;+use serde_json::Value;+use std::collections::{HashMap, HashSet};++#[derive(Clone, Debug)]+pub struct Pair {+ pub p: String,+ pub n: String,+ pub width: i64,+ pub gap: i64,+}++/// One net class as written in the project. A field left out inherits from the Default class.+#[derive(Clone, Debug, Default)]+pub struct NetClass {+ pub name: String,+ /// Lower number wins when a net matches several classes; Default is always last.+ pub priority: i64,+ pub clearance: Option<i64>,+ pub track: Option<i64>,+ pub via_dia: Option<i64>,+ pub via_drill: Option<i64>,+ pub pair_width: Option<i64>,+ pub pair_gap: Option<i64>,+}++/// A net's effective class after merging, every field filled.+#[derive(Clone, Debug)]+pub struct Resolved {+ pub name: String,+ pub clearance: i64,+ pub track: i64,+ pub via_dia: i64,+ pub via_drill: i64,+ pub pair_width: i64,+ pub pair_gap: i64,+}++/// Project-wide minimums (.kicad_pro board/design_settings/rules and .kicad_dru). Zero: not set.+#[derive(Clone, Debug, Default)]+pub struct Minimums {+ pub clearance: i64,+ pub track: i64,+ pub hole_clearance: i64,+ pub hole_to_hole: i64,+ pub via_dia: i64,+ pub through_drill: i64,+ pub annular: i64,+ /// `solder_mask_to_copper_clearance`: what KiCad adds to a pad's mask margin when it tests+ /// copper of another net against that opening.+ pub mask_to_copper: i64,+}++/// Settings the user typed on the command line. The project file governs otherwise, and a spec's+/// numbers can only raise it: a spec is often an old run's, and must not lower his rule unasked. A+/// named setting wins, and the output board's project is updated to match, so DRC and the route agree+/// (reference rule: "if the user initially specifies a default the settings should be updated to match").+#[derive(Clone, Copy, Debug, Default)]+pub struct Explicit {+ pub clearance: Option<i64>,+ pub track: Option<i64>,+ pub neck: Option<i64>,+ pub via: Option<i64>,+ pub drill: Option<i64>,+}++impl Explicit {+ pub fn any(&self) -> bool {+ self.clearance.is_some() || self.track.is_some() || self.neck.is_some() || self.via.is_some() || self.drill.is_some()+ }+}++/// A clearance lowered for one net at one pad, in a .kicad_dru: the recorded exception for a pad no+/// track can leave under its class (reference decision on benchmark B U3.12: a local rule at the pad,+/// for review, rather than a lower class or a broken one).+#[derive(Clone, Debug, PartialEq)]+pub struct PadRule {+ pub net: String,+ pub reference: String,+ pub pad: String,+ pub min: i64,+}++impl PadRule {+ /// The condition KiCad's DRC honours. Measured on KiCad 10 on a copy of benchmark B: this form+ /// clears exactly the pad's two findings; `B.Parent.Reference` matches nothing and says nothing.+ pub fn condition(&self) -> String {+ format!("A.NetName == '{}' && B.memberOfFootprint('{}') && B.Pad_Number == '{}'", self.net, self.reference, self.pad)+ }++ /// Reads a condition `condition` wrote; any other condition is not one of these rules.+ pub fn parse(cond: &str, min: i64) -> Option<PadRule> {+ let rest = cond.strip_prefix("A.NetName == '")?;+ let (net, rest) = rest.split_once("' && B.memberOfFootprint('")?;+ let (reference, rest) = rest.split_once("') && B.Pad_Number == '")?;+ let pad = rest.strip_suffix('\'')?;+ Some(PadRule { net: net.to_string(), reference: reference.to_string(), pad: pad.to_string(), min })+ }+}++/// One rail's current, as approved by the designer: spec `rails`. Part references, and amps.+#[derive(Clone, Debug, Default)]+pub struct RailSpec {+ /// Where the current enters this net.+ pub sources: Vec<String>,+ /// The most the rail can carry: its worst-case sum, or what its source limits it to.+ pub amps: f64,+ /// What each part draws from this net. A part may list up to `amps`; a segment carries the sum+ /// of what lies past it, never more than `amps`.+ pub loads: std::collections::BTreeMap<String, f64>,+ /// The rail's voltage, for the drop limit. None: the spec doesn't say, and no drop is judged+ /// (a voltage is never guessed from a net's name here).+ pub volts: Option<f64>,+}++#[derive(Clone, Debug)]+pub struct Rules {+ pub explicit: Explicit,+ /// In name order, and each rail's loads too: a hash map's order changes on every start of the+ /// program, and sizing the rails in a different order gave different widths (clock.rs's test,+ /// Benchmark C, 2026-11-09).+ pub rails: std::collections::BTreeMap<String, RailSpec>,+ /// Board under 3 x 3 in, set by `bind`: where 4.2's 1.25 x margin and floor apply.+ pub small_board: bool,+ /// Outer copper thickness, um, set by `bind`.+ pub copper_um: f64,+ pub pad_rules: Vec<PadRule>,+ /// `pad_rules` on the bound board: (net, pad index) to clearance.+ pub pad_exit: HashMap<(NetId, u32), i64>,+ /// Minimum clearance on every net, fine-pitch escapes included (2.2).+ pub clearance: i64,+ /// Data line width on open runs, 0.2-0.3 mm (4.2).+ pub track: i64,+ /// Width leaving a fine-pitch part; necks up for long runs (4.2).+ pub neck: i64,+ pub via: i64,+ pub drill: i64,+ pub edge: i64,+ /// Hole to hole, any net (2.2).+ pub hole_hole: i64,+ /// A pad's own via sits this far clear of the pad (4.4).+ pub pad_via_gap: i64,+ /// How far around a fine-pitch part counts as its escape.+ pub escape_margin: f64,+ /// Main-run floor for power on boards under 3 x 3 in (4.2).+ pub power_floor: i64,+ /// Explicit power widths per net: main run, then branches (spec wideNets).+ pub wide: HashMap<String, Vec<i64>>,+ /// Spec `loads` in amps per net and `maxRiseC`: what 4.2 says to size a rail from. Reported beside+ /// the drawn widths; see `Router::rail_notes` for why they don't set a width yet.+ pub loads: HashMap<String, f64>,+ pub max_rise: Option<f64>,+ pub pairs: Vec<Pair>,+ pub kelvin: Vec<String>,+ pub priority: Vec<String>,+ pub plane_nets: Vec<String>,+ pub skip_refs: Vec<String>,+ /// Search cost weights in nm of equivalent track length. Bends are weighed against distance.+ pub cost_bend45: f64,+ pub cost_bend90: f64,+ pub cost_via: f64,+ pub cost_bottom: f64,+ pub cost_tight: f64,+ pub node_limit: usize,+ pub passes: usize,+ /// Whole-run budget in seconds; past it, the best result so far is returned (4.6).+ pub time_limit: f64,+ /// The standing minimum clearance before the project raises `clearance`: never go under it (2.2).+ pub floor: i64,+ pub classes: Vec<NetClass>,+ /// netclass_patterns: (wildcard, class name).+ pub patterns: Vec<(String, String)>,+ /// netclass_assignments: net name to class names.+ pub assignments: HashMap<String, Vec<String>>,+ pub min: Minimums,+ /// spec placement.innerFills: (footprint, net) that must be filled under the part (4.7).+ pub inner_fills: Vec<(String, String)>,+ /// rule_severities from the project (error, warning, ignore).+ pub severities: HashMap<String, String>,+ /// Filled by `bind`: effective classes, the class of each net id, and coupled pairs.+ resolved: Vec<Resolved>,+ net_class: Vec<u32>,+ coupled: HashSet<(NetId, NetId)>,+ max_clearance: i64,+ /// When the search must stop, so the time limit holds even inside one long search.+ pub deadline: Option<crate::clock::Instant>,+ /// A* heuristic inflation; a soft search may run greedier than a first attempt.+ pub search_weight: f64,+}++impl Default for Rules {+ fn default() -> Self {+ Rules {+ explicit: Explicit::default(),+ rails: Default::default(),+ small_board: false,+ copper_um: 35.0,+ pad_rules: Vec::new(),+ pad_exit: HashMap::new(),+ clearance: mm(0.15),+ track: mm(0.2),+ neck: mm(0.15),+ via: mm(0.6),+ drill: mm(0.3),+ edge: mm(0.3),+ hole_hole: mm(0.25),+ pad_via_gap: mm(0.1),+ escape_margin: 1.0 * NM,+ power_floor: mm(0.4),+ wide: HashMap::new(),+ loads: HashMap::new(),+ max_rise: None,+ pairs: Vec::new(),+ kelvin: Vec::new(),+ priority: Vec::new(),+ plane_nets: Vec::new(),+ skip_refs: Vec::new(),+ cost_bend45: 0.3 * NM,+ cost_bend90: 1.2 * NM,+ cost_via: 3.0 * NM,+ cost_bottom: 1.05,+ cost_tight: 1.5,+ node_limit: 800_000,+ passes: 6,+ time_limit: 240.0,+ floor: mm(0.15),+ classes: Vec::new(),+ patterns: Vec::new(),+ assignments: HashMap::new(),+ min: Minimums::default(),+ inner_fills: Vec::new(),+ severities: HashMap::new(),+ resolved: Vec::new(),+ net_class: Vec::new(),+ coupled: HashSet::new(),+ max_clearance: 0,+ deadline: None,+ search_weight: 1.2,+ }+ }+}++impl Rules {+ /// Reads a spec. Unknown keys are ignored, so a board's existing spec file works as it is.+ pub fn load(path: &str) -> Result<Rules, String> {+ let text = std::fs::read_to_string(path).map_err(|e| format!("{path}: {e}"))?;+ let v: Value = serde_json::from_str(&text).map_err(|e| format!("{path}: {e}"))?;+ let mut r = Rules::default();+ let num = |k: &str| v.get(k).and_then(|x| x.as_f64());+ if let Some(x) = num("clearance") { r.clearance = mm(x); r.floor = mm(x); }+ if let Some(x) = num("track") { r.track = mm(x); }+ if let Some(x) = num("stub") { r.neck = mm(x); }+ if let Some(x) = num("viaSignal") { r.via = mm(x); }+ if let Some(x) = num("drillSignal") { r.drill = mm(x); }+ if let Some(x) = num("edgeClearance") { r.edge = mm(x); }+ if let Some(x) = v.pointer("/current/minPowerWidthMm").and_then(|x| x.as_f64()) { r.power_floor = mm(x); }+ if let Some(o) = v.get("wideNets").and_then(|x| x.as_object()) {+ for (k, a) in o {+ let w: Vec<i64> = a.as_array().map(|a| a.iter().filter_map(|x| x.as_f64()).map(mm).collect()).unwrap_or_default();+ r.wide.insert(k.clone(), w);+ }+ }+ if let Some(o) = v.get("impedanceTargets").and_then(|x| x.as_object()) {+ for t in o.values() {+ let (Some(p), Some(n)) = (t.pointer("/pair/0").and_then(|x| x.as_str()), t.pointer("/pair/1").and_then(|x| x.as_str())) else { continue };+ // Zero: the spec doesn't say; find_pairs takes it from the net class, or flags a default.+ let width = t.get("widthMm").and_then(|x| x.as_f64()).map(mm).unwrap_or(0);+ let gap = t.get("gapMm").and_then(|x| x.as_f64()).map(mm).unwrap_or(0);+ r.pairs.push(Pair { p: p.to_string(), n: n.to_string(), width, gap });+ }+ }+ if let Some(o) = v.get("rails").and_then(|x| x.as_object()) {+ for (net, x) in o {+ let list = |k: &str| x.get(k).and_then(|l| l.as_array()).map(|l| l.iter().filter_map(|s| s.as_str().map(String::from)).collect()).unwrap_or_default();+ let loads = x.get("loads").and_then(|l| l.as_object()).map(|m| m.iter().filter_map(|(k, a)| a.as_f64().map(|a| (k.clone(), a))).collect()).unwrap_or_default();+ r.rails.insert(net.clone(), RailSpec { sources: list("sources"), amps: x.get("amps").and_then(|a| a.as_f64()).unwrap_or(0.0), loads, volts: x.get("volts").and_then(|a| a.as_f64()).filter(|v| *v > 0.0) });+ }+ }+ if let Some(o) = v.get("loads").and_then(|x| x.as_object()) {+ for (k, l) in o {+ if let Some(a) = l.get("amps").and_then(|x| x.as_f64()) {+ r.loads.insert(k.clone(), a);+ }+ }+ }+ r.max_rise = num("maxRiseC");+ if let Some(a) = v.get("routePriority").and_then(|x| x.as_array()) {+ r.priority = a.iter().filter_map(|x| x.as_str().map(String::from)).collect();+ }+ if let Some(o) = v.get("kelvin").and_then(|x| x.as_object()) {+ r.kelvin = o.keys().cloned().collect();+ }+ if let Some(o) = v.get("planes").and_then(|x| x.as_object()) {+ r.plane_nets = o.keys().cloned().collect();+ }+ // Parked parts are written as a list or as a map of reference to reason.+ match v.pointer("/placement/parked") {+ Some(Value::Array(a)) => r.skip_refs = a.iter().filter_map(|x| x.as_str().map(String::from)).collect(),+ Some(Value::Object(o)) => r.skip_refs = o.keys().cloned().collect(),+ _ => {}+ }+ if let Some(o) = v.pointer("/placement/innerFills").and_then(|x| x.as_object()) {+ for (fp, nets) in o {+ for n in nets.as_array().map(|a| a.as_slice()).unwrap_or(&[]) {+ if let Some(n) = n.as_str() {+ r.inner_fills.push((fp.clone(), n.to_string()));+ }+ }+ }+ }+ Ok(r)+ }++ /// The spec's rail for this net name, whatever its case (+3v3, +3V3).+ pub fn rail(&self, net: &str) -> Option<&RailSpec> {+ self.rails.get(net).or_else(|| self.rails.iter().find(|(k, _)| k.eq_ignore_ascii_case(net)).map(|(_, v)| v))+ }++ /// Width 4.2 asks for at this current: the conservative chart at the design's rise, 20 C by+ /// default. On a board under 3 x 3 in a main run also gets 1.25 x and the floor.+ pub fn rail_width(&self, amps: f64, main: bool) -> i64 {+ let w = crate::current::conservative_mm(amps, self.max_rise.unwrap_or(20.0), self.copper_um);+ let w = if main && self.small_board { (w * 1.25).max(to_mm(self.power_floor)) } else { w };+ mm((w * 100.0).ceil() / 100.0)+ }++ /// Width that holds the copper's voltage drop over `len` (nm) at `amps` to the limit he set+ /// (current.rs DROP_LIMIT of the rail's `volts`). Zero when any of them is missing.+ pub fn drop_width(&self, amps: f64, len: f64, volts: f64) -> i64 {+ if amps <= 0.0 || len <= 0.0 || volts <= 0.0 || self.copper_um <= 0.0 {+ return 0;+ }+ let w = crate::current::RHO_CU * (len * 1e-9) * amps / (self.copper_um * 1e-6 * volts * crate::current::DROP_LIMIT);+ mm((w * 1000.0 * 100.0).ceil() / 100.0)+ }++ /// Whether the spec records a current for this net.+ pub fn has_load(&self, net: &str) -> bool {+ self.loads.keys().any(|l| l.eq_ignore_ascii_case(net))+ }++ /// Works out every net's effective class on this board. Net ids belong to one parsed file, so+ /// call this again for another board; without it the `*_for` calls answer with the single+ /// strictest number in `clearance`, which is safe but pessimistic.+ pub fn bind(&mut self, board: &Board) {+ let (bw, bh) = (board.bbox.2 - board.bbox.0, board.bbox.3 - board.bbox.1);+ self.small_board = bw > 0.0 && bw < 76.2 * NM && bh < 76.2 * NM;+ self.copper_um = crate::current::outer_copper_um(&board.text);+ self.pad_exit.clear();+ let lowest = self.floor.max(self.min.clearance);+ for rule in &self.pad_rules {+ let Some(&net) = board.net_ids.get(&rule.net) else { continue };+ for (i, p) in board.pads.iter().enumerate() {+ if p.number == rule.pad && board.footprints[p.fp].reference == rule.reference {+ self.pad_exit.insert((net, i as u32), rule.min.max(lowest));+ }+ }+ }+ self.resolved.clear();+ self.net_class = vec![0; board.nets.len()];+ self.coupled.clear();+ self.max_clearance = self.clearance.max(self.floor);+ if self.classes.is_empty() {+ return;+ }+ let mut seen: HashMap<String, u32> = HashMap::new();+ let names: Vec<String> = board.nets.clone();+ for (id, net) in names.iter().enumerate() {+ let matched = self.classes_of(net);+ let key = matched.join("\u{1}");+ let idx = match seen.get(&key) {+ Some(i) => *i,+ None => {+ let i = self.resolved.len() as u32;+ let res = self.resolve(&matched);+ self.max_clearance = self.max_clearance.max(res.clearance);+ self.resolved.push(res);+ seen.insert(key, i);+ i+ }+ };+ self.net_class[id] = idx;+ }+ for (i, n) in names.iter().enumerate() {+ if let Some(m) = pair_partner(n) {+ if let Some(&j) = board.net_ids.get(&m) {+ let (a, b) = (i as NetId, j);+ self.coupled.insert((a.min(b), a.max(b)));+ }+ }+ }+ }++ /// The class names a net belongs to, by explicit assignment and by pattern, best priority first.+ pub fn classes_of(&self, net: &str) -> Vec<String> {+ let mut names: Vec<&str> = Vec::new();+ if let Some(list) = self.assignments.get(net) {+ names.extend(list.iter().map(|s| s.as_str()));+ }+ names.extend(self.patterns.iter().filter(|(p, _)| wildcard(p, net)).map(|(_, c)| c.as_str()));+ let mut v: Vec<&NetClass> = self.classes.iter().filter(|c| names.contains(&c.name.as_str())).collect();+ v.sort_by_key(|c| c.priority);+ v.into_iter().map(|c| c.name.clone()).collect()+ }++ fn resolve(&self, matched: &[String]) -> Resolved {+ let def = self.classes.iter().find(|c| c.name == "Default");+ let mut chain: Vec<&NetClass> = matched.iter().filter_map(|m| self.classes.iter().find(|c| &c.name == m)).collect();+ chain.extend(def);+ let pick = |f: fn(&NetClass) -> Option<i64>, fallback: i64| chain.iter().find_map(|c| f(c)).unwrap_or(fallback);+ Resolved {+ name: chain.first().map(|c| c.name.clone()).unwrap_or_else(|| "Default".into()),+ clearance: pick(|c| c.clearance, self.floor),+ track: pick(|c| c.track, self.track),+ via_dia: pick(|c| c.via_dia, self.via),+ via_drill: pick(|c| c.via_drill, self.drill),+ pair_width: pick(|c| c.pair_width, 0),+ pair_gap: pick(|c| c.pair_gap, 0),+ }+ }++ pub fn class_of(&self, net: NetId) -> Option<&Resolved> {+ self.net_class.get(net as usize).and_then(|&i| self.resolved.get(i as usize))+ }++ /// Clearance between two nets, the way KiCad's DRC decides it: the larger of the two classes;+ /// a coupled differential pair may instead use its class's gap when that is smaller; and never+ /// under the board minimum or the designer's standing minimum (2.2).+ pub fn clearance_for(&self, a: NetId, b: NetId) -> i64 {+ let (Some(ca), Some(cb)) = (self.class_of(a), self.class_of(b)) else { return self.clearance };+ let mut c = ca.clearance.max(cb.clearance);+ if a != b && self.coupled.contains(&(a.min(b), a.max(b))) {+ let gap = ca.pair_gap.max(cb.pair_gap);+ if gap > 0 {+ c = c.min(gap);+ }+ }+ c.max(self.floor).max(self.min.clearance)+ }++ /// `clearance_for`, unless a pad rule lowers it for this net at this pad.+ pub fn clearance_at(&self, net: NetId, other: NetId, pad: Option<u32>) -> i64 {+ pad.and_then(|p| self.pad_exit.get(&(net, p)).copied()).unwrap_or_else(|| self.clearance_for(net, other))+ }++ /// The largest value `clearance_for` can return on the bound board: the reach for an index query.+ pub fn max_clearance(&self) -> i64 {+ self.max_clearance.max(self.clearance).max(self.floor)+ }++ /// The class's track width. A rail class may hold a current width (10 mm on the benchmark B): it+ /// is a default for drawing, not a limit.+ pub fn track_for(&self, net: NetId) -> i64 {+ self.class_of(net).map(|c| c.track).unwrap_or(self.track)+ }++ /// (diameter, drill) of the class's via.+ pub fn via_for(&self, net: NetId) -> (i64, i64) {+ self.class_of(net).map(|c| (c.via_dia, c.via_drill)).unwrap_or((self.via, self.drill))+ }++ /// (width, gap) of the class's differential pair, if it sets one.+ pub fn pair_for(&self, net: NetId) -> Option<(i64, i64)> {+ self.class_of(net).filter(|c| c.pair_width > 0 && c.pair_gap > 0).map(|c| (c.pair_width, c.pair_gap))+ }++ /// Copper of another net to a hole, plated or not. A project's min_hole_clearance when it has one.+ /// His document has no number for this: 2.2's 0.25 is hole to hole, a different rule. The 0.25+ /// here is the value KiCad projects carry untouched (134 of 176 of his project files, and the+ /// Benchmark C and benchmark B; v1.3 sets 0.2), so a board with no project is judged as KiCad would.+ pub fn hole_clearance(&self) -> i64 {+ if self.min.hole_clearance > 0 { self.min.hole_clearance } else { mm(0.25) }+ }++ /// Hole to hole, any net. The project's value is the one confirmed for this design; 2.2's 0.25+ /// is a starting value to confirm.+ pub fn hole_to_hole(&self) -> i64 {+ if self.min.hole_to_hole > 0 { self.min.hole_to_hole } else { self.hole_hole }+ }++ /// Finding severity for a KiCad rule name as this project sets it. A rule the project ignores+ /// stays visible as a warning: hiding it is not this tool's call.+ pub fn severity(&self, rule: &str) -> &'static str {+ match self.severities.get(rule).map(|s| s.as_str()) {+ Some("warning") | Some("ignore") | Some("exclusion") => "warn",+ _ => "error",+ }+ }+}++/// KiCad's pair naming: the same name ending in + and -, or P and N.+pub fn pair_partner(net: &str) -> Option<String> {+ let (stem, last) = net.split_at(net.len().checked_sub(1)?);+ let other = match last {+ "+" => "-",+ "-" => "+",+ "P" => "N",+ "N" => "P",+ _ => return None,+ };+ Some(format!("{stem}{other}"))+}++/// Anchored wildcard match with * and ?, as a netclass pattern is matched. Regular-expression+/// patterns (/.../) are not honoured; project.rs reports them.+pub fn wildcard(pat: &str, s: &str) -> bool {+ let (p, t): (Vec<char>, Vec<char>) = (pat.chars().collect(), s.chars().collect());+ let (mut i, mut j, mut star, mut mark) = (0usize, 0usize, None, 0usize);+ while j < t.len() {+ if i < p.len() && (p[i] == '?' || p[i] == t[j]) {+ i += 1;+ j += 1;+ } else if i < p.len() && p[i] == '*' {+ star = Some(i);+ mark = j;+ i += 1;+ } else if let Some(st) = star {+ i = st + 1;+ mark += 1;+ j = mark;+ } else {+ return false;+ }+ }+ while i < p.len() && p[i] == '*' {+ i += 1;+ }+ i == p.len()+}++#[cfg(test)]+mod tests {+ use super::*;++ #[test]+ fn wildcard_is_anchored() {+ assert!(wildcard("*USB2_D*", "/MCU_USB2_D+"));+ assert!(wildcard("+PS1_SENS", "+PS1_SENS"));+ assert!(!wildcard("-PS1", "-PS10"));+ assert!(wildcard("D?", "D1"));+ assert!(!wildcard("D?", "D12"));+ }++ #[test]+ fn pair_names() {+ assert_eq!(pair_partner("/MCU_USB2_D+").as_deref(), Some("/MCU_USB2_D-"));+ assert_eq!(pair_partner("USB.D_P").as_deref(), Some("USB.D_N"));+ assert_eq!(pair_partner("GND"), None);+ }++ fn board_with(nets: &[&str]) -> Board {+ let mut text = String::from("(kicad_pcb (layers (0 \"F.Cu\" signal) (2 \"B.Cu\" signal))");+ for (i, n) in nets.iter().enumerate() {+ text.push_str(&format!(" (segment (start 0 {i}) (end 1 {i}) (width 0.2) (layer \"F.Cu\") (net \"{n}\"))"));+ }+ text.push(')');+ Board::parse(text).unwrap()+ }++ #[test]+ fn clearance_is_pairwise_and_keeps_the_floor() {+ // Benchmark B's classes: Default 0.2, USB 0.4 with a 0.12 pair gap, a rail at 0.5.+ let b = board_with(&["A", "B", "/USB_D+", "/USB_D-", "RAIL"]);+ let mut r = Rules::default();+ let cl = |name: &str, p: i64, c: f64, gap: Option<f64>| NetClass { name: name.into(), priority: p, clearance: Some(mm(c)), pair_gap: gap.map(mm), pair_width: gap.map(|_| mm(0.2)), ..Default::default() };+ r.classes = vec![cl("Default", i64::MAX, 0.2, Some(0.25)), cl("USB", 7, 0.4, Some(0.12)), cl("RAIL", 0, 0.5, None)];+ r.patterns = vec![("*USB_D*".into(), "USB".into()), ("RAIL".into(), "RAIL".into())];+ r.bind(&b);+ let id = |n: &str| b.net_ids[n];+ assert_eq!(r.clearance_for(id("A"), id("B")), mm(0.2));+ assert_eq!(r.clearance_for(id("A"), id("/USB_D+")), mm(0.4));+ assert_eq!(r.clearance_for(id("RAIL"), id("/USB_D+")), mm(0.5));+ // The coupled pair uses its gap, but 0.12 is under the standing 0.15, so 0.15.+ assert_eq!(r.clearance_for(id("/USB_D+"), id("/USB_D-")), mm(0.15));+ // Fields a class leaves out come from Default.+ assert_eq!(r.class_of(id("RAIL")).unwrap().pair_gap, mm(0.25));+ assert_eq!(r.max_clearance(), mm(0.5));+ }++ #[test]+ fn unbound_rules_answer_with_the_single_number() {+ let r = Rules { clearance: mm(0.17), ..Default::default() };+ assert_eq!(r.clearance_for(1, 2), mm(0.17));+ }+}
src/search.rsadded+1317@@ -0,0 +1,1317 @@+//! The search. Not a grid: an implicit lattice of exact 0/45/90 degree lines through every pad+//! centre and along every obstacle at exact clearance, so a route hugs copper at the clearance it+//! asked for, leaves a pin straight along the pin's own line, and has few bends by construction.+//!+//! What is borrowed from road navigation:+//! - Direction is part of the search state (OSRM's edge-based graph), so a turn's cost is exact+//! instead of depending on which path reached a cell first.+//! - The search runs in rounds by layer changes (RAPTOR's rounds by transfers): first with no via+//! at all, then with vias, so "don't hop to the bottom layer where the top works" is structural.+//!+//! What is borrowed from routers: a legality check against real copper at every step, a slightly+//! inflated A* heuristic (VTR, nextpnr), soft search for finding who is in the way (Freerouting's+//! rip-in-search, KiCadRoutingTools' blocker analysis).+//!+//! What the search can't see from where it stands comes from global.rs: a coarse map that gives the+//! estimate its true way round obstacles, proves "no path" early, and carries the board-wide plan.++use crate::board::*;+use crate::geom::*;+use crate::index::*;+use crate::rules::Rules;+use std::cmp::Reverse;+use std::collections::{BinaryHeap, HashMap, HashSet};++/// A fast multiplicative hash for the search's state table. SipHash took 18 of 61 s on benchmark C+/// (measured with timers round every call), and these keys aren't attacker-controlled.+#[derive(Default, Clone, Copy)]+pub struct Fx(u64);+impl std::hash::Hasher for Fx {+ #[inline]+ fn finish(&self) -> u64 {+ self.0+ }+ #[inline]+ fn write(&mut self, b: &[u8]) {+ for &x in b {+ self.write_u64(x as u64);+ }+ }+ #[inline]+ fn write_u8(&mut self, v: u8) {+ self.write_u64(v as u64);+ }+ #[inline]+ fn write_i64(&mut self, v: i64) {+ self.write_u64(v as u64);+ }+ #[inline]+ fn write_u64(&mut self, v: u64) {+ self.0 = (self.0.rotate_left(5) ^ v).wrapping_mul(0x517c_c1b7_2722_0a95);+ }+}+pub type FxBuild = std::hash::BuildHasherDefault<Fx>;++pub const DIRS: [(i64, i64); 8] = [(1, 0), (1, 1), (0, 1), (-1, 1), (-1, 0), (-1, -1), (0, -1), (1, -1)];+const NO_DIR: u8 = 8;++/// Guide lines, by family: x = a, y = b, x - y = c, x + y = e. All constants are even nanometres,+/// so every crossing is a whole nanometre.+#[derive(Default, Clone)]+pub struct Lines {+ pub v: Vec<i64>,+ pub h: Vec<i64>,+ pub dp: Vec<i64>,+ pub dm: Vec<i64>,+}++#[inline]+fn even_up(v: f64) -> i64 {+ let x = v.ceil() as i64;+ x + (x & 1)+}+#[inline]+fn even_down(v: f64) -> i64 {+ let x = v.floor() as i64;+ x - (x & 1)+}+pub fn snap(p: P) -> P {+ (p.0 & !1, p.1 & !1)+}++impl Lines {+ pub fn add_point(&mut self, p: P) {+ let p = snap(p);+ self.v.push(p.0);+ self.h.push(p.1);+ self.dp.push(p.0 - p.1);+ self.dm.push(p.0 + p.1);+ }+ /// The eight tangents of an outline at distance `r`, rounded outward so clearance is kept.+ pub fn add_tangents(&mut self, s: &Shape, r: f64) {+ let rd = r * SQRT2;+ self.v.push(even_up(s.support(1.0, 0.0) + r));+ self.v.push(even_down(-s.support(-1.0, 0.0) - r));+ self.h.push(even_up(s.support(0.0, 1.0) + r));+ self.h.push(even_down(-s.support(0.0, -1.0) - r));+ self.dp.push(even_up(s.support(1.0, -1.0) + rd));+ self.dp.push(even_down(-s.support(-1.0, 1.0) - rd));+ self.dm.push(even_up(s.support(1.0, 1.0) + rd));+ self.dm.push(even_down(-s.support(-1.0, -1.0) - rd));+ }+ pub fn finish(&mut self) {+ for v in [&mut self.v, &mut self.h, &mut self.dp, &mut self.dm] {+ v.sort_unstable();+ v.dedup();+ }+ }+ #[inline]+ fn on_family(&self, p: P, d: u8) -> bool {+ match d % 4 {+ 0 => self.h.binary_search(&p.1).is_ok(),+ 1 => self.dp.binary_search(&(p.0 - p.1)).is_ok(),+ 2 => self.v.binary_search(&p.0).is_ok(),+ _ => self.dm.binary_search(&(p.0 + p.1)).is_ok(),+ }+ }+ /// Distance along direction `d`, in axis steps, to the next crossing line.+ #[inline]+ fn next_stop(&self, p: P, d: u8) -> Option<i64> {+ let (dx, dy) = DIRS[d as usize];+ let mut t = i64::MAX;+ let next = |a: &[i64], val: i64, up: bool| -> Option<i64> {+ if up {+ let i = a.partition_point(|&x| x <= val);+ a.get(i).map(|&x| x - val)+ } else {+ let i = a.partition_point(|&x| x < val);+ if i > 0 { Some(val - a[i - 1]) } else { None }+ }+ };+ if dx != 0 {+ if let Some(s) = next(&self.v, p.0, dx > 0) {+ t = t.min(s);+ }+ }+ if dy != 0 {+ if let Some(s) = next(&self.h, p.1, dy > 0) {+ t = t.min(s);+ }+ }+ let sp = dx - dy;+ if sp != 0 {+ if let Some(s) = next(&self.dp, p.0 - p.1, sp > 0) {+ t = t.min((s + sp.abs() - 1) / sp.abs());+ }+ }+ let sm = dx + dy;+ if sm != 0 {+ if let Some(s) = next(&self.dm, p.0 + p.1, sm > 0) {+ t = t.min((s + sm.abs() - 1) / sm.abs());+ }+ }+ if t == i64::MAX { None } else { Some(t) }+ }+}++/// Everything already on the board that a route must respect.+/// How much dearer a millimetre inside a pour to leave is than one anywhere else.+const POUR_AVOID_COST: f64 = 8.0;+/// What straying from the bus line before costs per unit length, at two pitches or more away.+const FOLLOW_COST: f64 = 3.0;+/// What a millimetre costs on top of the pair partner's track on another layer. Two tracks stacked+/// through the board are a wall on both layers: on adom/carrier-fc-rp2350a-mini a split USB pair ran+/// stacked for the module's whole height and sealed nine pads behind it.+const STACK_COST: f64 = 6.0;+/// What a millimetre costs inside the thermal-relief gap of another net's pad. KiCad wants two+/// spokes from each such pad into its pour (starved_thermal otherwise); a track through the gap+/// takes the spokes on that side. A cost, not a wall: one side lost is allowed, and where there is+/// no other way the track still goes. a flight controller board came back with 9 starved pads.+const RELIEF_COST: f64 = 2.5;+/// How near the partner's centre line counts as on top of it.+const STACK_BAND: f64 = 0.6 * NM;++pub struct World {+ pub layers: Vec<Grid>,+ pub holes: Grid,+ pub edge: Grid,+ pub route_layers: Vec<usize>,+ pub escapes: Vec<(f64, f64, f64, f64)>,+ pub zones: Vec<(NetId, usize, Vec<V>)>,+ /// The coarse map, its backward searches and the board-wide plan; see global.rs.+ pub guide: std::cell::RefCell<crate::global::Guide>,+ /// The bus line routed just before this one, on its layer: (layer, its segments, the pitch to+ /// keep from it). Running farther than the pitch costs extra, so a bus comes out as parallel+ /// lines, each passing a pad row's gaps on its own, as his benchmark B bus does.+ pub follow: std::cell::RefCell<Option<(usize, Vec<(V, V)>, f64)>>,+ /// Nets with an approved current (the spec's `rails`). Their tracks keep their width on every+ /// layer and neck only for the last stretch into a terminal: the width is what carries the+ /// current, so a stub layer at neck width (4.2's habit for unrated rails) is not allowed them.+ pub rail_nets: std::collections::HashSet<NetId>,+ /// Pads' solder mask openings: foreign copper may not bridge one (KiCad's solder_mask_bridge).+ pub mask: crate::mask::MaskMap,+ /// Each pad's own clearance override, nm (0 when it has none).+ pub pad_clearance: Vec<f64>,+ pub max_local: f64,+ /// Per zone: the other nets with a pad inside its outline. Such a pour can't be a wall to them:+ /// they have to get out, and KiCad refills around them. On benchmark B the USB connector+ /// sits inside the VBUS pour, and his own D+, D-, CC and GND copper crosses it.+ pub pour_open: Vec<Vec<NetId>>,+ /// Big pours each net with a pad inside them should leave at once: layer and outline. Copper in+ /// one costs extra in the search itself, so the way round is found rather than rejected after.+ pub pour_avoid: HashMap<NetId, Vec<(usize, Vec<V>)>>,+ /// Pads that join their own net's pour through thermal spokes, per layer, and each one's relief+ /// gap (by pad index; 0 where there is none). Copper of another net inside that gap cuts spokes:+ /// see RELIEF_COST.+ pub relief: Vec<Grid>,+ pub relief_gap: Vec<f64>,+ pub relief_reach: f64,+ /// Each net of a differential pair and its partner. A pair that could not be coupled is routed+ /// as two tracks; the second must not run on top of the first on another layer (see STACK_COST).+ pub partner: HashMap<NetId, NetId>,+ /// The tracks now on the board for each pair net: (route id, layer, ends). Kept by place/unplace.+ pub pair_segs: std::cell::RefCell<HashMap<NetId, Vec<(u32, usize, V, V)>>>,+}++#[derive(Clone, Copy, Debug)]+pub struct Profile {+ /// Track width on open runs, and inside a fine-pitch escape.+ pub width: i64,+ pub neck: i64,+ /// Hard minimum gap to other nets.+ pub clearance: i64,+ /// Preferred gap (3W target); less costs extra, never below `clearance`.+ pub target_gap: i64,+ pub max_vias: u32,+ pub margin: f64,+ /// A rail's bus layer. `width` then applies on this layer only, everywhere on it; on the other+ /// layers the route is a stub at `neck` and each mm costs more.+ pub layer_bias: Option<usize>,+}++#[derive(Clone, Debug)]+pub enum Terminal {+ /// A pad centre, on any of these layers.+ Point { p: P, layers: Vec<usize> },+ /// Anywhere inside this net's zone copper on a layer.+ Zone { layer: usize, poly: Vec<V> },+ /// Anywhere along a routed track of this net: a tap onto a rail (4.2). `wide` rails take a stub+ /// at 45 or 90 degrees, others only a Y. `stand_off` is how far a via must stay off the track,+ /// measured along the stub (4.4: no via inside a trace, branch 0.6-0.85 mm to the side).+ Track { a: P, b: P, layer: usize, wide: bool, stand_off: f64 },+}++/// A rail's cost per mm off its bus layer (`Profile::layer_bias`): stubs go to the bus and stop.+pub const OFF_BUS: f64 = 3.0;+const RAIL_WEIGHT: f64 = 2.0;++#[derive(Clone, Debug, Default)]+pub struct Path {+ /// Points with their layer; a layer change between neighbours is a via.+ pub pts: Vec<(P, usize)>,+ pub vias: u32,+ pub cost: f64,+ /// Other routes' connection ids this path crosses (only in a soft search).+ pub blockers: Vec<u32>,+ pub nodes: usize,+}++pub struct Query<'a> {+ pub net: NetId,+ /// Nets treated as own copper (a pair searches for both its nets together).+ pub also: Option<NetId>,+ pub sources: &'a [Terminal],+ pub targets: &'a [Terminal],+ pub profile: Profile,+ pub rules: &'a Rules,+ /// May cross routed copper in this set, at a price (who is in the way?).+ pub soft: Option<&'a dyn Fn(u32) -> f64>,+ /// Via stand-off from these pads is waived (own exposed pad).+ pub via_in: Option<usize>,+ pub history: Option<&'a History>,+ /// Circles where clearance isn't checked: a pair's centreline inside its own fan-in. The two+ /// real tracks and their stubs are checked exactly afterwards.+ pub free: &'a [(P, f64)],+ /// The direction the route continues in after the target, so it doesn't arrive at an acute+ /// angle (a stub joining a pair's coupled run).+ pub arrive: Option<u8>,+}++/// How contested each part of the board has been: raised where nets fought, decayed each pass+/// (FastRoute's 0.9 per pass, CUGR's fade). Only contested places are raised, not whole routes.+pub struct History {+ pub x0: f64,+ pub y0: f64,+ pub cell: f64,+ pub w: usize,+ pub h: usize,+ pub v: Vec<f32>,+}++impl History {+ pub fn new(bb: (f64, f64, f64, f64)) -> History {+ let cell = 0.5 * NM;+ let w = ((bb.2 - bb.0) / cell).ceil() as usize + 2;+ let h = ((bb.3 - bb.1) / cell).ceil() as usize + 2;+ History { x0: bb.0, y0: bb.1, cell, w, h, v: vec![0.0; w * h] }+ }+ #[inline]+ pub fn at(&self, p: V) -> f32 {+ let i = ((p.0 - self.x0) / self.cell).max(0.0) as usize;+ let j = ((p.1 - self.y0) / self.cell).max(0.0) as usize;+ if i < self.w && j < self.h { self.v[j * self.w + i] } else { 0.0 }+ }+ pub fn bump(&mut self, p: V) {+ let i = ((p.0 - self.x0) / self.cell).max(0.0) as usize;+ let j = ((p.1 - self.y0) / self.cell).max(0.0) as usize;+ if i < self.w && j < self.h {+ self.v[j * self.w + i] += 1.0;+ }+ }+ pub fn decay(&mut self) {+ for x in &mut self.v {+ *x *= 0.9;+ }+ }+}++impl World {+ /// Whether this copper is a pour this net may cross.+ pub fn crosses_pour(&self, o: &Obs, net: NetId) -> bool {+ match o.owner {+ Owner::Zone(z) => self.pour_open.get(z as usize).map(|v| v.binary_search(&net).is_ok()).unwrap_or(false),+ _ => false,+ }+ }++ pub fn in_escape(&self, p: V) -> bool {+ self.escapes.iter().any(|b| p.0 >= b.0 && p.0 <= b.2 && p.1 >= b.1 && p.1 <= b.3)+ }++ /// Smallest edge distance from a centreline to copper of other nets on a layer, stopping once+ /// it is certainly below `floor` (then a blocker that is not soft-crossable has been met).+ #[inline]+ #[allow(clippy::too_many_arguments)]+ fn gap(&self, rules: &Rules, base: i64, layer: usize, a: V, b: V, net: NetId, also: NetId, reach: f64, floor: f64, soft: Option<&dyn Fn(u32) -> f64>, blockers: &mut Vec<u32>, soft_cost: &mut f64) -> f64 {+ // Clearance is per pair of nets, as KiCad judges it: a 0.5 mm class pushes only what's beside+ // it. The returned gap is normalised to `base`, so callers compare it against one number.+ let reach = reach + (rules.max_clearance() - base).max(0) as f64 + self.max_pad_clearance();+ let bb = (a.0.min(b.0) - reach, a.1.min(b.1) - reach, a.0.max(b.0) + reach, a.1.max(b.1) + reach);+ let mut best = f64::INFINITY;+ self.layers[layer].visit(bb, |o| {+ if o.net == net && net != NO_NET {+ return true;+ }+ if o.net == also && also != NO_NET {+ return true;+ }+ if self.crosses_pour(o, net) {+ return true;+ }+ let local = if let Owner::Pad(i) = o.owner { self.pad_clearance[i as usize] } else { 0.0 };+ let pad = if let Owner::Pad(i) = o.owner { Some(i) } else { None };+ let d = o.dist_within(a, b, reach) - ((rules.clearance_at(net, o.net, pad) as f64).max(local) - base as f64);+ if d < floor {+ if let (Some(f), Owner::Conn(c)) = (soft, o.owner) {+ let price = f(c);+ if price.is_finite() {+ if !blockers.contains(&c) {+ blockers.push(c);+ *soft_cost += price;+ }+ return true;+ }+ }+ best = d;+ return false;+ }+ best = best.min(d);+ true+ });+ best+ }++ /// Edge distance from a centreline to the nearest copper of another net within `reach`, less+ /// any extra clearance that net pair needs over the board's: the room a track has.+ pub fn room(&self, rules: &Rules, layer: usize, a: V, b: V, net: NetId, reach: f64) -> f64 {+ let (mut blockers, mut cost) = (Vec::new(), 0.0);+ self.gap(rules, rules.clearance, layer, a, b, net, NO_NET, reach, f64::NEG_INFINITY, None, &mut blockers, &mut cost).min(reach)+ }++ #[inline]+ pub fn max_pad_clearance(&self) -> f64 {+ self.max_local+ }++ /// Whether a track stays clear of every through hole of another net, plated or not: every+ /// layer sees every hole (KiCad's hole_clearance).+ pub fn holes_clear(&self, rules: &Rules, a: V, b: V, half: f64, net: NetId) -> bool {+ self.holes_clear_also(rules, a, b, half, net, NO_NET)+ }++ /// `holes_clear` for copper shared by a pair: the partner's holes are not another net's.+ pub fn holes_clear_also(&self, rules: &Rules, a: V, b: V, half: f64, net: NetId, also: NetId) -> bool {+ let need = half + rules.hole_clearance() as f64;+ let far = need + self.max_pad_clearance();+ let bb = (a.0.min(b.0) - far, a.1.min(b.1) - far, a.0.max(b.0) + far, a.1.max(b.1) + far);+ let mut ok = true;+ self.holes.visit(bb, |o| {+ if (o.net == net && net != NO_NET) || (o.net == also && also != NO_NET) || matches!(o.shape, Shape::Poly { .. }) {+ return true;+ }+ let local = if let Owner::Pad(i) = o.owner { self.pad_clearance[i as usize] + half } else { 0.0 };+ if o.shape.dist_seg(a, b) < need.max(local) {+ ok = false;+ return false;+ }+ true+ });+ ok+ }++ fn edge_gap(&self, a: V, b: V, reach: f64) -> f64 {+ let bb = (a.0.min(b.0) - reach, a.1.min(b.1) - reach, a.0.max(b.0) + reach, a.1.max(b.1) + reach);+ let mut best = f64::INFINITY;+ self.edge.visit(bb, |o| {+ best = best.min(o.shape.dist_seg(a, b));+ true+ });+ best+ }++ /// Whether a through via can stand here: copper clear on every layer, holes apart, on the+ /// board, and off every pad (via-in-pad needs a decision, 4.4).+ pub fn via_ok(&self, p: P, q: &Query, soft: Option<&dyn Fn(u32) -> f64>, blockers: &mut Vec<u32>, soft_cost: &mut f64) -> bool {+ let r = q.rules;+ let c: V = p.into();+ // The net class's via, not the board's: the size 2.2 says the class may set.+ let (via_dia, via_drill) = r.via_for(q.net);+ let vr = via_dia as f64 / 2.0;+ let also = q.also.unwrap_or(NO_NET);+ let base = r.clearance_for(q.net, q.net);+ for l in 0..self.layers.len() {+ // A micron over: a pad corner here is a polygon inside KiCad's arc (benchmark C: 0.1994 against 0.2).+ let need = vr + base as f64 + 1000.0;+ let d = self.gap(r, base, l, c, c, q.net, also, need, need, soft, blockers, soft_cost);+ if d < need {+ return false;+ }+ }+ // Own-net pads: the via sits just clear of the pad, not in it.+ let own = vr + r.pad_via_gap as f64;+ let bb = (c.0 - own, c.1 - own, c.0 + own, c.1 + own);+ let mut clear = true;+ for l in [0, self.layers.len() - 1] {+ self.layers[l].visit(bb, |o| {+ if let Owner::Pad(i) = o.owner {+ if (o.net == q.net || (also != NO_NET && o.net == also)) && Some(i as usize) != q.via_in && o.shape.dist_pt(c) < own {+ clear = false;+ return false;+ }+ }+ true+ });+ }+ if !clear {+ return false;+ }+ if self.mask.via_hit(q.net, c, vr).is_some() || !self.holes_clear(r, c, c, vr, q.net) {+ return false;+ }+ let hole = via_drill as f64 / 2.0 + r.hole_to_hole() as f64;+ let bb = (c.0 - hole, c.1 - hole, c.0 + hole, c.1 + hole);+ self.holes.visit(bb, |o| {+ if o.shape.dist_pt(c) < hole {+ clear = false;+ return false;+ }+ true+ });+ if !clear {+ return false;+ }+ let e = vr + r.edge as f64;+ self.edge_gap(c, c, e) >= e+ }+}++fn octile(a: P, b: P) -> f64 {+ let (dx, dy) = ((a.0 - b.0).abs() as f64, (a.1 - b.1).abs() as f64);+ let (hi, lo) = if dx > dy { (dx, dy) } else { (dy, dx) };+ (hi - lo) + lo * SQRT2+}++fn near_poly(p: P, poly: &[V]) -> P {+ let c: V = p.into();+ if in_poly(c, poly) {+ return p;+ }+ let mut best = (f64::INFINITY, c);+ let n = poly.len();+ for i in 0..n {+ let (a, b) = (poly[i], poly[(i + 1) % n]);+ let ab = sub(b, a);+ let l2 = dot(ab, ab).max(1.0);+ let t = (dot(sub(c, a), ab) / l2).clamp(0.0, 1.0);+ let q = V(a.0 + ab.0 * t, a.1 + ab.1 * t);+ let d = hypot(sub(c, q));+ if d < best.0 {+ best = (d, q);+ }+ }+ to_p(best.1)+}++struct Node {+ p: P,+ layer: u8,+ dir: u8,+ vias: u8,+ parent: u32,+ g: f64,+ /// Not yet checked against copper. Moves are validated only when the search picks them+ /// (lazy A*), which is most of the speed: most candidate moves are never looked at.+ pending: Pending,+}++#[derive(Clone, Copy, PartialEq)]+enum Pending {+ None,+ Move { from: P },+ Via,+}++thread_local! {+ /// Whether the last failed search stopped on its budget rather than exhausting its window:+ /// the difference between "ran out" and "walled in" when a task goes to the designer.+ pub static RAN_OUT: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };+}++#[inline]+fn soft_ignores(q: &Query, o: &Obs) -> bool {+ match (q.soft, o.owner) {+ (Some(f), Owner::Conn(c)) => f(c).is_finite(),+ _ => false,+ }+}++/// Build the guide lines for a search window.+pub fn build_lines(w: &World, q: &Query, window: (f64, f64, f64, f64)) -> Lines {+ build_lines_on(w, q, window, &w.route_layers)+}++/// The same, from the copper on the given layers only. A rail's bus layer is mostly empty: it has no+/// use for a line along every pad on the other side of the board, and each one multiplies the states.+pub fn build_lines_on(w: &World, q: &Query, window: (f64, f64, f64, f64), layers: &[usize]) -> Lines {+ let mut lines = Lines::default();+ let r = q.rules;+ let p = q.profile;+ let also = q.also.unwrap_or(NO_NET);+ let mut terminal = |t: &Terminal, lines: &mut Lines| match t {+ Terminal::Point { p, .. } => lines.add_point(*p),+ Terminal::Zone { poly, .. } => {+ let b = Shape::Poly { pts: poly.clone(), r: 0.0 }.bbox();+ lines.add_point(((b.0 + b.2) as i64 / 2, (b.1 + b.3) as i64 / 2));+ }+ Terminal::Track { a, b, .. } => {+ add_track_lines(lines, *a, *b);+ lines.add_point(*a);+ lines.add_point(*b);+ }+ };+ for t in q.sources.iter().chain(q.targets.iter()) {+ terminal(t, &mut lines);+ }+ let wide = p.width as f64 / 2.0;+ let neck = p.neck as f64 / 2.0;+ let tgt = p.target_gap as f64 + 1000.0;+ for &l in layers {+ w.layers[l].visit(window, |o| {+ let own = o.net == q.net || (also != NO_NET && o.net == also);+ // A soft search crosses movable copper, so it has no use for lines around it, and+ // those lines are most of the lattice on a dense board (see Router::soft_path).+ if soft_ignores(q, o) {+ return true;+ }+ if !own {+ let at_pad = if let Owner::Pad(i) = o.owner { Some(i) } else { None };+ let clr = r.clearance_at(q.net, o.net, at_pad);+ let c = clr as f64 + 1000.0;+ let via_r = r.via_for(q.net).0 as f64 / 2.0 + clr as f64 + 1000.0;+ // Hug at the neck's clearance; at the run's own width if that is meaningfully wider.+ lines.add_tangents(&o.shape, neck + c);+ if wide > neck + 40_000.0 {+ lines.add_tangents(&o.shape, wide + c);+ }+ // The preferred spacing, along routed tracks only: that is what makes a bus.+ if tgt > c && matches!(o.owner, Owner::Conn(_) | Owner::Fixed) {+ lines.add_tangents(&o.shape, wide + tgt);+ }+ if q.profile.max_vias > 0 && !matches!(o.owner, Owner::Keepout | Owner::Zone(_)) {+ let (b, _) = (o.bb, 0);+ lines.v.push(even_up(b.2 + via_r));+ lines.v.push(even_down(b.0 - via_r));+ lines.h.push(even_up(b.3 + via_r));+ lines.h.push(even_down(b.1 - via_r));+ }+ }+ if let Owner::Pad(i) = o.owner {+ // Hug a pad's solder mask opening too, where it reaches past the clearance.+ let limit = w.mask.limit_of(i as usize);+ if !own && limit > r.clearance_for(q.net, o.net) as f64 {+ lines.add_tangents(&o.shape, neck + limit + 1000.0);+ // And a second set clear of the laying guard: the first serves runs along a+ // straight side, this one a diagonal past a rounded corner (mask.rs `curved_near`).+ if w.mask.guard() > 0.0 {+ lines.add_tangents(&o.shape, neck + limit + w.mask.guard() + 1000.0);+ }+ }+ let b = o.bb;+ let cx = ((b.0 + b.2) / 2.0) as i64;+ let cy = ((b.1 + b.3) / 2.0) as i64;+ // A neighbouring pin's own line: parallel runs at pin pitch (4.7).+ lines.v.push(cx & !1);+ lines.h.push(cy & !1);+ }+ true+ });+ }+ w.edge.visit(window, |o| {+ lines.add_tangents(&o.shape, wide + r.edge as f64 + 1000.0);+ true+ });+ w.holes.visit(window, |o| {+ if o.net != q.net && !matches!(o.shape, Shape::Poly { .. }) {+ let local = if let Owner::Pad(i) = o.owner { w.pad_clearance[i as usize] } else { 0.0 };+ lines.add_tangents(&o.shape, neck + local.max(r.hole_clearance() as f64) + 1000.0);+ }+ true+ });+ lines.finish();+ lines+}++/// The line a track lies on, so a search stops exactly where it crosses that track.+fn add_track_lines(lines: &mut Lines, a: P, b: P) {+ let (dx, dy) = (b.0 - a.0, b.1 - a.1);+ if dy == 0 {+ lines.h.push(a.1);+ } else if dx == 0 {+ lines.v.push(a.0);+ } else if dx == dy {+ lines.dp.push(a.0 - a.1);+ } else if dx == -dy {+ lines.dm.push(a.0 + a.1);+ }+}++/// Whether a route arriving at `p` on `layer` heading `dir` may end on this track. A square T only+/// off a wide rail, never parallel, and any via at the required distance from the track.+#[allow(clippy::too_many_arguments)]+fn track_accepts(t: &Terminal, p: P, layer: usize, dir: u8, last_via: Option<P>) -> bool {+ let Terminal::Track { a, b, layer: tl, wide, stand_off } = t else { return false };+ if *tl != layer || pt_seg(p.into(), (*a).into(), (*b).into()) > 2.0 {+ return false;+ }+ if let Some(v) = last_via {+ if pt_seg(v.into(), (*a).into(), (*b).into()) < *stand_off {+ return false;+ }+ }+ // Straight off a via onto the track: a via inside a trace (4.4), only as a marked last resort.+ if dir == NO_DIR {+ return *stand_off < 0.0;+ }+ let (dx, dy) = (b.0 - a.0, b.1 - a.1);+ let Some(axis) = DIRS.iter().position(|d| d.0 == dx.signum() && d.1 == dy.signum() && (dx.abs() == dy.abs() || dx == 0 || dy == 0)) else { return true };+ // Landing on the track's very end is a corner, not a junction: it must not fold back acutely.+ for (end, onward) in [(*a, axis as i32), (*b, axis as i32 + 4)] {+ if octile(p, end) <= 2.0 {+ let t = (onward - dir as i32).rem_euclid(8);+ if t.min(8 - t) > if *wide { 2 } else { 1 } {+ return false;+ }+ }+ }+ let turn = (axis as i32 - dir as i32).rem_euclid(4);+ let turn = turn.min(4 - turn);+ match turn {+ 0 => false,+ 1 => true,+ _ => *wide,+ }+}++/// A* over the lattice from any source to any target. Returns None when no legal path exists in+/// the window within the node budget.+pub fn search(w: &World, q: &Query) -> Option<Path> {+ RAN_OUT.with(|c| c.set(false));+ crate::clock::tick(crate::clock::SEARCH_START);+ let t0 = std::time::Instant::now();+ let mut nodes = 0usize;+ let found = search_inner(w, q, &mut nodes);+ {+ let mut g = w.guide.borrow_mut();+ g.searches += 1;+ if found.is_some() {+ g.nodes_ok += nodes as u64;+ } else {+ g.nodes_failed += nodes as u64;+ g.note_failed(q);+ }+ }+ if trace() {+ eprintln!(" search vias={} margin={:.1} w={:.2} gap={:.2} soft={} -> {} nodes={} {:.1} ms", q.profile.max_vias, q.profile.margin / NM, q.profile.width as f64 / NM, q.profile.target_gap as f64 / NM, q.soft.is_some(), found.is_some(), nodes, t0.elapsed().as_secs_f64() * 1000.0);+ }+ found+}++fn search_inner(w: &World, q: &Query, node_count: &mut usize) -> Option<Path> {+ let r = q.rules;+ let pf = q.profile;+ // Window: the bounding box of the terminals plus a margin.+ let (mut x0, mut y0, mut x1, mut y1) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);+ for t in q.sources.iter().chain(q.targets.iter()) {+ let b = match t {+ Terminal::Point { p, .. } => (p.0 as f64, p.1 as f64, p.0 as f64, p.1 as f64),+ Terminal::Zone { poly, .. } => Shape::Poly { pts: poly.clone(), r: 0.0 }.bbox(),+ Terminal::Track { a, b, .. } => ((a.0.min(b.0)) as f64, (a.1.min(b.1)) as f64, (a.0.max(b.0)) as f64, (a.1.max(b.1)) as f64),+ };+ x0 = x0.min(b.0);+ y0 = y0.min(b.1);+ x1 = x1.max(b.2);+ y1 = y1.max(b.3);+ }+ let mut window = (x0 - pf.margin, y0 - pf.margin, x1 + pf.margin, y1 + pf.margin);+ // A planned detour may leave the terminals' box: the first attempt can already follow it.+ if q.soft.is_none() {+ if let Some(cr) = w.guide.borrow().corridor_of(q) {+ let b = cr.bbox;+ window = (window.0.min(b.0), window.1.min(b.1), window.2.max(b.2), window.3.max(b.3));+ }+ }+ let table = w.guide.borrow_mut().prepare(w, q, window);+ let audit = audit_guide();+ let walled = table.is_some() && w.guide.borrow_mut().hopeless(w, q);+ if walled && !audit {+ return None;+ }+ let started = *w.guide.borrow_mut().start.get_or_insert_with(crate::clock::Instant::now);+ let hint = if q.soft.is_none() { w.guide.borrow().hint.as_ref().filter(|h| h.net == q.net).cloned() } else { None };+ let mut lines = build_lines(w, q, window);+ if let Some(h) = &hint {+ for (a, b, _) in &h.lanes {+ lines.add_point(to_p(*a));+ lines.add_point(to_p(*b));+ }+ lines.finish();+ }+ let bus_lines = pf.layer_bias.map(|l| build_lines_on(w, q, window, &[l]));+ let also = q.also.unwrap_or(NO_NET);+ // The targets' own lines: a route may turn onto one of these anywhere.+ let mut term = Lines::default();+ for t in q.targets {+ match t {+ Terminal::Point { p, .. } => term.add_point(*p),+ Terminal::Zone { poly, .. } => {+ let b = Shape::Poly { pts: poly.clone(), r: 0.0 }.bbox();+ term.add_point(((b.0 + b.2) as i64 / 2, (b.1 + b.3) as i64 / 2));+ }+ // A route turns toward a track where it meets a line through the track's ends: empty+ // bus layers have no copper to bend at otherwise.+ Terminal::Track { a, b, .. } => {+ term.add_point(*a);+ term.add_point(*b);+ }+ }+ }+ // A bus member may also turn onto its neighbour's shape anywhere.+ if let Some(h) = &hint {+ for (a, b, _) in &h.lanes {+ term.add_point(to_p(*a));+ term.add_point(to_p(*b));+ }+ }+ term.finish();+ // A shortest route only bends where it wraps something: next to copper, or onto a target's+ // line. Everywhere else it goes straight. This is what keeps the lattice small.+ let bend_reach = ((pf.width.max(pf.neck) as f64 / 2.0 + pf.target_gap.max(pf.clearance) as f64) * 1.2 + 40_000.0).max(r.via as f64 / 2.0 + r.clearance as f64 + 60_000.0);+ // The hugging lines lie at each neighbour's own class clearance, so the turn must be allowed out+ // there: at a USB class's 0.4 mm they lay outside this reach, every legal bend read as open+ // copper, and a net of a wide class had nowhere to turn (benchmark B's pair, "no room").+ let bend_half = pf.width.max(pf.neck) as f64 / 2.0;+ let bend_far = bend_reach.max(bend_half + r.max_clearance() as f64 * 1.2 + 40_000.0);+ let near_copper = |p: P, layer: usize| -> bool {+ let c: V = p.into();+ let bb = (c.0 - bend_far, c.1 - bend_far, c.0 + bend_far, c.1 + bend_far);+ let mut hit = false;+ w.layers[layer].visit(bb, |o| {+ if (o.net == q.net && q.net != NO_NET) || (also != NO_NET && o.net == also) || soft_ignores(q, o) {+ return true;+ }+ let reach = bend_reach.max(bend_half + r.clearance_for(q.net, o.net) as f64 * 1.2 + 40_000.0);+ if o.dist_within(c, c, reach * 1.01) <= reach {+ hit = true;+ return false;+ }+ true+ });+ if !hit {+ w.edge.visit(bb, |o| {+ if o.dist_within(c, c, bend_reach * 1.01) <= bend_reach {+ hit = true;+ return false;+ }+ true+ });+ }+ hit+ };++ // Target positions for the heuristic and the arrival test.+ let mut point_targets: HashSet<(P, usize)> = HashSet::new();+ let mut h_points: Vec<(P, Vec<usize>)> = Vec::new();+ let mut zone_targets: Vec<(usize, &Vec<V>)> = Vec::new();+ let mut track_targets: Vec<(P, P, usize)> = Vec::new();+ for t in q.targets {+ match t {+ Terminal::Point { p, layers } => {+ let s = snap(*p);+ for &l in layers {+ point_targets.insert((s, l));+ }+ h_points.push((s, layers.clone()));+ }+ Terminal::Zone { layer, poly } => zone_targets.push((*layer, poly)),+ Terminal::Track { a, b, layer, .. } => track_targets.push((*a, *b, *layer)),+ }+ }+ // Remaining cost of covering `d` to a target that is on `on_layer(l)`. For a rail, staying off the+ // bus costs OFF_BUS per mm, so the bound has to know a via is usually the cheap way: without this+ // the search crawls the crowded stub layer for millimetres before it tries going down.+ let rest = |d: f64, layer: usize, on_layer: &dyn Fn(usize) -> bool| -> f64 {+ match pf.layer_bias {+ None => d + if on_layer(layer) { 0.0 } else { r.cost_via },+ Some(bus) => {+ let via_end = if on_layer(bus) { 0.0 } else { r.cost_via };+ if layer == bus {+ d + via_end+ } else {+ let down = r.cost_via + d + via_end;+ if on_layer(layer) { down.min(OFF_BUS * d) } else { down }+ }+ }+ }+ };+ // A rail's targets are every piece of its copper, pours of hundreds of points included, and working+ // that out per state was most of a rail search's time on benchmark C. With many targets the estimate+ // is kept per 0.1 mm square, less the most it can change across a square, so it's still a bound.+ let many = h_points.len() + track_targets.len() + zone_targets.len() * 8 > 8;+ let near_cache: std::cell::RefCell<HashMap<(i64, i64, u8), f64, FxBuild>> = Default::default();+ const CACHE_CELL: i64 = 100_000;+ let target_bound = |p: P, layer: usize| -> f64 {+ let mut best = f64::INFINITY;+ for (t, ls) in &h_points {+ best = best.min(rest(octile(p, *t), layer, &|l| ls.contains(&l)));+ }+ for (l, poly) in &zone_targets {+ best = best.min(rest(octile(p, near_poly(p, poly)), layer, &|x| x == *l));+ }+ for (a, b, l) in &track_targets {+ // Straight-line distance never exceeds the octile path, so this stays a lower bound.+ best = best.min(rest(pt_seg(p.into(), (*a).into(), (*b).into()), layer, &|x| x == *l));+ }+ best+ };+ let h = |p: P, layer: usize| -> f64 {+ let mut best = if many {+ let key = (p.0.div_euclid(CACHE_CELL), p.1.div_euclid(CACHE_CELL), layer as u8);+ let mut cache = near_cache.borrow_mut();+ *cache.entry(key).or_insert_with(|| {+ let c = (key.0 * CACHE_CELL + CACHE_CELL / 2, key.1 * CACHE_CELL + CACHE_CELL / 2);+ // OFF_BUS can steepen the bound by its factor, so the square's slack is scaled by it.+ (target_bound(c, layer) - OFF_BUS.max(1.0) * SQRT2 * CACHE_CELL as f64).max(0.0)+ })+ } else {+ target_bound(p, layer)+ };+ // Raised to the coarse map's true way round obstacles. Infinity is the map's proof that this+ // place can't reach the target.+ if let Some(tb) = table {+ let g = w.guide.borrow_mut().cost(tb, w, r, p.into(), layer);+ if g.is_finite() {+ best = best.max(g);+ } else if !audit {+ return g;+ }+ }+ best+ };++ // The pair partner's tracks, if this net has one on the board (STACK_COST).+ let stack: Vec<(usize, V, V)> = w.partner.get(&q.net).and_then(|p| w.pair_segs.borrow().get(p).map(|v| v.iter().map(|(_, l, a, b)| (*l, *a, *b)).collect())).unwrap_or_default();+ let mut nodes: Vec<Node> = Vec::new();+ let mut best: HashMap<(P, u8, u8), f64, FxBuild> = HashMap::with_capacity_and_hasher(4096, FxBuild::default());+ let mut heap: BinaryHeap<Reverse<(i64, u32)>> = BinaryHeap::new();+ // A rail is pulled tight afterwards and its bus layer is mostly empty, so its search may run+ // greedier: the answer barely changes and the node count drops by an order of magnitude.+ let weight = if pf.layer_bias.is_some() { RAIL_WEIGHT } else { r.search_weight };+ let mut push = |nodes: &mut Vec<Node>, best: &mut HashMap<(P, u8, u8), f64, FxBuild>, heap: &mut BinaryHeap<Reverse<(i64, u32)>>, n: Node| {+ let key = (n.p, n.layer, n.dir);+ let est = h(n.p, n.layer as usize);+ if est.is_infinite() {+ return;+ }+ if let Some(&g) = best.get(&key) {+ if g <= n.g {+ return;+ }+ }+ best.insert(key, n.g);+ let f = n.g + weight * est;+ nodes.push(n);+ heap.push(Reverse((f as i64, (nodes.len() - 1) as u32)));+ };+ for s in q.sources {+ match s {+ Terminal::Point { p, layers } => {+ for &l in layers {+ if w.route_layers.contains(&l) {+ push(&mut nodes, &mut best, &mut heap, Node { p: snap(*p), layer: l as u8, dir: NO_DIR, vias: 0, parent: u32::MAX, g: 0.0, pending: Pending::None });+ }+ }+ }+ Terminal::Zone { layer, poly } => {+ let b = Shape::Poly { pts: poly.to_vec(), r: 0.0 }.bbox();+ let c = snap(((b.0 + b.2) as i64 / 2, (b.1 + b.3) as i64 / 2));+ if w.route_layers.contains(layer) {+ push(&mut nodes, &mut best, &mut heap, Node { p: c, layer: *layer as u8, dir: NO_DIR, vias: 0, parent: u32::MAX, g: 0.0, pending: Pending::None });+ }+ }+ // Routes leave a pad or a fill; a track is only ever something to join.+ Terminal::Track { .. } => {}+ }+ }++ // Every source closed off on the coarse map: a proof that no route exists in this window.+ if table.is_some() && heap.is_empty() {+ w.guide.borrow_mut().closed_early += 1;+ return None;+ }++ let source_points: Vec<P> = q.sources.iter().filter_map(|s| if let Terminal::Point { p, .. } = s { Some(snap(*p)) } else { None }).collect();+ let target_points: Vec<P> = h_points.iter().map(|x| x.0).collect();+ // How far from a terminal a track may run at its neck. A wide rail's body can't come as close to+ // a small pad as a signal's can: it necks from where its own edge would meet the pad's neighbours.+ let rated = w.rail_nets.contains(&q.net) && pf.width > pf.neck;+ let neck_reach = if rated { (pf.width as f64 / 2.0 + pf.clearance as f64 + 0.3 * NM).max(0.4 * NM) } else { 0.4 * NM };+ let near_term = |x: P| source_points.iter().chain(target_points.iter()).any(|&s| octile(x, s) <= neck_reach);+ let mut expanded = 0usize;+ while let Some(Reverse((_, id))) = heap.pop() {+ let (p, layer, dir, vias, g, pending) = {+ let n = &nodes[id as usize];+ (n.p, n.layer as usize, n.dir, n.vias, n.g, n.pending)+ };+ let key = (p, layer as u8, dir);+ if best.get(&key).map(|&b| g > b).unwrap_or(false) {+ continue;+ }+ // Lazy check: is the move that reached this state legal, and what does it really cost?+ match pending {+ Pending::Move { from } => {+ expanded += 1;+ crate::clock::tick(1);+ *node_count = expanded;+ // The whole-run limit holds inside a long search too (4.6).+ if expanded & 4095 == 0 && started.elapsed().as_secs_f64() > r.time_limit {+ // Cut short, not closed: nothing was proved about the way.+ RAN_OUT.with(|c| c.set(true));+ return None;+ }+ if expanded > pf_limit(q) {+ RAN_OUT.with(|c| c.set(true));+ return None;+ }+ if expanded % 1024 == 0 && r.deadline.map(|d| crate::clock::Instant::now() > d).unwrap_or(false) {+ RAN_OUT.with(|c| c.set(true));+ return None;+ }+ let (a, b): (V, V) = (from.into(), p.into());+ // On its bus layer a rail keeps its width under a fine-pitch part: the escape is on+ // the other side. Off the bus layer it is a stub.+ let on_bus = pf.layer_bias == Some(layer);+ let in_esc = if pf.layer_bias.is_some() && !rated { !on_bus } else { (w.in_escape(a) && w.in_escape(b)) || near_term(from) || near_term(p) };+ let half = if in_esc { pf.neck } else { pf.width } as f64 / 2.0;+ let hard = half + pf.clearance as f64 - DRC_TOL;+ let soft_reach = half + pf.target_gap as f64;+ let reach = hard.max(soft_reach);+ let is_free = q.free.iter().any(|(c, rad)| octile(from, *c) <= *rad && octile(p, *c) <= *rad);+ let mut blockers = Vec::new();+ let mut soft_cost = 0.0;+ let gap = if is_free { f64::INFINITY } else { w.gap(r, pf.clearance, layer, a, b, q.net, also, reach, hard, q.soft, &mut blockers, &mut soft_cost) };+ if gap < hard {+ best.remove(&key);+ continue;+ }+ let e = half + r.edge as f64;+ if !is_free && (w.edge_gap(a, b, e) < e || w.mask.seg_hit_also(q.net, also, layer, a, b, half).is_some() || !w.holes_clear_also(r, a, b, half, q.net, also)) {+ best.remove(&key);+ continue;+ }+ let len = hypot(sub(a, b));+ // A crossing costs its price once however finely the lattice cuts it: the band of a+ // track and its clearance is about 0.5 mm wide, and lattice steps are far smaller.+ let mut extra = soft_cost * (len / (0.5 * NM)).min(1.0);+ if !in_esc && gap < soft_reach {+ extra += len * r.cost_tight * (1.0 - (gap - hard) / (soft_reach - hard).max(1.0));+ }+ // His J1: CC2 stays on B.Cu past the VBUS pour's edge, then hops to F.Cu.+ if let Some(polys) = w.pour_avoid.get(&q.net) {+ let m = V((a.0 + b.0) / 2.0, (a.1 + b.1) / 2.0);+ if polys.iter().any(|(l, p)| *l == layer && in_poly(m, p)) {+ extra += len * POUR_AVOID_COST;+ }+ }+ if let Some(hist) = q.history {+ let m = V((a.0 + b.0) / 2.0, (a.1 + b.1) / 2.0);+ extra += hist.at(m) as f64 * len * 0.5;+ }+ if w.relief_reach > 0.0 {+ let reach = w.relief_reach + half;+ let bb = (a.0.min(b.0) - reach, a.1.min(b.1) - reach, a.0.max(b.0) + reach, a.1.max(b.1) + reach);+ let mut hit = false;+ w.relief[layer].visit(bb, |o| {+ if o.net != q.net {+ if let Owner::Pad(i) = o.owner {+ if o.shape.dist_seg(a, b) - half < w.relief_gap[i as usize] {+ hit = true;+ return false;+ }+ }+ }+ true+ });+ if hit {+ extra += len * RELIEF_COST;+ }+ }+ if !stack.is_empty() {+ let m = V((a.0 + b.0) / 2.0, (a.1 + b.1) / 2.0);+ if stack.iter().any(|(l, s0, s1)| *l != layer && pt_seg(m, *s0, *s1) < STACK_BAND) {+ extra += len * STACK_COST;+ }+ }+ if let Some((fl, segs, pitch)) = w.follow.borrow().as_ref() {+ if *fl == layer && !segs.is_empty() {+ let m = V((a.0 + b.0) / 2.0, (a.1 + b.1) / 2.0);+ let d = segs.iter().map(|(s0, s1)| pt_seg(m, *s0, *s1)).fold(f64::INFINITY, f64::min);+ extra += len * FOLLOW_COST * ((d - pitch) / pitch).clamp(0.0, 1.0);+ }+ }+ nodes[id as usize].pending = Pending::None;+ if extra > 0.0 {+ let ng = g + extra;+ nodes[id as usize].g = ng;+ best.insert(key, ng);+ let f = ng + weight * h(p, layer);+ heap.push(Reverse((f as i64, id)));+ continue;+ }+ }+ Pending::Via => {+ expanded += 1;+ crate::clock::tick(1);+ // Two vias of one path are holes too (2.2), and neither is on the board yet to see.+ let mut up = nodes[id as usize].parent;+ let min_hole = r.drill as f64 + r.hole_hole as f64;+ let mut close = false;+ while up != u32::MAX && nodes[id as usize].vias > 1 {+ let n = &nodes[up as usize];+ if n.pending == Pending::None && n.dir == NO_DIR && n.parent != u32::MAX && n.p != p && hypot(sub(n.p.into(), p.into())) < min_hole {+ close = true;+ break;+ }+ up = n.parent;+ }+ if close {+ best.remove(&key);+ continue;+ }+ let mut blockers = Vec::new();+ let mut soft_cost = 0.0;+ if !w.via_ok(p, q, q.soft, &mut blockers, &mut soft_cost) {+ best.remove(&key);+ continue;+ }+ nodes[id as usize].pending = Pending::None;+ if soft_cost > 0.0 {+ let ng = g + soft_cost;+ nodes[id as usize].g = ng;+ best.insert(key, ng);+ let f = ng + weight * h(p, layer);+ heap.push(Reverse((f as i64, id)));+ continue;+ }+ }+ Pending::None => {}+ }+ let mut arrived = point_targets.contains(&(p, layer)) || zone_targets.iter().any(|(l, poly)| *l == layer && in_poly(p.into(), poly));+ if !arrived && !track_targets.is_empty() {+ let last_via = last_via_of(&nodes, id);+ arrived = q.targets.iter().any(|t| track_accepts(t, p, layer, dir, last_via));+ }+ let arrive_ok = match (q.arrive, dir) {+ (Some(next), d) if d != NO_DIR => ((next as i32 - d as i32).rem_euclid(8)).min((d as i32 - next as i32).rem_euclid(8)) <= 1,+ _ => true,+ };+ if arrived && arrive_ok {+ if let (true, Some(tb)) = (audit, table) {+ audit_path(&nodes, id, w, q, tb, walled);+ }+ return Some(rebuild(&nodes, id, q, w, expanded));+ }++ // Straight on and 45-degree turns; 90 degrees only at a price (chamfered afterwards).+ // Asked at most once per node, and only if a turn is on a line: near_copper took about 10%.+ let mut may_bend = if dir == NO_DIR { Some(true) } else { None };+ let lines = match (&bus_lines, pf.layer_bias) {+ (Some(b), Some(l)) if l == layer => b,+ _ => &lines,+ };+ for d in 0..8u8 {+ let turn = if dir == NO_DIR { 0 } else { ((d as i32 - dir as i32).rem_euclid(8)).min((dir as i32 - d as i32).rem_euclid(8)) };+ if turn >= 3 {+ continue;+ }+ // Straight off a via onto a bus layer's sparser lines, from wherever the via stands.+ if dir != NO_DIR && !lines.on_family(p, d) {+ continue;+ }+ if dir == NO_DIR && pf.layer_bias.is_none() && !lines.on_family(p, d) {+ continue;+ }+ if turn != 0 && !term.on_family(p, d) && !*may_bend.get_or_insert_with(|| near_copper(p, layer)) {+ continue;+ }+ let Some(mut t) = lines.next_stop(p, d) else { continue };+ let (dx, dy) = DIRS[d as usize];+ let mut np = (p.0 + dx * t, p.1 + dy * t);+ // In open copper a stop can only go straight on, so skip it: on a dense board's lattice+ // a straight run across an empty layer otherwise stops thousands of times per mm, each+ // a node and a clearance check, and floods the budget (piprobe_v13: 362k states on+ // an almost empty B.Cu, then "no path" 10 mm short). Stops where a bend or an arrival+ // is possible are all kept, so no route shape is lost.+ let open_stop = |x: P| !near_copper(x, layer) && !(0..8u8).any(|k| k != d && term.on_family(x, k)) && !point_targets.contains(&(x, layer)) && zone_targets.is_empty();+ let mut hops = 0;+ while hops < 512 && open_stop(np) {+ let Some(more) = lines.next_stop(np, d) else { break };+ let further = (np.0 + dx * more, np.1 + dy * more);+ if (further.0 as f64) < window.0 || (further.0 as f64) > window.2 || (further.1 as f64) < window.1 || (further.1 as f64) > window.3 {+ break;+ }+ t += more;+ np = further;+ hops += 1;+ }+ if (np.0 as f64) < window.0 || (np.0 as f64) > window.2 || (np.1 as f64) < window.1 || (np.1 as f64) > window.3 {+ continue;+ }+ let len = t as f64 * if dx != 0 && dy != 0 { SQRT2 } else { 1.0 };+ let per_mm = match pf.layer_bias {+ Some(l) if l == layer => 1.0,+ Some(_) => OFF_BUS,+ None if layer == 0 => 1.0,+ None => r.cost_bottom,+ };+ let mut cost = len * per_mm;+ if let Some(h) = &hint {+ cost += len * h.extra(p.into(), np.into(), layer);+ }+ cost += match turn {+ 0 => 0.0,+ 1 => r.cost_bend45,+ _ => r.cost_bend90,+ };+ push(&mut nodes, &mut best, &mut heap, Node { p: np, layer: layer as u8, dir: d, vias, parent: id, g: g + cost, pending: Pending::Move { from: p } });+ }++ // Change layer, in rounds by via count.+ // Most via candidates fail (9 in 10 on the benchmark boards), so the map's proof that none+ // can stand in this cell saves each one a trip through the heap and a full check.+ if (vias as u32) < pf.max_vias && (audit || table.map_or(true, |tb| w.guide.borrow_mut().via_possible(tb, w, r, p.into()))) {+ for &l in &w.route_layers {+ if l != layer {+ push(&mut nodes, &mut best, &mut heap, Node { p, layer: l as u8, dir: NO_DIR, vias: vias + 1, parent: id, g: g + r.cost_via, pending: Pending::Via });+ }+ }+ }+ }+ None+}++fn trace() -> bool {+ static V: std::sync::OnceLock<bool> = std::sync::OnceLock::new();+ *V.get_or_init(|| std::env::var_os("ADOM_ROUTE_TRACE").is_some())+}++/// Audit mode: the coarse map's proofs aren't acted on, and any finished route through a place the+/// map called closed is reported. This is how the map's optimism is measured.+fn audit_guide() -> bool {+ static V: std::sync::OnceLock<bool> = std::sync::OnceLock::new();+ *V.get_or_init(|| std::env::var_os("ADOM_ROUTE_AUDIT_GUIDE").is_some())+}++fn audit_path(nodes: &[Node], mut id: u32, w: &World, q: &Query, table: usize, walled: bool) {+ if walled {+ eprintln!("GUIDE VIOLATION net {}: routed although a terminal was called walled in", q.net);+ }+ loop {+ let n = &nodes[id as usize];+ let g = w.guide.borrow_mut().cost(table, w, q.rules, n.p.into(), n.layer as usize);+ if g.is_infinite() {+ eprintln!("GUIDE VIOLATION net {} vias {} at ({:.3},{:.3}) layer {}", q.net, q.profile.max_vias, n.p.0 as f64 / NM, n.p.1 as f64 / NM, n.layer);+ }+ if n.parent != u32::MAX && nodes[n.parent as usize].layer != n.layer && !w.guide.borrow_mut().via_possible(table, w, q.rules, n.p.into()) {+ eprintln!("GUIDE VIOLATION net {}: via at ({:.3},{:.3}) in a cell the map called closed", q.net, n.p.0 as f64 / NM, n.p.1 as f64 / NM);+ }+ if n.parent == u32::MAX {+ break;+ }+ id = n.parent;+ }+}++/// Where this node's copper last came through a via, if it did on the way here.+fn last_via_of(nodes: &[Node], mut id: u32) -> Option<P> {+ loop {+ let n = &nodes[id as usize];+ if n.parent == u32::MAX {+ return None;+ }+ let parent = &nodes[n.parent as usize];+ if parent.layer != n.layer {+ return Some(n.p);+ }+ id = n.parent;+ }+}++fn pf_limit(q: &Query) -> usize {+ // Small windows get a small budget; the wide last-resort searches get the full one.+ let m = q.profile.margin;+ // A soft search that finds its way does so early: the longest that succeeded across the three+ // benchmark boards took 182k nodes, while every wider one spent its full budget and failed.+ if q.soft.is_some() {+ return q.rules.node_limit / 4;+ }+ if m < 2.0 * NM { q.rules.node_limit / 16 } else if m < 6.0 * NM { q.rules.node_limit / 4 } else { q.rules.node_limit }+}++fn rebuild(nodes: &[Node], mut id: u32, q: &Query, w: &World, expanded: usize) -> Path {+ let mut pts = Vec::new();+ let cost = nodes[id as usize].g;+ let vias = nodes[id as usize].vias as u32;+ loop {+ let n = &nodes[id as usize];+ pts.push((n.p, n.layer as usize));+ if n.parent == u32::MAX {+ break;+ }+ id = n.parent;+ }+ pts.reverse();+ let mut blockers = Vec::new();+ if let Some(f) = q.soft {+ // Find exactly who the finished path crosses.+ let half = q.profile.neck as f64 / 2.0 + q.profile.clearance as f64;+ let also = q.also.unwrap_or(NO_NET);+ for s in pts.windows(2) {+ let ((a, la), (b, lb)) = (s[0], s[1]);+ let (va, vb): (V, V) = (a.into(), b.into());+ if la == lb {+ let mut c = 0.0;+ w.gap(q.rules, q.profile.clearance, la, va, vb, q.net, also, half, half, Some(f), &mut blockers, &mut c);+ } else {+ let need = q.rules.via_for(q.net).0 as f64 / 2.0 + q.profile.clearance as f64;+ for l in 0..w.layers.len() {+ let mut c = 0.0;+ w.gap(q.rules, q.profile.clearance, l, va, va, q.net, also, need, need, Some(f), &mut blockers, &mut c);+ }+ }+ }+ }+ Path { pts, vias, cost, blockers, nodes: expanded }+}++#[cfg(test)]+mod tests {+ use super::*;++ fn rail(wide: bool, stand_off: f64) -> Terminal {+ Terminal::Track { a: (0, 0), b: (1_000_000, 0), layer: 3, wide, stand_off }+ }++ #[test]+ fn a_tap_joins_a_wide_rail_at_45_or_90_and_a_thin_one_only_as_a_y() {+ let mid = (500_000, 0);+ assert!(track_accepts(&rail(true, 0.0), mid, 3, 2, None));+ assert!(track_accepts(&rail(true, 0.0), mid, 3, 1, None));+ assert!(!track_accepts(&rail(false, 0.0), mid, 3, 2, None));+ assert!(track_accepts(&rail(false, 0.0), mid, 3, 1, None));+ // Never along the rail, never on the wrong layer.+ assert!(!track_accepts(&rail(true, 0.0), mid, 3, 0, None));+ assert!(!track_accepts(&rail(true, 0.0), mid, 0, 2, None));+ }++ #[test]+ fn a_tap_keeps_its_via_off_the_rail_and_never_folds_back_at_its_end() {+ let mid = (500_000, 0);+ // A via on the rail, or one too close to it, is refused while a stand-off is asked for (4.4).+ assert!(!track_accepts(&rail(true, 600_000.0), mid, 3, NO_DIR, Some(mid)));+ assert!(!track_accepts(&rail(true, 600_000.0), mid, 3, 2, Some((500_000, -300_000))));+ assert!(track_accepts(&rail(true, 600_000.0), mid, 3, 2, Some((500_000, -700_000))));+ // Arriving heading -x at the rail's start would turn back along +x: an acute corner.+ assert!(!track_accepts(&rail(true, 0.0), (0, 0), 3, 3, None));+ assert!(track_accepts(&rail(true, 0.0), (0, 0), 3, 1, None));+ }+}
src/spread.rsadded+189@@ -0,0 +1,189 @@+//! Breathing room (reference rule, 2026-11-09: "first routing everything completely then circling back and+//! optimizing spacing where it is possible"; he gives traces as much room as there is). Once every+//! connection is down, each middle piece of a track slides sideways, its two neighbours stretching+//! or shrinking along their own directions so every angle stays, to where its room to the nearest+//! other copper is largest. Repeated, lines packed at the routing pitch spread out evenly across+//! the channel they share. Nothing is re-routed: topology, layers and vias stay as routed.+//!+//! Widths are not touched here: a track's width comes from the current through it (current.rs), at+//! routing time, so a rail never necks from one arbitrary width to another.++use crate::board::*;+use crate::classify::*;+use crate::geom::*;+use crate::router::*;+use crate::search::*;+use crate::clock::Instant;++/// Room past which more is not worth moving copper for: his benchmark B bus has a median of 1.59 mm+/// centre to centre at 0.2 mm wide, about 1.4 mm of edge room.+const ROOM: f64 = 1.4 * NM;+/// Slide step and the farthest one piece goes in one pass.+const STEP: i64 = 25_000;+const MAX_SHIFT: i64 = 1_500_000;+/// Extra copper is worth this share of full room per unit length: 1 mm more track must buy at+/// least 0.05 mm of room along a millimetre.+const LEN_COST: f64 = 0.05;+/// A neighbour piece keeps at least this much of itself, or its own length if shorter.+const KEEP: i64 = 300_000;+const PASSES: usize = 8;++impl<'a> Router<'a> {+ pub(crate) fn spread(&mut self) {+ let t0 = Instant::now();+ let (mut moves, mut passes, mut undone) = (0usize, 0usize, 0usize);+ let mut gained = 0.0;+ for _ in 0..PASSES {+ passes += 1;+ let mut moved = 0usize;+ let mut ids: Vec<u32> = self.routes.values().filter(|r| !matches!(r.task.level, Level::Pair | Level::Kelvin | Level::PlaneFanout) && r.segs.len() >= 3).map(|r| r.id).collect();+ ids.sort_unstable();+ for id in ids {+ if self.over_time() {+ break;+ }+ let (net, vias, mut cur) = {+ let r = &self.routes[&id];+ (r.task.net, r.vias.clone(), r.segs.clone())+ };+ // What else of the net lands on this track: other connections' ends, taps, vias,+ // the net's pads. A piece any of it touches stays, or the net comes apart.+ let mut held: Vec<V> = Vec::new();+ for r in self.routes.values().filter(|r| r.task.net == net && r.id != id) {+ for sg in &r.segs {+ held.push(sg.a.into());+ held.push(sg.b.into());+ }+ held.extend(r.vias.iter().map(|&v| V::from(v)));+ }+ held.extend(self.board.pads.iter().filter(|pd| pd.net == net).map(|pd| V::from(pd.center)));+ let mut changed = false;+ for k in 1..cur.len() - 1 {+ let own_other = |j: usize| j + 1 < k || j > k + 1;+ let touched = |sg: &Seg| {+ let (a, b): (V, V) = (sg.a.into(), sg.b.into());+ held.iter().any(|h| pt_seg(*h, a, b) < 2_000.0) || cur.iter().enumerate().filter(|(j, _)| own_other(*j)).any(|(_, o)| pt_seg(o.a.into(), a, b) < 2_000.0 || pt_seg(o.b.into(), a, b) < 2_000.0)+ };+ // The pad a stub starts on is its own end and stays: only the far piece ends count.+ let p_mid = { let sg = &cur[k - 1]; let (a, b): (V, V) = (sg.a.into(), sg.b.into()); held.iter().any(|h| pt_seg(*h, a, b) < 2_000.0 && hypot(sub(*h, a)) > 2_000.0) || cur.iter().enumerate().filter(|(j, _)| own_other(*j) && *j + 2 != k).any(|(_, o)| { let (oa, ob): (V, V) = (o.a.into(), o.b.into()); pt_seg(oa, a, b) < 2_000.0 || pt_seg(ob, a, b) < 2_000.0 }) };+ let q_mid = { let sg = &cur[k + 1]; let (a, b): (V, V) = (sg.a.into(), sg.b.into()); held.iter().any(|h| pt_seg(*h, a, b) < 2_000.0 && hypot(sub(*h, b)) > 2_000.0) || cur.iter().enumerate().filter(|(j, _)| own_other(*j) && *j != k + 2).any(|(_, o)| { let (oa, ob): (V, V) = (o.a.into(), o.b.into()); pt_seg(oa, a, b) < 2_000.0 || pt_seg(ob, a, b) < 2_000.0 }) };+ if touched(&cur[k]) || p_mid || q_mid {+ continue;+ }+ if let Some((a2, b2, g)) = self.slide(&cur, k, net, &vias) {+ cur[k - 1].b = a2;+ cur[k].a = a2;+ cur[k].b = b2;+ cur[k + 1].a = b2;+ changed = true;+ moved += 1;+ gained += g;+ }+ }+ if changed {+ // Whole or not at all: a track can touch its net's copper anywhere along its+ // width (a pad it crosses off-centre, a track it overlaps), which the held points+ // above don't see. If the net has more pieces after the move, the move is undone.+ let before = self.open_pieces(net);+ let doubled = |me: &Self| -> f64 { let r = &me.routes[&id]; (0..r.segs.len()).map(|i| me.doubled_along(r, i)).sum() };+ let doubled_before = doubled(self);+ let saved = self.unplace(id).unwrap();+ let (sid, task, old, vias) = (saved.id, saved.task.clone(), saved.segs.clone(), saved.vias.clone());+ self.place(sid, task.clone(), cur, vias.clone());+ // Nor may it end up running beside its own net's copper (4.2's doubled runs).+ if self.open_pieces(net) > before || doubled(self) > doubled_before + 1.0 {+ self.unplace(sid);+ self.place(sid, task, old, vias);+ undone += 1;+ }+ }+ }+ moves += moved;+ if moved == 0 || self.over_time() {+ break;+ }+ }+ self.log.push(format!("spacing: {moves} track pieces moved for room in {passes} passes, {:.1} mm2 of room gained (room x length, up to {:.1} mm), {:.1} s; {undone} moves undone to keep a net whole", gained / NM / NM, ROOM / NM, t0.elapsed().as_secs_f64()));+ }++ /// Where piece `k` of a track does best, if better than where it is: its new ends and the room+ /// it gained.+ fn slide(&self, segs: &[Seg], k: usize, net: NetId, vias: &[P]) -> Option<(P, P, f64)> {+ let (p, s, q) = (&segs[k - 1], &segs[k], &segs[k + 1]);+ if p.b != s.a || s.b != q.a || p.layer != s.layer || q.layer != s.layer || vias.contains(&s.a) || vias.contains(&s.b) {+ return None;+ }+ let (ds, dp, dq) = (dir_of(s.a, s.b)?, dir_of(p.a, p.b)?, dir_of(q.a, q.b)?);+ let (sx, sy) = DIRS[ds as usize];+ let l = ((sx * sx + sy * sy) as f64).sqrt();+ let n = (-(sy as f64) / l, sx as f64 / l);+ let (px, py) = DIRS[dp as usize];+ let (qx, qy) = DIRS[dq as usize];+ let dot_p = px as f64 * n.0 + py as f64 * n.1;+ let dot_q = qx as f64 * n.0 + qy as f64 * n.1;+ if dot_p.abs() < 1e-6 || dot_q.abs() < 1e-6 {+ return None;+ }+ let layer = s.layer;+ let half = s.width as f64 / 2.0;+ let reach = ROOM + half + 1.0;+ let len = |a: P, b: P| hypot(sub(a.into(), b.into()));+ // How far short of full room a piece is along its length (mm of room times mm of track):+ // a long line pinched only by the pad at its end still has its whole middle to gain.+ let short = |a: P, b: P, w: i64| -> (f64, f64) {+ let (va, vb): (V, V) = (a.into(), b.into());+ let l = hypot(sub(va, vb));+ let k = ((l / (0.4 * NM)).ceil() as usize).max(1);+ let (mut sum, mut least) = (0.0, f64::INFINITY);+ for i in 0..k {+ let (t0, t1) = (i as f64 / k as f64, (i + 1) as f64 / k as f64);+ let pa = V(va.0 + (vb.0 - va.0) * t0, va.1 + (vb.1 - va.1) * t0);+ let pb = V(va.0 + (vb.0 - va.0) * t1, va.1 + (vb.1 - va.1) * t1);+ let r = (self.world.room(&self.rules, layer, pa, pb, net, reach) - w as f64 / 2.0).min(ROOM);+ least = least.min(r);+ sum += (ROOM - r) * l / k as f64;+ }+ (sum, least)+ };+ let ((sp, mp), (ss, ms), (sq, mq)) = (short(p.a, p.b, p.width), short(s.a, s.b, s.width), short(q.a, q.b, q.width));+ let before = sp + ss + sq;+ if ss <= 1.0 {+ return None;+ }+ let total0 = len(p.a, p.b) + len(s.a, s.b) + len(q.a, q.b);+ let keep = |a: P, b: P| (len(a, b) as i64).min(KEEP);+ let (keep_p, keep_q, keep_s) = (keep(p.a, p.b), keep(q.a, q.b), keep(s.a, s.b));+ let mut best: Option<(f64, P, P, f64)> = None;+ for sign in [1i64, -1] {+ let mut off = STEP;+ while off <= MAX_SHIFT {+ let o = (sign * off) as f64;+ let (ka, kb) = ((o / dot_p).round() as i64, (o / dot_q).round() as i64);+ let a2 = (s.a.0 + ka * px, s.a.1 + ka * py);+ let b2 = (s.b.0 + kb * qx, s.b.1 + kb * qy);+ // Every angle stays: each piece keeps its direction, and none shrinks away (a chamfer slid+ // into its corner turns back into a 90-degree bend).+ let ok_shape = dir_of(a2, b2) == Some(ds) && dir_of(p.a, a2) == Some(dp) && dir_of(b2, q.b) == Some(dq) && len(p.a, a2) as i64 >= keep_p && len(b2, q.b) as i64 >= keep_q && len(a2, b2) as i64 >= keep_s;+ if !ok_shape {+ break;+ }+ if !self.seg_clear(p.a, a2, layer, p.width, net, NO_NET) || !self.seg_clear(a2, b2, layer, s.width, net, NO_NET) || !self.seg_clear(b2, q.b, layer, q.width, net, NO_NET) {+ break;+ }+ let ((sp2, mp2), (ss2, ms2), (sq2, mq2)) = (short(p.a, a2, p.width), short(a2, b2, s.width), short(b2, q.b, q.width));+ // No piece's tightest point gets tighter: room is gained, never traded.+ if mp2 + 1.0 >= mp.min(ROOM) && ms2 + 1.0 >= ms.min(ROOM) && mq2 + 1.0 >= mq.min(ROOM) {+ let extra = len(p.a, a2) + len(a2, b2) + len(b2, q.b) - total0;+ let cost = sp2 + ss2 + sq2 + LEN_COST * ROOM * extra.max(0.0);+ if best.map(|b| cost < b.0).unwrap_or(true) {+ best = Some((cost, a2, b2, before - (sp2 + ss2 + sq2)));+ }+ }+ off += STEP;+ }+ }+ let (cost, a2, b2, gain) = best?;+ // Worth moving only for a real gain: 0.01 mm of room over a millimetre of track is noise.+ (cost + 0.01 * NM * NM < before).then_some((a2, b2, gain))+ }+}
src/sx.rsadded+110@@ -0,0 +1,110 @@+//! A small S-expression reader for KiCad files. Lists keep their byte span so a writer can splice+//! the original text instead of re-serialising it.++#[derive(Debug, Clone)]+pub enum Node {+ Atom(String),+ List(Vec<Node>, (usize, usize)),+}++impl Node {+ pub fn items(&self) -> &[Node] {+ match self {+ Node::List(v, _) => v,+ _ => &[],+ }+ }+ pub fn head(&self) -> &str {+ match self {+ Node::List(v, _) => match v.first() {+ Some(Node::Atom(a)) => a,+ _ => "",+ },+ _ => "",+ }+ }+ pub fn span(&self) -> (usize, usize) {+ match self {+ Node::List(_, s) => *s,+ _ => (0, 0),+ }+ }+ pub fn child(&self, name: &str) -> Option<&Node> {+ self.items().iter().find(|n| n.head() == name)+ }+ pub fn children<'a>(&'a self, name: &'a str) -> impl Iterator<Item = &'a Node> + 'a {+ self.items().iter().filter(move |n| n.head() == name)+ }+ pub fn atom(&self, i: usize) -> Option<&str> {+ match self.items().get(i) {+ Some(Node::Atom(a)) => Some(a),+ _ => None,+ }+ }+ pub fn num(&self, i: usize) -> Option<f64> {+ self.atom(i).and_then(|a| a.parse().ok())+ }+ pub fn atoms(&self) -> Vec<&str> {+ self.items()+ .iter()+ .filter_map(|n| if let Node::Atom(a) = n { Some(a.as_str()) } else { None })+ .collect()+ }+}++pub fn parse(text: &str) -> Result<Node, String> {+ let b = text.as_bytes();+ let mut stack: Vec<(Vec<Node>, usize)> = Vec::new();+ let mut i = 0;+ let mut root = None;+ while i < b.len() {+ match b[i] {+ b' ' | b'\t' | b'\r' | b'\n' => i += 1,+ b'(' => {+ stack.push((Vec::new(), i));+ i += 1;+ }+ b')' => {+ let (items, start) = stack.pop().ok_or("unbalanced ')'")?;+ let n = Node::List(items, (start, i + 1));+ i += 1;+ match stack.last_mut() {+ Some((p, _)) => p.push(n),+ None => {+ root = Some(n);+ break;+ }+ }+ }+ b'"' => {+ i += 1;+ let mut s = Vec::new();+ while i < b.len() && b[i] != b'"' {+ if b[i] == b'\\' && i + 1 < b.len() {+ i += 1;+ s.push(match b[i] {+ b'n' => b'\n',+ b't' => b'\t',+ c => c,+ });+ } else {+ s.push(b[i]);+ }+ i += 1;+ }+ i += 1;+ let a = Node::Atom(String::from_utf8_lossy(&s).into_owned());+ stack.last_mut().ok_or("atom outside list")?.0.push(a);+ }+ _ => {+ let st = i;+ while i < b.len() && !matches!(b[i], b' ' | b'\t' | b'\r' | b'\n' | b'(' | b')') {+ i += 1;+ }+ let a = Node::Atom(text[st..i].to_string());+ stack.last_mut().ok_or("atom outside list")?.0.push(a);+ }+ }+ }+ root.ok_or_else(|| "no root list".to_string())+}
src/write.rsadded+102@@ -0,0 +1,102 @@+//! Writes copper into a copy of the board. Nothing else in the file changes. The input file is+//! never overwritten (4.6: never overwrite a board someone has reviewed).++use crate::board::*;+use crate::geom::*;+use crate::router::*;+use std::fmt::Write;++fn uuid(seed: u64) -> String {+ // A fixed mix: stable across runs for the same order, unique within a file.+ let mut x = seed.wrapping_mul(0x9E37_79B9_7F4A_7C15) ^ 0xA5A5_5A5A_DEAD_BEEF;+ let mut next = || {+ x ^= x << 13;+ x ^= x >> 7;+ x ^= x << 17;+ x+ };+ let (a, b) = (next(), next());+ format!("{:08x}-{:04x}-4{:03x}-8{:03x}-{:012x}", (a >> 32) as u32, (a >> 16) as u16, a & 0xfff, (b >> 48) & 0xfff, b & 0xffff_ffff_ffff)+}++/// The board file's own number for each net name, from its `(net N "name")` table. A file that+/// numbers its nets (KiCad 9 and earlier) has to get those numbers back on new copper: the parser+/// numbers nets in the order it meets them, which is not the file's order. Written with the+/// parser's number, every track on a small sensor board carried another net's number+/// (2026-11-10); KiCad re-derives a track's net from the pads it touches when it loads a board, so+/// its DRC said nothing, and only this program's own check of the written file showed it.+fn file_numbers(board: &Board) -> std::collections::HashMap<String, String> {+ let mut out = std::collections::HashMap::new();+ for line in board.text.lines() {+ let l = line.trim();+ let Some(rest) = l.strip_prefix("(net ") else { continue };+ let Some((num, name)) = rest.split_once(' ') else { continue };+ if !num.chars().all(|c| c.is_ascii_digit()) || !name.starts_with('"') || !name.ends_with("\")") {+ continue;+ }+ let name = &name[1..name.len() - 2];+ out.entry(name.replace("\\\"", "\"").replace("\\\\", "\\")).or_insert_with(|| num.to_string());+ }+ out+}++fn net_field(board: &Board, net: NetId, numbers: Option<&std::collections::HashMap<String, String>>) -> String {+ match numbers {+ Some(table) => table.get(board.net_name(net)).cloned().unwrap_or_else(|| "0".to_string()),+ None => format!("\"{}\"", board.net_name(net).replace('\\', "\\\\").replace('"', "\\\"")),+ }+}++fn n(v: i64) -> String {+ let s = format!("{:.6}", v as f64 / NM);+ let t = s.trim_end_matches('0').trim_end_matches('.');+ t.to_string()+}++pub fn render(board: &Board, routes: &[&Route], rules: &crate::rules::Rules) -> String {+ let numeric = board.text.contains("\n\t(net 0 ") || board.text.contains("\n (net 0 ");+ let numbers = numeric.then(|| file_numbers(board));+ let mut out = String::new();+ let mut seed = 1u64;+ for r in routes {+ let net = net_field(board, r.task.net, numbers.as_ref());+ for s in &r.segs {+ seed += 1;+ let _ = write!(out, "\t(segment\n\t\t(start {} {})\n\t\t(end {} {})\n\t\t(width {})\n\t\t(layer \"{}\")\n\t\t(net {})\n\t\t(uuid \"{}\")\n\t)\n", n(s.a.0), n(s.a.1), n(s.b.0), n(s.b.1), n(s.width), board.layers[s.layer].name, net, uuid(seed));+ }+ for v in &r.vias {+ seed += 1;+ // A via with no track of its own (a stitching via, a return via, a via in a pad) is marked+ // free. KiCad gives any other via the net of whatever copper it lands in when the board+ // loads, and the file still holds the OLD plane fills: on a large Ethernet switch board a+ // GND via through the +3.3V plane came back as a +3.3V via, 0.11 mm from a GND pad.+ // Checked in KiCad 10: with the flag the via keeps its net and the finding is gone.+ let free = if r.segs.is_empty() { "\n\t\t(free yes)" } else { "" };+ let _ = write!(out, "\t(via\n\t\t(at {} {})\n\t\t(size {})\n\t\t(drill {})\n\t\t(layers \"F.Cu\" \"B.Cu\"){free}\n\t\t(net {})\n\t\t(uuid \"{}\")\n\t)\n", n(v.0), n(v.1), n(rules.via_for(r.task.net).0), n(rules.via_for(r.task.net).1), net, uuid(seed));+ }+ }+ let end = board.text.rfind(')').unwrap_or(board.text.len());+ let mut text = String::with_capacity(board.text.len() + out.len());+ text.push_str(&board.text[..end]);+ text.push_str(&out);+ text.push_str(&board.text[end..]);+ text+}++/// A copy of the file with every track and via removed, for measuring against a reference.+pub fn strip(board: &Board) -> String {+ let mut spans: Vec<(usize, usize)> = board.tracks.iter().map(|t| t.span).chain(board.vias.iter().map(|v| v.span)).chain(board.teardrop_spans.iter().copied()).collect();+ spans.sort();+ spans.dedup();+ let mut text = String::with_capacity(board.text.len());+ let mut at = 0;+ for (a, b) in spans {+ if a < at {+ continue;+ }+ text.push_str(&board.text[at..a]);+ at = b;+ }+ text.push_str(&board.text[at..]);+ text+}
uninstall.shadded+8@@ -0,0 +1,8 @@+#!/bin/bash+# Package uninstall hook: remove the binary, its older name, and the skill this app installed.+HERE="$(cd "$(dirname "$0")" && pwd)"+NAME=adom-aiflow-routing+rm -f "$HOME/.local/bin/$NAME"+[ "$(readlink "$HOME/.local/bin/adom-route" 2>/dev/null)" = "$NAME" ] && rm -f "$HOME/.local/bin/adom-route"+for skill in "$HERE"/skills/*; do for consumer in .claude .codex; do rm -rf "$HOME/$consumer/skills/$(basename "$skill")"; done; done+echo "OK: $NAME removed"