← Commit history
.adomignoreadded+1
@@ -0,0 +1 @@+target/
Cargo.lockadded+415
@@ -0,0 +1,415 @@+# This file is automatically @generated by Cargo.+# It is not intended for manual editing.+version = 4++[[package]]+name = "adler2"+version = "2.0.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"++[[package]]+name = "adom-aiflow-reverse"+version = "0.2.0"+dependencies = [+ "clap",+ "image",+ "serde",+ "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 = "autocfg"+version = "1.5.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"++[[package]]+name = "bitflags"+version = "2.13.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"++[[package]]+name = "bytemuck"+version = "1.25.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"++[[package]]+name = "byteorder-lite"+version = "0.1.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"++[[package]]+name = "cfg-if"+version = "1.0.5"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"++[[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 = "crc32fast"+version = "1.5.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "5f97d3416d5337d15c4b0d78c8c91df922ca143f57ec20e2d1c188333047ef04"+dependencies = [+ "cfg-if",+]++[[package]]+name = "fdeflate"+version = "0.3.7"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"+dependencies = [+ "simd-adler32",+]++[[package]]+name = "flate2"+version = "1.1.10"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"+dependencies = [+ "crc32fast",+ "miniz_oxide 0.9.1",+ "zlib-rs",+]++[[package]]+name = "heck"+version = "0.5.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"++[[package]]+name = "image"+version = "0.25.10"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"+dependencies = [+ "bytemuck",+ "byteorder-lite",+ "moxcms",+ "num-traits",+ "png",+ "zune-core",+ "zune-jpeg",+]++[[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 = "miniz_oxide"+version = "0.8.9"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"+dependencies = [+ "adler2",+ "simd-adler32",+]++[[package]]+name = "miniz_oxide"+version = "0.9.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c"+dependencies = [+ "adler2",+ "simd-adler32",+]++[[package]]+name = "moxcms"+version = "0.8.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"+dependencies = [+ "num-traits",+ "pxfm",+]++[[package]]+name = "num-traits"+version = "0.2.19"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"+dependencies = [+ "autocfg",+]++[[package]]+name = "once_cell_polyfill"+version = "1.70.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"++[[package]]+name = "png"+version = "0.18.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"+dependencies = [+ "bitflags",+ "crc32fast",+ "fdeflate",+ "flate2",+ "miniz_oxide 0.8.9",+]++[[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 = "pxfm"+version = "0.1.30"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"++[[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",+ "serde_derive",+]++[[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 = [+ "itoa",+ "memchr",+ "serde",+ "serde_core",+ "zmij",+]++[[package]]+name = "simd-adler32"+version = "0.3.10"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"++[[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 = "zlib-rs"+version = "0.6.8"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112"++[[package]]+name = "zmij"+version = "1.0.23"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"++[[package]]+name = "zune-core"+version = "0.5.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b"++[[package]]+name = "zune-jpeg"+version = "0.5.15"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"+dependencies = [+ "zune-core",+]
Cargo.tomladded+29
@@ -0,0 +1,29 @@+# adom-aiflow-reverse 0.2: reverse engineer a PCB from photos. The program measures (board outline,+# rectification, holes, part bodies, copper islands, vias, connectivity), generates the KiCad copy with the+# photographed pours and tracks, and routes the rest on its own N-layer router; the AI names.+[package]+name = "adom-aiflow-reverse"+version = "0.2.0"+edition = "2021"+license = "MIT"++[[bin]]+name = "adom-aiflow-reverse"+path = "src/main.rs"++[dependencies]+serde = { version = "1", features = ["derive"] }+serde_json = "1"+clap = { version = "4", features = ["derive"] }+image = { version = "0.25", default-features = false, features = ["jpeg", "png"] }++[profile.release]+opt-level = 3+lto = "thin"+codegen-units = 4++[profile.dev]+opt-level = 2++# standalone: not a member of any surrounding workspace+[workspace]
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.
assets/flow.htmladded+155
@@ -0,0 +1,155 @@+<!doctype html>+<html lang="en">+<head>+<meta charset="utf-8">+<meta name="viewport" content="width=device-width, initial-scale=1">+<title>Reverse Flow</title>+<style>+:root{--bg:#0d1117;--panel:#161b22;--line:#30363d;--text:#e6edf3;--dim:#8b949e;--accent:#2f81f7;--done:#3fb950;--now:#d29922;--stale:#f85149}+*{box-sizing:border-box}+html,body{margin:0;height:100%;background:var(--bg);color:var(--text);font:14px/1.45 Inter,system-ui,-apple-system,"Segoe UI",sans-serif}+header{display:flex;align-items:center;gap:12px;padding:10px 16px;border-bottom:1px solid var(--line);background:var(--panel)}+header h1{font-size:15px;margin:0;font-weight:600}+header .proj{color:var(--dim);font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}+.live{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--dim);cursor:pointer;border:1px solid var(--line);border-radius:6px;padding:4px 10px;background:none}+.live i{width:8px;height:8px;border-radius:50%;background:var(--dim)}+.live.on i{background:var(--done);box-shadow:0 0 6px var(--done)}+main{display:grid;grid-template-columns:270px 1fr;height:calc(100% - 49px)}+aside{border-right:1px solid var(--line);overflow:auto;padding:12px}+aside h2{font-size:11px;letter-spacing:.06em;text-transform:uppercase;color:var(--dim);margin:14px 4px 6px}+.step,.hist{display:flex;align-items:center;gap:8px;padding:7px 8px;border-radius:6px;cursor:pointer;min-height:36px}+.step:hover,.hist:hover{background:#1f2630}+.step.sel,.hist.sel{background:#1f2a3a;outline:1px solid var(--accent)}+.chip{font-size:10px;font-weight:600;text-transform:uppercase;padding:2px 6px;border-radius:10px;border:1px solid var(--line);color:var(--dim);min-width:46px;text-align:center}+.chip.done{color:var(--done);border-color:var(--done)}.chip.now{color:var(--now);border-color:var(--now)}.chip.stale{color:var(--stale);border-color:var(--stale)}+.hist .n{color:var(--dim);font-variant-numeric:tabular-nums;width:30px}+.hist .t{color:var(--dim);font-size:11px;margin-left:auto}+.hist .r{font-size:12px;color:var(--dim)}+section{display:flex;flex-direction:column;min-width:0}+.bar{display:flex;align-items:center;gap:8px;padding:8px 12px;border-bottom:1px solid var(--line);flex-wrap:wrap}+.tab{border:1px solid var(--line);background:none;color:var(--text);border-radius:6px;padding:6px 12px;cursor:pointer;min-height:32px}+.tab.sel{border-color:var(--accent);background:#1f2a3a}+.what{color:var(--dim);font-size:12px;flex:1;min-width:200px}+.todo{padding:8px 12px;font-size:12px;color:var(--dim);border-bottom:1px solid var(--line);max-height:72px;overflow:auto}+.view{flex:1;position:relative;min-height:0}+iframe{border:0;width:100%;height:100%;background:#fff}+.grid{position:absolute;inset:0;overflow:auto;padding:12px;display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:12px;align-content:start}+.grid figure{margin:0;background:var(--panel);border:1px solid var(--line);border-radius:8px;overflow:hidden}+.grid img{width:100%;display:block;cursor:zoom-in}+.grid figcaption{font-size:12px;color:var(--dim);padding:6px 8px}+.find{position:absolute;inset:0;overflow:auto;padding:14px 16px}+.find h3{font-size:13px;margin:16px 0 6px;color:var(--text)}+.find .sum{font-size:15px;margin:0 0 6px}+.find table{border-collapse:collapse;width:100%;font-size:12px}+.find th,.find td{border-bottom:1px solid var(--line);padding:5px 8px;text-align:left;vertical-align:top}+.find th{color:var(--dim);font-weight:600;position:sticky;top:0;background:var(--bg)}+.find td.tag{font-weight:600}.find td.INFERRED,.find td.UNKNOWN,.find td.Low{color:var(--now)}.find td.SEEN,.find td.MEASURED,.find td.DATASHEET,.find td.High{color:var(--done)}+.empty{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:var(--dim);text-align:center;padding:24px}+</style>+</head>+<body>+<header>+  <h1>AI Flow: Reverse Engineering</h1>+  <div class="proj" id="proj"></div>+  <button class="live on" id="live" title="Follow the run: show each new view as it is recorded"><i></i><span>Live</span></button>+</header>+<main>+  <aside>+    <h2>Steps</h2><div id="steps"></div>+    <h2>Process record</h2><div id="hist"></div>+  </aside>+  <section>+    <div class="bar"><div id="tabs"></div><div class="what" id="what"></div></div>+    <div class="todo" id="todo"></div>+    <div class="view" id="view"><div class="empty">No views yet. Each stage records its view here when it completes.</div></div>+  </section>+</main>+<script>+let S = null, follow = true, selEntry = null, selKind = null, lastCount = -1, selStep = null, findSig = "";+// deep link to one recorded view: #v=<view number>&k=pcb|sch|photos+const hp = new URLSearchParams(location.hash.slice(1));+let pin = hp.get("v") ? {n: +hp.get("v"), k: hp.get("k")} : null;+const wantKind = hp.get("v") ? null : hp.get("k");   // #k=timeline|findings|pcb|sch|photos keeps that tab while following+if (hp.get("s")) selStep = hp.get("s");             // #s=<step> shows that step's findings+const $ = id => document.getElementById(id);+const esc = s => String(s ?? "").replace(/[&<>"]/g, c => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[c]));+const label = {timeline: "AI Flow timeline", pcb: "PCB", sch: "Schematic", photos: "Photos", findings: "Findings"};+const time = t => (t || "").replace("T", " ").replace("Z", " UTC");++async function poll() {+  try {+    const r = await fetch("state", {cache: "no-store"});+    S = await r.json();+    const n = (S.views || []).length;+    if (pin) { const i = (S.views || []).findIndex(e => e.n === pin.n); if (i >= 0) { selEntry = i; selKind = pin.k; follow = false; setLive(); pin = null; } }+    if (follow && n !== lastCount && n > 0) { selEntry = n - 1; selKind = wantKind; }+    lastCount = n;+    render();+  } catch (e) { $("what").textContent = "Flow window lost the server; retrying..."; }+}++function render() {+  $("proj").textContent = S.project || "";+  $("steps").innerHTML = S.steps.map(s => `<div class="step ${entryStep() === s.name ? "sel" : ""}" data-step="${esc(s.name)}"><span class="chip ${s.state}">${s.state === "todo" ? "" : s.state}</span><span>${esc(s.name)}</span></div>`).join("");+  const V = S.views || [];+  $("hist").innerHTML = V.slice().reverse().map(e => `<div class="hist ${V.indexOf(e) === selEntry ? "sel" : ""}" data-i="${V.indexOf(e)}"><span class="n">#${e.n}</span><span><div>${esc(e.step)}</div><div class="r">${esc(e.reason)}</div></span><span class="t">${esc(time(e.at).slice(11, 16))}</span></div>`).join("") || `<div class="r" style="padding:8px;color:var(--dim)">Nothing recorded yet.</div>`;+  document.querySelectorAll(".step").forEach(el => el.onclick = () => { const i = lastOf(el.dataset.step); follow = false; setLive(); selStep = el.dataset.step; if (i !== null) selEntry = i; if (!selKind || selKind === "timeline") selKind = "findings"; render(); showTodo(el.dataset.step); });+  document.querySelectorAll(".hist").forEach(el => el.onclick = () => { follow = false; setLive(); selEntry = +el.dataset.i; selStep = null; selKind = null; render(); });+  showEntry();+  if (follow) showTodo(S.current);+}++function lastOf(step) { const V = S.views || []; for (let i = V.length - 1; i >= 0; i--) if (V[i].step === step) return i; return null; }+function entryStep() { if (selStep) return selStep; const e = (S.views || [])[selEntry]; return e ? e.step : S.current; }+function showTodo(step) { const s = S.steps.find(x => x.name === step); $("todo").textContent = s ? `${s.name}: ${s.do}` + (s.stale.length ? `  (stale: ${s.stale.join(", ")} changed)` : "") : "Every step is done."; }++function showEntry() {+  const e = (S.views || [])[selEntry];+  const kinds = ["timeline"].concat(e ? e.views.map(v => v.kind) : [], ["findings"]);+  if (!selKind || !kinds.includes(selKind)) selKind = e ? e.views[0]?.kind || "timeline" : "timeline";+  $("tabs").innerHTML = kinds.map(k => `<button class="tab ${k === selKind ? "sel" : ""}" data-k="${k}">${label[k] || k}</button>`).join(" ");+  document.querySelectorAll(".tab").forEach(b => b.onclick = () => { selKind = b.dataset.k; showEntry(); });+  const box = $("view");+  if (selKind === "timeline") {+    $("what").textContent = "AI Flow timeline: the step path, loop backs and what each finished step found";+    if (box.dataset.src !== "timeline/") { box.dataset.src = "timeline/"; box.innerHTML = `<iframe src="timeline/" title="AI Flow timeline" style="background:#0d1117"></iframe>`; }+    return;+  }+  if (selKind === "findings") { showFindings(box); return; }+  $("what").textContent = `View #${e.n} · ${e.step} · ${e.reason} · ${time(e.at)}`;+  const v = e.views.find(x => x.kind === selKind);+  if (!v) return;+  if (v.kind === "photos") {+    if (box.dataset.src === "photos") return;+    box.dataset.src = "photos";+    const ph = (S.photos || []).map(p => ({src: "files/photos/" + encodeURIComponent(p.file), cap: `${p.file} · ${p.side}`}));+    const ev = (S.evidence || []).map(f => ({src: "files/" + f.split("/").map(encodeURIComponent).join("/"), cap: f}));+    box.innerHTML = `<div class="grid">${ph.concat(ev).map(x => `<figure><a href="${x.src}" target="_blank"><img loading="lazy" src="${x.src}" alt="${esc(x.cap)}"></a><figcaption>${esc(x.cap)}</figcaption></figure>`).join("")}</div>`;+    return;+  }+  const src = "files/" + v.file.split("/").map(encodeURIComponent).join("/");+  if (box.dataset.src === src) return;+  box.dataset.src = src;+  box.innerHTML = `<iframe src="${src}" title="${label[v.kind]} view ${e.n}"></iframe>`;+}++function showFindings(box) {+  const step = entryStep();+  const f = (S.findings || {})[step];+  const title = (S.titles || {})[step] || step;+  $("what").textContent = `Findings · ${title} · from the step's files`;+  const sig = step + JSON.stringify(f || null);+  if (box.dataset.src === "findings" && findSig === sig) return;+  box.dataset.src = "findings"; findSig = sig;+  if (!f) { box.innerHTML = `<div class="empty">Nothing recorded for ${esc(title)} yet.</div>`; return; }+  const cls = c => /^(SEEN|MEASURED|DATASHEET|INFERRED|UNKNOWN|High|Medium|Low)$/.test(c) ? `tag ${c}` : "";+  const tables = (f.tables || []).filter(t => t.rows.length).map(t => `<h3>${esc(t.title)} <span style="color:var(--dim);font-weight:400">(${t.rows.length})</span></h3><table><tr>${t.cols.map(c => `<th>${esc(c)}</th>`).join("")}</tr>${t.rows.map(r => `<tr>${r.map(c => `<td class="${cls(c)}">${esc(c)}</td>`).join("")}</tr>`).join("")}</table>`).join("");+  box.innerHTML = `<div class="find"><p class="sum">${esc(f.summary)}</p>${tables}</div>`;+}++function setLive() { $("live").classList.toggle("on", follow); }+$("live").onclick = () => { follow = !follow; setLive(); if (follow) { lastCount = -1; poll(); } };+poll(); setInterval(poll, 2000);+</script>+</body>+</html>
assets/timeline-favicon.svgadded+1
@@ -0,0 +1 @@+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#e6edf3" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="5" cy="5" r="2.2"/><circle cx="5" cy="19" r="2.2"/><path d="M5 7.5v9M8 5h6a5 5 0 0 1 0 10h-3"/><path d="M13 12.5 10.5 15l2.5 2.5"/></svg>
assets/timeline.htmladded+1407
@@ -0,0 +1,1407 @@+<!doctype html>+<html lang="en">+<head>+<meta charset="utf-8">+<meta name="viewport" content="width=device-width, initial-scale=1">+<title>AI Flow run</title>+<link rel="icon" type="image/svg+xml" href="favicon.svg">+<link rel="preconnect" href="https://fonts.googleapis.com">+<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>+<link href="https://fonts.googleapis.com/css2?family=Familjen+Grotesk:wght@500;600;700&family=DM+Sans:opsz,[email protected],400;9..40,500;9..40,600;9..40,700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">+<style>+:root {+  color-scheme: dark;+  --bg: #0d1117;+  --surface: #161b22;+  --elevated: #1c2128;+  --overlay: #21262d;+  --border: #30363d;+  --border-soft: #21262d;+  --text: #e6edf3;+  --text-body: #c9d1d9;+  --text-2: #8b949e;+  --text-3: #484f58;+  --accent: #00b8b1;+  --accent-bright: #00e6dc;+  --accent-glow: rgba(0, 184, 177, .38);+  --accent-wash: rgba(0, 184, 177, .10);+  --on-accent: #05221f;+  --purple: #8c6bf7;+  --purple-wash: rgba(140, 107, 247, .13);+  --blue: #64abff;+  --sig: linear-gradient(100deg, #00e6dc, #39b8ff 60%, #8c6bf7);++  --f-display: 'Familjen Grotesk', 'DM Sans', system-ui, sans-serif;+  --f-body: 'Satoshi', 'DM Sans', system-ui, sans-serif;+  --f-mono: 'JetBrains Mono', ui-monospace, monospace;++  --pad: 20px;+  --row: 32px;+  --row-cur: 86px;+  --gut: 64px;+  --node-col: 40px;+  --phase-col: 112px;+  --ease: cubic-bezier(.65, 0, .35, 1);+  --ease-out: cubic-bezier(.22, 1, .36, 1);+}+* { box-sizing: border-box; }+html, body { margin: 0; background: var(--bg); color: var(--text-body); }+body {+  font-family: var(--f-body);+  font-size: 15px;+  line-height: 1.4;+  overflow-x: hidden;+  -webkit-font-smoothing: antialiased;+  font-variant-numeric: tabular-nums;+  min-height: 100vh;+}+body::before {+  /* very soft teal light from the top, gives the ground some depth on camera */+  content: ""; position: fixed; inset: 0; pointer-events: none; z-index: 0;+  background:+    radial-gradient(1200px 420px at 15% -120px, rgba(0, 184, 177, .07), transparent 70%);+}+.mono { font-family: var(--f-mono); font-variant-numeric: tabular-nums; }+svg.i { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; flex: none; }++/* ---------- app bar (44px, three zones) ---------- */+.appbar {+  position: relative; z-index: 2;+  height: 44px; display: flex; align-items: center; gap: 12px;+  padding: 0 var(--pad);+  border-bottom: 1px solid var(--border-soft);+  background: rgba(13, 17, 23, .7);+}+.brand { display: flex; align-items: center; gap: 8px; color: var(--accent); font-family: var(--f-display); font-weight: 600; font-size: 15px; letter-spacing: .1px; white-space: nowrap; cursor: default; }+.brand svg.i { width: 16px; height: 16px; color: var(--text); }+.brand .sep { color: var(--text-2); }+.vsep { width: 1px; height: 20px; background: var(--border); flex: none; }+.subject { display: flex; align-items: baseline; gap: 10px; min-width: 0; }+.subject b { color: var(--text); font-family: var(--f-display); font-weight: 600; font-size: 15px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }+.subject .meta2 { font-family: var(--f-mono); font-size: 12px; color: var(--text-2); white-space: nowrap; display: inline-flex; align-items: center; gap: 7px; }+.appbar .spacer { flex: 1; }+.chip {+  display: inline-flex; align-items: center; gap: 7px; height: 26px; padding: 0 11px;+  border: 1px solid var(--border); border-radius: 999px; color: var(--text-body);+  font-size: 13px; font-weight: 500; white-space: nowrap; background: var(--surface);+}+.chip svg.i { width: 15px; height: 15px; color: var(--text); }+.live .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--accent); box-shadow: 0 0 0 0 rgba(0, 184, 177, .6); animation: livepulse 2.4s ease-out infinite; align-self: center; }+.live.stale .dot { background: var(--text-2); animation: none; }+.live.idle .dot { background: var(--text-2); animation: none; }+.live .when { color: var(--text-2); font-family: var(--f-mono); font-size: 12px; }+@keyframes livepulse { 0% { box-shadow: 0 0 0 0 rgba(0, 184, 177, .55); } 70%, 100% { box-shadow: 0 0 0 7px rgba(0, 184, 177, 0); } }++/* ---------- hero header ---------- */+.hero { position: relative; z-index: 2; padding: 14px var(--pad) 0; display: grid; grid-template-columns: 1fr auto; gap: 6px 20px; align-items: center; }+.hero h1 {+  margin: 0; font-family: var(--f-display); font-weight: 700; color: var(--text);+  font-size: 26px; line-height: 1.12; letter-spacing: -.2px;+  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;+}+.runline { display: flex; align-items: baseline; gap: 0; margin-top: 4px; min-width: 0; overflow: hidden; white-space: nowrap; font-family: var(--f-mono); font-size: 12.5px; color: var(--text-2); }+.runline span { flex: none; white-space: nowrap; }+.runline span:not(.first)::before { content: "\00b7"; margin: 0 8px; color: var(--text-2); }+.runline span[hidden] { display: none; }+.runline .thread { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; color: var(--text-body); }+.finishline { display: flex; align-items: center; gap: 8px; margin-top: 5px; color: var(--text-2); font-size: 14px; min-width: 0; }+.finishline svg.i { color: var(--text); width: 17px; height: 17px; }+.finishline b { color: var(--text-body); font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }+.ring { position: relative; width: 84px; height: 84px; grid-row: span 2; }+.ring svg { width: 84px; height: 84px; transform: rotate(-90deg); }+.ring circle { fill: none; stroke-width: 7; }+.ring .track { stroke: var(--overlay); }+.ring .redo { stroke: var(--purple); opacity: .85; transition: stroke-dasharray .9s var(--ease), stroke-dashoffset .9s var(--ease); }+.ring .done { stroke: url(#ringgrad); stroke-linecap: round; transition: stroke-dasharray .9s var(--ease); }+.ring .pct { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; }+.ring .pct b { font-family: var(--f-mono); font-weight: 600; font-size: 21px; color: var(--text); line-height: 1; }+.ring .pct b small { font-size: 12px; color: var(--text-2); font-weight: 500; margin-left: 1px; }+.ring .pct span { font-size: 11px; color: var(--text-2); margin-top: 3px; letter-spacing: .4px; }++.stats { grid-column: 1 / 2; display: flex; flex-wrap: wrap; gap: 6px 26px; align-items: baseline; margin-top: 4px; }+.stat { display: flex; align-items: baseline; gap: 7px; white-space: nowrap; font-size: 16px; color: var(--text-2); }+.stat b { font-family: var(--f-mono); font-weight: 600; color: var(--text); font-size: 26px; line-height: 1; letter-spacing: -.5px; transition: color .4s; }+.stat.stepno { color: var(--text-body); font-family: var(--f-display); font-weight: 600; font-size: 19px; }+.stat.stepno b { color: var(--accent-bright); font-size: 30px; }+.stat.loops b { color: var(--purple); }+.stat.loops.none b { color: var(--text-2); }+.stat .unit { font-size: 14px; }++/* ---------- latest loop callout ---------- */+.callout-wrap { position: relative; z-index: 2; padding: 0 var(--pad); display: grid; grid-template-rows: 0fr; transition: grid-template-rows .6s var(--ease-out), margin .6s var(--ease-out); margin-top: 0; }+.callout-wrap.on { grid-template-rows: 1fr; margin-top: 12px; }+.callout-wrap > div { overflow: hidden; min-height: 0; }+.callout {+  display: grid; grid-template-columns: auto 1fr; gap: 2px 12px; align-items: start;+  padding: 8px 14px 9px 12px; border-radius: 12px; cursor: default;+  background: linear-gradient(90deg, var(--purple-wash), rgba(140, 107, 247, .04));+  border: 1px solid rgba(140, 107, 247, .35);+  transition: border-color .2s ease, background .2s ease;+}+.callout:hover { border-color: rgba(140, 107, 247, .7); }+.callout .ic { grid-row: span 2; width: 32px; height: 32px; border-radius: 50%; display: grid; place-items: center; background: rgba(140, 107, 247, .18); color: var(--text); margin-top: 1px; }+.callout .ic svg.i { width: 17px; height: 17px; }+.callout .head { font-size: 14px; color: var(--purple); font-weight: 600; display: flex; gap: 8px; align-items: baseline; flex-wrap: wrap; }+.callout .head em { font-style: normal; color: var(--text); }+.callout .head .t { margin-left: auto; color: var(--text-2); font-family: var(--f-mono); font-size: 12px; font-weight: 400; }+.callout .why { color: var(--text-body); font-size: 15px; line-height: 1.38; }+.callout.fresh { animation: calloutin 1.4s var(--ease-out); }+@keyframes calloutin { 0% { box-shadow: 0 0 0 0 rgba(140, 107, 247, .55); } 100% { box-shadow: 0 0 0 14px rgba(140, 107, 247, 0); } }++/* ---------- stage: the timeline ---------- */+.stage { position: relative; z-index: 1; margin: 10px var(--pad) 0; padding: 0 0 8px; }+.overlay { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; pointer-events: none; z-index: 4; }+.spine, .spine-fill { position: absolute; z-index: 0; border-radius: 2px; }+.spine { background: var(--border); }+.spine-fill { background: linear-gradient(180deg, rgba(0, 184, 177, .7), var(--accent-bright)); transition: height .8s var(--ease), width .8s var(--ease); box-shadow: 0 0 10px rgba(0, 184, 177, .25); }+.halo { position: absolute; left: 0; top: 0; width: 0; height: 0; z-index: 2; pointer-events: none; transition: transform .85s var(--ease); }+.halo::before, .halo::after {+  content: ""; position: absolute; left: -22px; top: -22px; width: 44px; height: 44px; border-radius: 50%;+  border: 2px solid var(--accent); opacity: 0;+  animation: ping 2.2s var(--ease-out) infinite;+}+.halo::after { animation-delay: 1.1s; }+.halo .glow { position: absolute; left: -26px; top: -26px; width: 52px; height: 52px; border-radius: 50%; background: radial-gradient(circle, var(--accent-glow), transparent 68%); }+.halo.hide { opacity: 0; }+@keyframes ping { 0% { transform: scale(.55); opacity: .85; } 100% { transform: scale(1.45); opacity: 0; } }++ol.steps { list-style: none; margin: 0; padding: 0; position: relative; z-index: 3; }+li.step {+  position: relative;+  display: grid; grid-template-columns: var(--gut) var(--node-col) minmax(0, 1fr) auto var(--phase-col);+  align-items: center; height: var(--row);+  transition: height .6s var(--ease);+}+li.step.cur { height: var(--row-cur); }+li.step .rowbg {+  position: absolute; left: calc(var(--gut) - 8px); right: calc(var(--phase-col) - 10px); top: 3px; bottom: 3px;+  border-radius: 12px; opacity: 0; transition: opacity .5s ease;+  background: linear-gradient(90deg, rgba(0, 184, 177, .14), rgba(0, 184, 177, .03) 70%);+  border: 1px solid rgba(0, 184, 177, .34);+  box-shadow: 0 8px 30px rgba(0, 0, 0, .25);+}+li.step.cur .rowbg { opacity: 1; }+li.step:not(.cur):hover .rowbg { opacity: .45; background: var(--surface); border-color: var(--border); box-shadow: none; }++.node {+  grid-column: 2; justify-self: center; position: relative; z-index: 2;+  width: 22px; height: 22px; border-radius: 50%;+  display: grid; place-items: center;+  background: var(--bg); border: 2px solid var(--text-3);+  transition: background .45s ease, border-color .45s ease, transform .45s var(--ease-out), width .45s var(--ease-out), height .45s var(--ease-out), box-shadow .45s ease;+}+.node > svg { grid-area: 1 / 1; }+.node svg { width: 14px; height: 14px; fill: none; stroke-width: 3; stroke-linecap: round; stroke-linejoin: round; opacity: 0; transition: opacity .3s ease; }+.node .ck { stroke: var(--on-accent); stroke-dasharray: 20; stroke-dashoffset: 20; }+.node .rd { stroke: var(--text); stroke-width: 2.4; width: 13px; height: 13px; }+.node .cu { stroke: none; fill: var(--accent-bright); width: 10px; height: 10px; }++li.done .node { background: var(--accent); border-color: var(--accent); }+li.done .node .ck { opacity: 1; stroke-dashoffset: 0; transition: stroke-dashoffset .5s var(--ease-out) .1s, opacity .1s; }+li.redo .node { background: #17142b; border-color: var(--purple); }+li.redo .node .rd { opacity: 1; }+li.cur .node { width: 30px; height: 30px; background: var(--bg); border: 3px solid var(--accent-bright); box-shadow: 0 0 0 5px rgba(0, 230, 220, .14), 0 0 24px rgba(0, 230, 220, .55); }+li.cur .node svg.cu { opacity: 1; }+li.just-done .node { animation: donepop .9s var(--ease-out); }+@keyframes donepop { 0% { transform: scale(.6); box-shadow: 0 0 0 0 rgba(0, 184, 177, .7); } 45% { transform: scale(1.18); } 100% { transform: scale(1); box-shadow: 0 0 0 16px rgba(0, 184, 177, 0); } }+li.just-redo .node { animation: redopop 1s var(--ease-out); }+@keyframes redopop { 0% { box-shadow: 0 0 0 0 rgba(140, 107, 247, .7); } 100% { box-shadow: 0 0 0 16px rgba(140, 107, 247, 0); } }++.label { grid-column: 3; min-width: 0; padding-left: 8px; position: relative; z-index: 2; }+.label .t { display: flex; align-items: center; gap: 8px; min-width: 0; font-size: 16px; font-weight: 500; color: var(--text-body); transition: color .4s ease, font-size .45s var(--ease); }+.label .t .nm { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }+.label .n { font-family: var(--f-mono); font-size: 12px; color: var(--text-2); width: 20px; flex: none; text-align: right; margin-right: 2px; transition: color .4s; }+li.todo .label .t { color: var(--text-2); }+li.redo .label .t { color: var(--text-body); }+li.cur .label .t { font-family: var(--f-display); font-size: 23px; font-weight: 700; color: var(--text); letter-spacing: -.1px; }+li.cur .label .n { color: var(--accent-bright); }+.badge {+  flex: none; font-family: var(--f-mono); font-size: 12px; font-weight: 600; line-height: 1;+  padding: 3px 6px 3px 5px; border-radius: 999px; display: inline-flex; align-items: center; gap: 3px;+  color: var(--purple); background: var(--purple-wash); border: 1px solid rgba(140, 107, 247, .4);+  transition: opacity .3s ease, transform .3s var(--ease-out);+}+.badge svg { width: 11px; height: 11px; fill: none; stroke: var(--text); stroke-width: 2.4; stroke-linecap: round; stroke-linejoin: round; }+.badge.off { display: none; }+li.cur .badge { font-size: 13px; }+.label .does {+  font-size: 15px; color: var(--text-body); line-height: 1.35; margin-top: 3px; padding-left: 30px;+  display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;+  max-height: 0; opacity: 0; transition: opacity .4s ease .15s, max-height .5s var(--ease);+}+li.cur .label .does { max-height: 42px; opacity: 1; }+.meta { grid-column: 4; position: relative; z-index: 2; padding: 0 14px 0 10px; font-family: var(--f-mono); font-size: 13px; color: var(--text-2); white-space: nowrap; text-align: right; }+li.cur .meta { color: var(--text-body); }+.meta .redo-tag { color: var(--purple); font-family: var(--f-body); font-size: 13px; font-weight: 600; }++/* finish row */+li.finish .node { width: 30px; height: 30px; border-radius: 9px; background: var(--surface); border: 2px solid var(--border); color: var(--text); }+li.finish .node svg.fl { width: 16px; height: 16px; opacity: 1; stroke: currentColor; stroke-width: 2; }+li.finish .label .t { font-family: var(--f-display); font-weight: 600; color: var(--text-body); font-size: 16px; }+li.finish .label .t .nm { white-space: nowrap; }+li.finish .label .t .k { color: var(--text-2); font-weight: 500; }+li.finish.won .node { background: var(--sig); border-color: transparent; color: var(--bg); box-shadow: 0 0 26px rgba(57, 184, 255, .45); }+li.finish.won .label .t { color: var(--text); font-size: 23px; font-weight: 700; }+li.finish.won { height: var(--row-cur); }+li.finish.won .rowbg { opacity: 1; background: linear-gradient(100deg, rgba(0, 230, 220, .14), rgba(57, 184, 255, .08) 60%, rgba(140, 107, 247, .10)); border-color: rgba(57, 184, 255, .45); }+body.wide li.finish.won { height: auto; }+body.wide li.finish.won .label .t { font-size: 15px; }+.stat.stepno.won b { background: var(--sig); -webkit-background-clip: text; background-clip: text; color: transparent; }++/* phase brackets */+.phase { position: absolute; z-index: 2; pointer-events: auto; transition: top .6s var(--ease), height .6s var(--ease), left .6s var(--ease), width .6s var(--ease); }+.phase .bar { position: absolute; background: var(--border); border-radius: 2px; transition: background .5s ease; }+.phase .nm { position: absolute; font-family: var(--f-display); font-weight: 600; font-size: 14px; color: var(--text-2); white-space: nowrap; transition: color .5s; }+.phase .ct { position: absolute; font-family: var(--f-mono); font-size: 12px; color: var(--text-2); white-space: nowrap; }+.phase.active .bar { background: var(--accent); box-shadow: 0 0 8px rgba(0, 184, 177, .45); }+.phase.active .nm { color: var(--text); }+.phase.complete .bar { background: rgba(0, 184, 177, .75); }+.phase.complete .nm { color: var(--text-body); }+.phase.rework .bar { background: var(--purple); }+.phase.rework .nm { color: var(--text-body); }++/* loop arcs */+.arc { fill: none; stroke: var(--purple); stroke-width: 2.4; stroke-linecap: round; opacity: .5; transition: opacity .3s ease, stroke-width .3s ease; }+.arc.latest { opacity: 1; stroke: var(--purple); filter: drop-shadow(0 0 5px rgba(140, 107, 247, .55)); }+.arc.new { stroke-dasharray: 1; stroke-dashoffset: 1; animation: draw 1.3s var(--ease) forwards; }+.arrow { fill: var(--purple); opacity: .5; transition: opacity .3s ease; }+.arrow.latest { fill: var(--purple); opacity: 1; }+.arrow.new { animation: fadein .3s ease 1.15s both; }+.arc-hit { fill: none; stroke: transparent; stroke-width: 16; pointer-events: stroke; cursor: help; }+.loopbadge { pointer-events: auto; cursor: help; }+.loopbadge circle { fill: #1b1735; stroke: var(--purple); stroke-width: 1.6; }+.loopbadge.latest circle { fill: var(--purple); stroke: var(--purple); }+.loopbadge text { font-family: var(--f-mono); font-size: 11.5px; font-weight: 600; fill: var(--purple); text-anchor: middle; dominant-baseline: central; }+.loopbadge.latest text { fill: #0d1117; }+.loopbadge.new { animation: fadein .35s ease 1.1s both; }+.arcgrp.hl .arc { opacity: 1; stroke-width: 3.4; }+.arcgrp.hl .arrow { opacity: 1; }+.arcgrp.dim .arc, .arcgrp.dim .arrow { opacity: .18; }+@keyframes draw { to { stroke-dashoffset: 0; } }+@keyframes fadein { from { opacity: 0; } }++body.notrans *, body.notrans *::before, body.notrans *::after { transition: none !important; }++.arcpill {+  position: absolute; z-index: 4; left: 0; top: 0; display: none; align-items: center; gap: 9px;+  max-width: min(900px, calc(100% - 16px)); padding: 6px 18px 6px 6px; border-radius: 18px; cursor: help;+  background: #1a1631; border: 1.5px solid var(--purple); color: var(--text-body);+  font-size: 14.5px; line-height: 1.25; box-shadow: 0 6px 24px rgba(0, 0, 0, .45), 0 0 18px rgba(140, 107, 247, .25);+  transition: left .6s var(--ease), top .6s var(--ease), opacity .4s ease;+}+.arcpill .num { flex: none; width: 22px; height: 22px; border-radius: 50%; background: var(--purple); color: #0d1117; font-family: var(--f-mono); font-size: 12px; font-weight: 600; display: grid; place-items: center; }+.arcpill .tx { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }+.arcpill b { color: var(--purple); font-weight: 600; }+body.wide .arcpill.on { display: flex; }+body.wide .loopbadge.latest { display: none; }+.arcpill.new { animation: fadein .4s ease 1.2s both; }++/* ---------- wide layout: horizontal timeline ---------- */+.lower { display: none; }+body.wide { --pad: 32px; }+body.wide .hero { grid-template-columns: minmax(0, 1fr) auto auto; padding-top: 22px; }+body.wide .hero h1 { font-size: 32px; }+body.wide .ring { grid-row: 1 / 3; grid-column: 3; }+body.wide .stats { grid-column: 2; grid-row: 1 / 3; gap: 10px 34px; margin: 0 6px 0 0; flex-wrap: nowrap; }+body.wide .stat b { font-size: 30px; }+body.wide .stat.stepno b { font-size: 36px; }+body.wide .callout-wrap { display: none; }+body.wide .stage { margin-top: 22px; background: var(--surface); border: 1px solid var(--border-soft); border-radius: 16px; padding: 18px 18px 16px; }+body.wide ol.steps { display: grid; grid-auto-flow: column; grid-auto-columns: minmax(0, 1fr); }+body.wide li.step, body.wide li.step.cur {+  height: auto; display: flex; flex-direction: column; align-items: center; text-align: center;+  padding-top: var(--htop); grid-template-columns: none;+}+body.wide .node { flex: none; }+body.wide li.step .rowbg { left: 3px; right: 3px; top: calc(var(--htop) - 18px); bottom: -6px; }+body.wide .label { padding: 10px 2px 0; width: 100%; }+body.wide .label .t { flex-direction: column; gap: 5px; font-size: 14px; line-height: 1.2; padding: 0 2px; hyphens: auto; font-size: 13.5px; }+body.wide .label .t .nm { white-space: normal; overflow: visible; text-wrap: balance; }+body.wide li.cur .label .t { font-size: 16px; color: var(--accent-bright); }+body.wide .label .n { width: auto; margin: 0; text-align: center; }+body.wide .label .does { display: none; }+body.wide .meta { padding: 4px 0 0; text-align: center; font-size: 12px; }+body.wide li.finish .label .t .k { display: block; }+body.wide .lower { display: grid; grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr); gap: 18px; margin: 18px var(--pad) 24px; position: relative; z-index: 2; }+.card { background: var(--surface); border: 1px solid var(--border-soft); border-radius: 16px; padding: 18px 22px; min-width: 0; }+.card .eyebrow { font-family: var(--f-display); font-size: 12px; font-weight: 600; letter-spacing: 1.2px; text-transform: uppercase; color: var(--text-2); display: flex; align-items: center; gap: 8px; }+.card .eyebrow svg.i { width: 15px; height: 15px; color: var(--text); }+.now h2 { font-family: var(--f-display); font-size: 34px; font-weight: 700; color: var(--text); margin: 10px 0 6px; line-height: 1.1; display: flex; align-items: center; gap: 12px; }+.now h2 .n:empty { display: none; }+.now h2 .n { font-family: var(--f-mono); color: var(--accent-bright); font-size: 22px; font-weight: 600; }+.now p { margin: 0; font-size: 19px; line-height: 1.45; color: var(--text-body); }+.now .facts { display: flex; gap: 22px; margin-top: 14px; color: var(--text-2); font-size: 14px; flex-wrap: wrap; }+.now .facts b { font-family: var(--f-mono); color: var(--text); font-weight: 500; }+.looplist { margin: 10px 0 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 10px; }+.looplist li { display: grid; grid-template-columns: 26px 1fr auto; gap: 3px 12px; align-items: baseline; padding: 9px 12px; border-radius: 10px; border: 1px solid transparent; cursor: default; transition: background .2s ease, border-color .2s ease; }+.looplist li:hover { background: var(--elevated); }+.looplist li.latest { background: var(--purple-wash); border-color: rgba(140, 107, 247, .35); }+.looplist .num { grid-row: span 2; align-self: start; width: 24px; height: 24px; border-radius: 50%; display: grid; place-items: center; font-family: var(--f-mono); font-size: 12px; font-weight: 600; color: var(--purple); border: 1.5px solid var(--purple); }+.looplist li.latest .num { background: var(--purple); color: #0d1117; }+.looplist .path { font-weight: 600; color: var(--text); font-size: 15px; display: flex; gap: 7px; align-items: center; }+.looplist .path svg.i { width: 15px; height: 15px; color: var(--text); }+.looplist .tm { font-family: var(--f-mono); font-size: 12px; color: var(--text-2); }+.looplist .why { grid-column: 2 / 4; color: var(--text-body); font-size: 15px; }+.looplist .empty { color: var(--text-2); font-size: 15px; display: block; padding: 6px 0; }++/* ---------- waiting state ---------- */+.waiting { display: none; position: fixed; inset: 0; z-index: 5; place-items: center; text-align: center; padding: 24px; }+body.waiting-on .waiting { display: grid; }+body.waiting-on .appbar ~ *:not(.waiting) { visibility: hidden; }+.waiting .orb { width: 74px; height: 74px; margin: 0 auto 22px; border-radius: 50%; position: relative; display: grid; place-items: center; color: var(--text); }+.waiting .orb::before { content: ""; position: absolute; inset: 0; border-radius: 50%; border: 2px solid var(--border); border-top-color: var(--accent); animation: spin 2.4s linear infinite; }+.waiting .orb svg.i { width: 28px; height: 28px; }+.waiting h2 { font-family: var(--f-display); font-weight: 700; font-size: 26px; color: var(--text); margin: 0 0 8px; }+.waiting p { margin: 0 auto; max-width: 420px; color: var(--text-2); font-size: 15px; line-height: 1.5; }+.waiting code { font-family: var(--f-mono); font-size: 13px; color: var(--text-body); background: var(--surface); border: 1px solid var(--border); padding: 1px 6px; border-radius: 6px; }+@keyframes spin { to { transform: rotate(360deg); } }++/* ---------- tooltip ---------- */+#tip {+  position: fixed; z-index: 99999; left: 0; top: 0; display: none; opacity: 0; pointer-events: none;+  max-width: min(340px, calc(100vw - 24px));+  background: rgba(33, 38, 45, .97); color: var(--text-body);+  border: 1px solid rgba(0, 184, 177, .28); border-radius: 10px;+  padding: 10px 13px; font: 400 14px/1.45 var(--f-body);+  box-shadow: 0 14px 40px rgba(0, 0, 0, .55);+  transition: opacity .18s ease, transform .18s ease;+}+#tip.loopy { border-color: rgba(140, 107, 247, .5); }+#tip.on { display: block; }+#tip.shown { opacity: 1; }+#tip .tt { font-family: var(--f-display); font-weight: 700; font-size: 15px; color: var(--text); margin-bottom: 3px; display: flex; gap: 8px; align-items: baseline; }+#tip .tt .s { margin-left: auto; font-family: var(--f-body); font-weight: 600; font-size: 12px; }+#tip .s.done { color: var(--accent); } #tip .s.current { color: var(--accent-bright); } #tip .s.redo { color: var(--purple); } #tip .s.todo { color: var(--text-2); }+#tip .ft { margin-top: 6px; color: var(--text-2); font-size: 12.5px; font-family: var(--f-mono); }++/* phones and very narrow panes */+@media (max-width: 620px) {+  :root { --pad: 14px; --phase-col: 0px; --row-cur: 96px; }+  .phase { display: none; }+  li.step .rowbg { right: 0; }+  .hero h1 { font-size: 22px; white-space: normal; }+  .ring { width: 70px; height: 70px; } .ring svg { width: 70px; height: 70px; }+  .stat b { font-size: 22px; } .stat.stepno b { font-size: 26px; }+}++@media (prefers-reduced-motion: reduce) {+  *, *::before, *::after { animation-duration: .001ms !important; animation-iteration-count: 1 !important; animation-delay: 0s !important; transition-duration: .001ms !important; transition-delay: 0s !important; }+  .halo::before, .halo::after { display: none; }+  .arc.new { stroke-dashoffset: 0; }+}++/* Timeline first; supporting project evidence follows the workflow. */+body { --pad: 28px; }+.hero, body.wide .hero { padding-top: 14px; padding-bottom: 6px; }+.hero h1, body.wide .hero h1 { font-size: clamp(24px, 2.5vw, 32px); white-space: normal; letter-spacing: -.7px; }+.finishline { margin-top: 10px; }+.overview { position: relative; display: grid; grid-template-columns: minmax(0,1.15fr) minmax(0,1fr); gap: 20px; margin: 24px var(--pad); }+.overview .card { padding: 24px; }+.board-preview { min-height: 250px; margin: 0; display: grid; place-items: center; overflow: hidden; background: radial-gradient(ellipse at center, #163534, #111820 75%); border: 1px solid var(--border-soft); border-radius: 16px; position: relative; }+.board-preview img { width: 100%; height: 290px; object-fit: contain; }+.board-preview figcaption { position: absolute; bottom: 0; left: 0; right: 0; padding: 10px 16px; background: #0d1117e8; font-size: 12px; color: var(--text-2); }+.preview-empty { text-align: center; padding: 30px; color: var(--text-2); }+.preview-empty svg { width: 56px; height: 56px; margin-bottom: 12px; color: var(--accent); }+.preview-empty strong { display: block; color: var(--text); font-size: 20px; margin-bottom: 6px; }+.overview .now h2 { font-size: 30px; margin-top: 18px; }+.overview .now p { font-size: 16px; }+.overview .now { border-top: 2px solid var(--accent); }+.recent-work { margin-top: 24px; padding-top: 16px; border-top: 1px solid var(--border-soft); }+.recent-work ul { list-style: none; padding: 0; margin: 10px 0 0; display: flex; flex-wrap: wrap; gap: 8px; }+.recent-work li { color: #8bd5a5; font-size: 12px; padding: 5px 9px; background: #16271f; border-radius: 6px; }+.section-heading { margin: 12px var(--pad) 4px; display: flex; justify-content: space-between; align-items: baseline; gap: 12px; }+.section-heading h2 { font-size: 18px; margin: 0; color: var(--text); }+.section-heading span { font-size: 12px; color: var(--text-2); }+.community { position: relative; margin: 24px var(--pad) 30px; padding: 22px 24px; display: flex; gap: 24px; align-items: center; justify-content: space-between; border: 1px solid var(--border-soft); border-radius: 14px; background: var(--surface); }+.community h2 { margin: 0 0 6px; font-size: 18px; color: var(--text); }+.community p { margin: 0; max-width: 570px; color: var(--text-2); font-size: 14px; }+.community nav { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; flex-shrink: 0; }+.community a { color: var(--text-body); text-decoration: none; font-size: 13px; border-radius: 6px; padding: 7px 2px; }+.community a.primary { background: var(--accent); color: var(--on-accent); padding: 10px 15px; font-weight: 700; }+.community a:hover { color: var(--accent-bright); }+.community a.primary:hover { color: var(--on-accent); background: var(--accent-bright); }+.community a:active { transform: translateY(1px); }+a:focus-visible, button:focus-visible { outline: 2px solid var(--accent-bright); outline-offset: 4px; }+.prompt-hint { font: inherit; border: 0; background: transparent; color: var(--text-2); padding: 0; width: 18px; height: 18px; cursor: pointer; }+.prompt-hint:hover { color: var(--accent-bright); }+.prompt-hint:active { transform: scale(.92); }+#copy-feedback { position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%); background: var(--elevated); padding: 10px 18px; border-radius: 8px; z-index: 100; }+body.wide .lower { grid-template-columns: 1fr; }+body.wide .stage { margin-top: 14px; }+@media (max-width: 800px) {+ body { --pad: 18px; }+ .overview { grid-template-columns: 1fr; gap: 14px; }+ .board-preview { min-height: 210px; }+ .board-preview img { height: 230px; }+ .community { flex-direction: column; align-items: flex-start; gap: 16px; }+ .section-heading { flex-wrap: wrap; }+}++.rules-card { margin: 0 var(--pad) 24px; }+.rules-card .card { padding: 20px 22px; }+.rules-card .rc-body { display: grid; grid-template-columns: minmax(0,1fr) minmax(0,1fr) minmax(0,1.2fr); gap: 16px; margin-top: 14px; align-items: start; }+.rules-card figure { margin: 0; background: #111820; border: 1px solid var(--border-soft); border-radius: 12px; padding: 10px; min-width: 0; }+.rules-card figure svg { display: block; width: 100%; height: 190px; }+.rules-card figcaption { font-size: 12px; color: var(--text-2); margin-top: 6px; }+.rules-card .rc-facts { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; font-size: 13px; color: var(--text-2); min-width: 0; }+.rules-card .rc-facts b { font-family: var(--f-mono); color: var(--text); font-weight: 500; }+.rules-card .rc-facts li.flag b, .rules-card .rc-flags li { color: var(--purple); }+.rules-card .rc-flags { list-style: none; margin: 12px 0 0; padding: 0; display: grid; gap: 6px; font-size: 13px; overflow-wrap: anywhere; }+.rules-card .rc-flags li::before { content: "! "; font-weight: 700; }+.rules-card .rc-note { margin: 10px 0 0; font-size: 12px; color: var(--text-2); }+@media (max-width: 800px) { .rules-card .rc-body { grid-template-columns: 1fr; } }++</style>+</head>+<body class="waiting-on">++<svg width="0" height="0" style="position:absolute" aria-hidden="true">+  <defs>+    <linearGradient id="ringgrad" x1="0" y1="0" x2="1" y2="1">+      <stop offset="0" stop-color="#00b8b1"/><stop offset="1" stop-color="#00e6dc"/>+    </linearGradient>+    <symbol id="i-flow" viewBox="0 0 24 24"><circle cx="5" cy="5" r="2.2"/><circle cx="5" cy="19" r="2.2"/><path d="M5 7.5v9M8 5h6a5 5 0 0 1 0 10h-3"/><path d="M13 12.5 10.5 15l2.5 2.5"/></symbol>+    <symbol id="i-flag" viewBox="0 0 24 24"><path d="M5 21V4"/><path d="M5 4.5c4.5-2 7.5 2 12 0v9c-4.5 2-7.5-2-12 0"/><path d="M9 4.2v9M13 5.4v9"/></symbol>+    <symbol id="i-loop" viewBox="0 0 24 24"><path d="M4 12a8 8 0 1 0 2.4-5.7"/><path d="M4 4v4.5h4.5"/></symbol>+    <symbol id="i-back" viewBox="0 0 24 24"><path d="M9 14 4 9l5-5"/><path d="M4 9h10a6 6 0 0 1 0 12h-3"/></symbol>+    <symbol id="i-cpu" viewBox="0 0 24 24"><rect x="6" y="6" width="12" height="12" rx="2"/><path d="M9 2.5v3.5M15 2.5v3.5M9 18v3.5M15 18v3.5M2.5 9H6M2.5 15H6M18 9h3.5M18 15h3.5"/></symbol>+    <symbol id="i-now" viewBox="0 0 24 24"><circle cx="12" cy="12" r="8.5"/><path d="M12 7.5V12l3 2"/></symbol>+  </defs>+</svg>++<header class="appbar">+  <div class="brand" data-tipkey="brand"><svg class="i"><use href="#i-flow"/></svg>AI Flow <span class="sep">&middot;</span> Timeline</div>+  <div class="vsep"></div>+  <div class="subject"><b id="engine" data-tipkey="engine" style="display:none"><span></span></b><span class="meta2 live idle" id="live" data-tipkey="live"><span class="dot"></span><span class="lbl">Live</span><span class="when" id="when"></span></span></div>+</header>++<section class="hero">+  <div style="min-width:0">+    <h1 id="title">AI Flow run</h1>+    <div class="runline" id="runline" data-tipkey="runline"><span id="rl-board" hidden></span><span id="rl-lane" hidden></span><span class="thread" id="rl-thread" hidden></span><span id="rl-folder" hidden></span></div>+    <div class="finishline" data-tipkey="finish"><svg class="i"><use href="#i-flag"/></svg><span>Finish line:</span><b id="finish"></b></div>+  </div>+  <div class="stats" id="stats">+    <div class="stat stepno" data-tipkey="stepno">Step <b id="s-step">0</b> of <span id="s-total">0</span></div>+    <div class="stat" data-tipkey="togo"><b id="s-togo">0</b> to go</div>+    <div class="stat" data-tipkey="minutes"><b id="s-min">0.0</b> <span class="unit">AI min</span></div>+    <div class="stat loops none" id="s-loops-w" data-tipkey="loops"><b id="s-loops">0</b> <span id="s-loops-l">loop backs</span></div>+  </div>+  <div class="ring" data-tipkey="ring">+    <svg viewBox="0 0 84 84"><circle class="track" cx="42" cy="42" r="36"/><circle class="redo" id="ring-redo" cx="42" cy="42" r="36"/><circle class="done" id="ring-done" cx="42" cy="42" r="36"/></svg>+    <div class="pct"><b id="pct">0<small>%</small></b><span>done</span></div>+  </div>+</section>++<div class="section-heading"><h2>From idea to tested board</h2><span>Workflow progress · engineering checks still apply</span></div>++<div class="callout-wrap" id="callout-wrap"><div><div class="callout" id="callout" data-tipkey="callout">+  <div class="ic"><svg class="i"><use href="#i-loop"/></svg></div>+  <div class="head"><span id="co-n">Loop back</span><em id="co-path"></em><span class="t" id="co-t"></span></div>+  <div class="why" id="co-why"></div>+</div></div></div>++<main class="stage" id="stage">+  <div class="spine" id="spine"></div>+  <div class="spine-fill" id="spine-fill"></div>+  <svg class="overlay" id="overlay"></svg>+  <div class="halo hide" id="halo"><div class="glow"></div></div>+  <div id="phases"></div>+  <div class="arcpill" id="arcpill"><span class="num"></span><span class="tx"></span></div>+  <ol class="steps" id="steps"></ol>+</main>++<section class="overview" aria-label="Project overview">+  <figure class="board-preview">+    <div class="preview-empty" id="preview-empty"><svg class="i"><use href="#i-cpu"/></svg><strong id="project-phase">Define the project</strong><p id="project-context">Requirements, decisions, and evidence guide each stage.</p></div>+    <img id="board-image" hidden alt="Saved board view">+    <figcaption id="board-caption" hidden></figcaption>+  </figure>+  <div class="card now">+    <div class="eyebrow"><svg class="i"><use href="#i-now"/></svg>Now working on</div>+    <h2><span class="n" id="now-n"></span><span id="now-title"></span></h2>+    <p id="now-does"></p>+    <div class="facts" id="now-facts"></div>+    <div class="recent-work"><div class="eyebrow">Completed in this workflow</div><ul id="recent-work"></ul></div>+  </div>+</section>+++<section class="rules-card" id="rules-card" hidden aria-label="Design rules at start">+  <div class="card">+    <div class="eyebrow"><svg class="i"><use href="#i-cpu"/></svg>Design rules at start <span id="rc-lane"></span></div>+    <div class="rc-body">+      <figure><svg id="rc-board" role="img" aria-label="Board outline to scale"></svg><figcaption id="rc-board-cap"></figcaption></figure>+      <figure><svg id="rc-detail" role="img" aria-label="Minimum trace, clearance, via pad and drill to scale"></svg><figcaption id="rc-detail-cap"></figcaption></figure>+      <ul class="rc-facts" id="rc-facts"></ul>+    </div>+    <ul class="rc-flags" id="rc-flags"></ul>+    <p class="rc-note" id="rc-note"></p>+  </div>+</section>++<section class="lower" id="lower">+  <div class="card">+    <div class="eyebrow"><svg class="i"><use href="#i-loop"/></svg>Loop backs: where the AI went back to rework</div>+    <ul class="looplist" id="looplist"></ul>+  </div>+</section>++<section class="community" aria-label="Contribute to AI Flow">+  <div><h2>Built together. Better for everyone. <button class="prompt-hint" id="contribute-prompt" title="Help me contribute an improvement to adom-aiflow through a pull request." aria-label="Copy contribution prompt">ⓘ</button></h2>+  <p>AI Flow is MIT open source. Share a fix, a skill, or a lesson from your builds so the whole community benefits.</p></div>+  <nav aria-label="Open source links"><a href="https://wiki.adom.inc/adom/adom-aiflow/files/LICENSE" target="_blank" rel="noopener noreferrer">MIT license</a><a href="https://wiki.adom.inc/adom/adom-aiflow" target="_blank" rel="noopener noreferrer">Source</a><a class="primary" href="https://wiki.adom.inc/adom/adom-aiflow/prs" target="_blank" rel="noopener noreferrer">Contribute a PR</a></nav>+</section>+<div id="copy-feedback" role="status" hidden></div>++<div class="waiting" id="waiting">+  <div>+    <div class="orb"><svg class="i"><use href="#i-flow"/></svg></div>+    <h2>Waiting for the flow to start</h2>+    <p>The timeline appears here as soon as adom-aiflow writes <code>timeline.json</code>. It checks again every second.</p>+  </div>+</div>++<div id="tip" role="tooltip"></div>++<script>+(() => {+  'use strict';+  const $ = id => document.getElementById(id);+  const SVGNS = 'http://www.w3.org/2000/svg';+  const params = new URLSearchParams(location.search);+  // Relative on purpose: the page is served behind a proxy path. Never add a leading slash.+  const SRC = (params.get('src') || 'timeline.json').replace(/^\/+/, '');+  const WIDE_AT = 1700;+  const reduced = matchMedia('(prefers-reduced-motion: reduce)');+  const STATUSES = { todo: 1, current: 1, done: 1, redo: 1 };+  const STATUS_LABEL = { done: 'Done', current: 'Working now', redo: 'Needs rework', todo: 'Not started' };++  const esc = s => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));+  const fmtMin = m => (Math.round((+m || 0) * 10) / 10).toFixed(1);+  const fmtClock = iso => {+    const d = new Date(iso);+    if (isNaN(d)) return '';+    return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false });+  };+  const fmtClockS = iso => {+    const d = new Date(iso);+    if (isNaN(d)) return '';+    return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false });+  };++  const svgEl = (tag, attrs) => {+    const el = document.createElementNS(SVGNS, tag);+    for (const k in attrs) el.setAttribute(k, attrs[k]);+    return el;+  };++  // Design-rule sanity card (presentation.json rulesCard, written by `start`). Read-only.+  const num = v => (typeof v === 'number' && isFinite(v)) ? v : null;+  const niceBar = len => { const c = [0.1, 0.2, 0.5, 1, 2, 5, 10, 20, 50, 100, 200]; return c.reduce((b, x) => x <= len ? x : b, c[0]); };+  function renderRulesCard(card) {+    const sec = $('rules-card');+    if (!card || typeof card !== 'object') { sec.hidden = true; return; }+    sec.hidden = false;+    $('rc-lane').textContent = card.lane ? '· ' + card.lane + ' lane' : '';+    const ink = 'var(--text-2)', cu = '#c9a227', teal = '#00b8b1';+    // board outline at its own scale with a scale bar+    const bsvg = $('rc-board'); bsvg.replaceChildren();+    const b = card.board || {};+    const w = num(b.widthMm), h = num(b.heightMm);+    const d = card.draw || {};+    const t = num(d.traceMm) || 0, c = num(d.clearanceMm) || 0, v = num(d.viaPadMm) || 0, dr = num(d.drillMm) || 0;+    const win = Math.max(3, 2 * t + c + v + 1.2);+    if (w && h) {+      const m = Math.max(w, h) * 0.08;+      bsvg.setAttribute('viewBox', [-m, -m, w + 2 * m, h + 2.6 * m].join(' '));+      const pts = Array.isArray(b.outline) ? b.outline.filter(p => Array.isArray(p) && num(p[0]) != null && num(p[1]) != null) : [];+      if (pts.length > 2) bsvg.appendChild(svgEl('polygon', {points: pts.map(p => p[0] + ',' + p[1]).join(' '), fill: '#16352f', stroke: teal, 'stroke-width': Math.max(w, h) / 250}));+      else bsvg.appendChild(svgEl('rect', {x: 0, y: 0, width: w, height: h, fill: '#16352f', stroke: teal, 'stroke-width': Math.max(w, h) / 250}));+      // the detail window, same scale as the board+      bsvg.appendChild(svgEl('rect', {x: w - win, y: h - win, width: win, height: win, fill: 'none', stroke: '#8c6bf7', 'stroke-width': Math.max(w, h) / 300}));+      const bar = niceBar(w / 3);+      bsvg.appendChild(svgEl('rect', {x: 0, y: h + m, width: bar, height: m * 0.35, fill: ink}));+      const lbl = svgEl('text', {x: bar + m * 0.4, y: h + m * 1.35, fill: ink, 'font-size': m * 0.9, 'font-family': 'JetBrains Mono, monospace'}); lbl.textContent = bar + ' mm'; bsvg.appendChild(lbl);+      $('rc-board-cap').textContent = 'Board ' + w + ' x ' + h + ' mm; the purple square is the detail window (' + win.toFixed(1) + ' mm).';+    } else { $('rc-board-cap').textContent = 'No board outline read.'; }+    // the smallest features, drawn at one scale in the detail window+    const dsvg = $('rc-detail'); dsvg.replaceChildren();+    const pad = win * 0.08;+    dsvg.setAttribute('viewBox', [-pad, -pad, win + 2 * pad, win + 2 * pad].join(' '));+    dsvg.appendChild(svgEl('rect', {x: 0, y: 0, width: win, height: win, fill: '#16352f', stroke: '#8c6bf7', 'stroke-width': win / 200}));+    const x0 = 0.4, len = win * 0.62;+    if (t > 0) {+      dsvg.appendChild(svgEl('rect', {x: x0, y: 0.3, width: t, height: len, fill: cu}));+      dsvg.appendChild(svgEl('rect', {x: x0 + t + c, y: 0.3, width: t, height: len, fill: cu}));+    }+    if (v > 0) {+      const cx = x0 + 2 * t + c + 0.6 + v / 2, cy = 0.3 + v / 2;+      dsvg.appendChild(svgEl('circle', {cx, cy, r: v / 2, fill: cu}));+      if (dr > 0) dsvg.appendChild(svgEl('circle', {cx, cy, r: Math.min(dr, v) / 2, fill: '#0d1117'}));+    }+    const bar = niceBar(win / 3);+    dsvg.appendChild(svgEl('rect', {x: x0, y: win - 0.45, width: bar, height: win / 60, fill: ink}));+    const dl = svgEl('text', {x: x0 + bar + 0.15, y: win - 0.3, fill: ink, 'font-size': win / 16, 'font-family': 'JetBrains Mono, monospace'}); dl.textContent = bar + ' mm'; dsvg.appendChild(dl);+    $('rc-detail-cap').textContent = 'Two min traces ' + t + ' mm at ' + c + ' mm clearance, via pad ' + v + ' mm with ' + dr + ' mm drill, to scale.';+    const items = Array.isArray(card.items) ? card.items : [];+    const loads = Array.isArray(card.loads) ? card.loads : [];+    $('rc-facts').innerHTML = items.map(i => '<li class="' + (i.flag ? 'flag' : '') + '">' + esc(i.label) + ' <b>' + esc(i.value) + ' ' + esc(i.unit) + '</b> <span>' + esc(i.source) + '</span></li>').join('')+      + loads.map(l => '<li class="' + (l.flag ? 'flag' : '') + '">load ' + esc(l.net) + ' <b>' + esc(l.amps) + ' A</b> <span>widest ' + esc(l.widestMm) + ' mm</span></li>').join('');+    const flags = Array.isArray(card.flags) ? card.flags : [];+    $('rc-flags').innerHTML = flags.map(f => '<li>' + esc(f) + '</li>').join('');+    $('rc-note').textContent = String(card.note || 'Read-only: this card changes no gate.') + (card.rangeSource ? ' Range: ' + card.rangeSource + '.' : '');+  }++  let presentation = null;+  let presentationRaw = null;+  let presentationGeneration = 0;+  // Optional artwork updates independently of measured workflow state.+  // Keep the last good view across half-written JSON and failed image loads.+  async function refreshPresentation(incoming) {+    try {+      let raw;+      if (incoming !== undefined) raw = JSON.stringify(incoming);+      else {+        const res = await fetch('presentation.json', {cache: 'no-store', signal: AbortSignal.timeout(3000)});+        if (!res.ok) return;+        raw = await res.text();+      }+      if (raw === presentationRaw) return;+      const p = JSON.parse(raw);+      if (!p || typeof p !== 'object' || Array.isArray(p)) return;+      let url = null;+      if (p.image != null && p.image !== '') {+        if (typeof p.image !== 'string') return;+        url = new URL(p.image, location.href);+        if (url.origin !== location.origin || !/\.(png|jpe?g|webp)$/i.test(url.pathname)) return;+      }+      const generation = ++presentationGeneration;+      if (url) {+        // Reload an overwritten filename when its presentation metadata changes.+        url.searchParams.set('_presentation', Date.now() + '-' + generation);+        const preview = new Image();+        await new Promise((resolve, reject) => {+          const timeout = setTimeout(() => reject(new Error('preview timeout')), 3000);+          preview.onload = () => { clearTimeout(timeout); resolve(); };+          preview.onerror = () => { clearTimeout(timeout); reject(new Error('preview unavailable')); };+          preview.src = url.href;+        });+        if (generation !== presentationGeneration) return;+      }+      presentationRaw = raw;+      presentation = p;+      try { renderRulesCard(p.rulesCard); } catch (_) { /* the card is optional */ }+      const img = $('board-image');+      img.hidden = !url;+      $('preview-empty').hidden = !!url;+      $('board-caption').hidden = !url;+      img.alt = String(p.alt || 'Saved board view');+      $('board-caption').textContent = url ? String(p.caption || 'Saved board view · not a live render') : '';+      if (url) img.src = url.href;+      else img.removeAttribute('src');+      if (model) render(model);+    } catch (_) { /* Preserve the last successfully loaded presentation. */ }+  }+  $('contribute-prompt').addEventListener('click', async () => {+    const prompt = $('contribute-prompt').title;+    const feedback = $('copy-feedback');+    try { await navigator.clipboard.writeText(prompt); feedback.textContent = 'Prompt copied'; }+    catch { feedback.textContent = prompt; }+    feedback.hidden = false;+    setTimeout(() => { feedback.hidden = true; }, 8000);+  });++  // ---------------- state ----------------+  let model = null;          // normalized data+  let lastRaw = '';+  let stepKey = '';           // names joined, decides rebuild vs in-place update+  const rows = new Map();     // name -> {li, ...}+  let finishLi = null;+  const phaseEls = new Map();+  const seenLoops = new Set();+  let firstRender = true;+  let lastOkAt = 0, everOk = false;+  let lastCurrent = null;++  // ---------------- normalize ----------------+  function normalize(d) {+    if (!d || typeof d !== 'object' || !Array.isArray(d.steps) || !d.steps.length) return null;+    const steps = [];+    const seen = new Set();+    d.steps.forEach((s, i) => {+      if (!s || typeof s !== 'object') return;+      let name = String(s.name || s.title || ('step-' + i));+      while (seen.has(name)) name += '_';+      seen.add(name);+      steps.push({+        name,+        title: String(s.title || s.name || 'Step ' + (i + 1)),+        does: String(s.does || ''),+        phase: String(s.phase || ''),+        status: STATUSES[s.status] ? s.status : 'todo',+        visits: Math.max(0, parseInt(s.visits, 10) || 0),+        minutes: Math.max(0, +s.minutes || 0),+      });+    });+    if (!steps.length) return null;+    let current = d.current && seen.has(String(d.current)) ? String(d.current) : null;+    if (!current) {+      const c = steps.find(s => s.status === 'current');+      if (c) current = c.name;+    }+    steps.forEach(s => {+      if (s.name === current) s.status = 'current';+      else if (s.status === 'current') s.status = 'todo';+    });+    const idx = Object.fromEntries(steps.map((s, i) => [s.name, i]));+    const loops = (Array.isArray(d.loops) ? d.loops : [])+      .filter(l => l && idx[l.from] != null && idx[l.to] != null && idx[l.from] !== idx[l.to])+      .map((l, i) => ({ from: String(l.from), to: String(l.to), why: String(l.why || ''), at: l.at || '', n: i + 1 }));+    loops.forEach(l => { l.key = l.from + '>' + l.to + '@' + l.at + '#' + l.n; });+    return {+      title: String(d.title || d.label || d.runFolder || 'AI Flow run'),+      board: String(d.board || ''),+      aiThread: String(d.aiThread || ''),+      runFolder: String(d.runFolder || ''),+      runDir: String(d.runDir || ''),+      engine: String(d.engine || ''),+      finish: String(d.finish || 'Finished'),+      updated: d.updated || '',+      current, steps, loops, idx,+    };+  }++  // ---------------- build ----------------+  const ICON_CK = '<svg class="ck" viewBox="0 0 24 24"><path d="M5.5 12.5 10 17l8.5-9.5"/></svg>';+  const ICON_RD = '<svg class="rd" viewBox="0 0 24 24"><path d="M4 12a8 8 0 1 0 2.4-5.7"/><path d="M4 4v4.5h4.5"/></svg>';+  const ICON_CU = '<svg class="cu" viewBox="0 0 24 24"><circle cx="12" cy="12" r="11"/></svg>';+  const ICON_FL = '<svg class="fl" viewBox="0 0 24 24"><path d="M5 21V4"/><path d="M5 4.5c4.5-2 7.5 2 12 0v9c-4.5 2-7.5-2-12 0"/><path d="M9 4.2v9M13 5.4v9"/></svg>';+  const ICON_BADGE = '<svg viewBox="0 0 24 24"><path d="M4 12a8 8 0 1 0 2.4-5.7"/><path d="M4 4v4.5h4.5"/></svg>';++  function build(m) {+    const ol = $('steps');+    ol.textContent = '';+    rows.clear();+    m.steps.forEach((s, i) => {+      const li = document.createElement('li');+      li.className = 'step todo';+      li.dataset.name = s.name;+      li.dataset.tipkey = 'step:' + s.name;+      li.innerHTML =+        '<div class="rowbg"></div>' ++        '<div class="node">' + ICON_CK + ICON_RD + ICON_CU + '</div>' ++        '<div class="label"><div class="t"><span class="n">' + (i + 1) + '</span><span class="nm"></span><span class="badge off">' + ICON_BADGE + '<span class="v"></span></span></div><div class="does"></div></div>' ++        '<div class="meta"></div>';+      ol.appendChild(li);+      rows.set(s.name, {+        li, node: li.querySelector('.node'), nm: li.querySelector('.nm'), badge: li.querySelector('.badge'),+        v: li.querySelector('.badge .v'), does: li.querySelector('.does'), meta: li.querySelector('.meta'), status: null,+      });+    });+    finishLi = document.createElement('li');+    finishLi.className = 'step finish';+    finishLi.dataset.tipkey = 'finish';+    finishLi.innerHTML = '<div class="rowbg"></div><div class="node">' + ICON_FL + '</div><div class="label"><div class="t"><span class="n"></span><span class="nm"><span class="k">Finish line: </span><span class="fx"></span></span></div></div><div class="meta"></div>';+    ol.appendChild(finishLi);++    const ph = $('phases');+    ph.textContent = '';+    phaseEls.clear();+    groupsOf(m).forEach(g => {+      const el = document.createElement('div');+      el.className = 'phase';+      el.dataset.tipkey = 'phase:' + g.key;+      el.innerHTML = '<div class="bar"></div><div class="nm"></div><div class="ct"></div>';+      el.querySelector('.nm').textContent = g.phase || 'Steps';+      ph.appendChild(el);+      phaseEls.set(g.key, el);+    });+  }++  function groupsOf(m) {+    const out = [];+    m.steps.forEach((s, i) => {+      const last = out[out.length - 1];+      if (last && last.phase === s.phase) last.end = i;+      else out.push({ phase: s.phase, start: i, end: i, key: s.phase + '#' + i });+    });+    return out;+  }++  // ---------------- render ----------------+  function render(m) {+    const key = m.steps.map(s => s.name + '|' + s.phase).join(',');+    if (key !== stepKey) { build(m); stepKey = key; firstRender = true; seenLoops.clear(); }++    const total = m.steps.length;+    const done = m.steps.filter(s => s.status === 'done').length;+    const redo = m.steps.filter(s => s.status === 'redo').length;+    const allDone = done === total;+    const curIdx = m.current ? m.idx[m.current] : -1;+    const posIdx = curIdx >= 0 ? curIdx : (allDone ? total - 1 : Math.max(0, m.steps.findIndex(s => s.status !== 'done')));+    const toGo = m.steps.filter(s => s.status === 'todo' || s.status === 'redo').length;+    const minutes = m.steps.reduce((a, s) => a + s.minutes, 0);++    const active = m.steps[posIdx];+    const early = ['intake','rules','design','test-plan','sourcing','components','libraries','library','schematic','simulation','models'];+    const context = {+      intake: ['Start with the goal', 'Define what to build, the constraints, and what success looks like.'],+      rules: ['Set the boundaries', 'Manufacturing limits and project requirements guide the design.'],+      design: ['Shape the circuit', 'Circuit decisions and calculations come before board layout.'],+      sourcing: ['Choose the right parts', 'Compare availability, cost, and component evidence.'],+      schematic: ['Connect the design', 'The schematic captures how the circuit works.'],+      simulation: ['Test the idea', 'Review simulation evidence against the requirements.']+    };+    const copy = context[active.name] || [early.includes(active.name) ? 'Build the design foundation' : 'Follow the engineering evidence', active.does || 'Recorded work and checks determine the next step.'];+    $('project-phase').textContent = presentation?.title || copy[0];+    $('project-context').textContent = presentation?.summary || copy[1];+    // header+    document.title = (presentation?.projectTitle || m.title) + ' \u00b7 AI Flow';+    $('title').textContent = presentation?.projectTitle || m.title;+    $('title').title = presentation?.projectTitle || m.title;+    runline(m);+    $('engine').style.display = m.engine ? '' : 'none';+    $('engine').querySelector('span').textContent = m.engine;+    $('finish').textContent = m.finish;+    setText('s-step', allDone && curIdx < 0 ? total : posIdx + 1);+    document.querySelector('.stat.stepno').classList.toggle('won', allDone && curIdx < 0);+    setText('s-total', total);+    setText('s-togo', toGo);+    setText('s-min', fmtMin(minutes));+    setText('s-loops', m.loops.length);+    $('s-loops-l').textContent = m.loops.length === 1 ? 'loop back' : 'loop backs';+    $('s-loops-w').classList.toggle('none', !m.loops.length);+    const C = 2 * Math.PI * 36;+    const fDone = done / total, fRedo = redo / total;+    $('ring-done').style.strokeDasharray = (C * fDone) + ' ' + C;+    $('ring-redo').style.strokeDasharray = (C * fRedo) + ' ' + C;+    $('ring-redo').style.strokeDashoffset = -(C * fDone);+    $('pct').firstChild.nodeValue = Math.round(fDone * 100);+    $('when').textContent = fmtClockS(m.updated);++    $('recent-work').innerHTML = m.steps.filter(s => s.status === 'done').slice(-3).map(s => '<li>' + esc(s.title) + '</li>').join('') || '<li style="color:var(--text-2);background:transparent">No completed steps recorded yet</li>';+    // rows+    m.steps.forEach((s, i) => {+      const r = rows.get(s.name);+      const prev = r.status;+      const cls = s.status === 'current' ? 'cur' : s.status;+      if (prev !== s.status) {+        r.li.className = 'step ' + cls;+        if (!firstRender && s.status === 'done' && prev && prev !== 'done') flash(r.li, 'just-done', 1000);+        if (!firstRender && s.status === 'redo' && prev && prev !== 'redo') flash(r.li, 'just-redo', 1100);+        r.status = s.status;+      }+      if (r.nm.textContent !== s.title) r.nm.textContent = s.title;+      if (r.does.textContent !== s.does) r.does.textContent = s.does;+      const showBadge = s.visits > 1;+      r.badge.classList.toggle('off', !showBadge);+      r.v.textContent = 'x' + s.visits;+      r.badge.setAttribute('aria-label', 'Visited ' + s.visits + ' times');+      let meta = '';+      if (s.status === 'redo') meta = '<span class="redo-tag">rework</span>';+      else if (s.minutes > 0) meta = fmtMin(s.minutes) + ' min';+      if (r.meta._v !== meta) { r.meta.innerHTML = meta; r.meta._v = meta; }+    });+    finishLi.querySelector('.fx').textContent = m.finish;+    finishLi.classList.toggle('won', allDone);++    // phases+    groupsOf(m).forEach(g => {+      const el = phaseEls.get(g.key);+      if (!el) return;+      const ss = m.steps.slice(g.start, g.end + 1);+      const nd = ss.filter(s => s.status === 'done').length;+      el.classList.toggle('active', ss.some(s => s.status === 'current'));+      el.classList.toggle('complete', nd === ss.length);+      el.classList.toggle('rework', !ss.some(s => s.status === 'current') && ss.some(s => s.status === 'redo'));+      el.querySelector('.ct').textContent = nd + ' of ' + ss.length;+    });++    // latest loop callout (narrow) and loop list (wide)+    const latest = m.loops[m.loops.length - 1];+    const wrap = $('callout-wrap');+    if (latest) {+      const fresh = !seenLoops.has(latest.key) && !firstRender;+      $('co-n').textContent = 'Loop back ' + latest.n + ':';+      $('co-path').textContent = titleOf(m, latest.from) + ' sent the AI back to ' + titleOf(m, latest.to);+      $('co-t').textContent = fmtClock(latest.at);+      $('co-why').textContent = latest.why;+      wrap.classList.add('on');+      if (fresh) flash($('callout'), 'fresh', 1500);+    } else wrap.classList.remove('on');++    const ll = $('looplist');+    ll.innerHTML = m.loops.length ? m.loops.slice().reverse().slice(0, 4).map(l =>+      '<li class="' + (l === latest ? 'latest' : '') + '" data-tipkey="loop:' + esc(l.key) + '" data-loop="' + esc(l.key) + '">' ++      '<span class="num">' + l.n + '</span>' ++      '<span class="path">' + esc(titleOf(m, l.from)) + ' <svg class="i"><use href="#i-back"/></svg> back to ' + esc(titleOf(m, l.to)) + '</span>' ++      '<span class="tm">' + esc(fmtClock(l.at)) + '</span>' ++      '<span class="why">' + esc(l.why) + '</span></li>').join('')+      : '<li><span class="empty">No loop backs yet. When a later step finds a problem, the AI goes back to an earlier step and it shows up here.</span></li>';++    // now card (wide)+    const cs = m.steps[posIdx];+    $('now-n').textContent = allDone && curIdx < 0 ? '' : String(posIdx + 1).padStart(2, '0');+    $('now-title').textContent = allDone && curIdx < 0 ? 'Finished: ' + m.finish : cs.title;+    $('now-does').textContent = allDone && curIdx < 0 ? 'Every step is done.' : cs.does;+    $('now-facts').innerHTML = allDone && curIdx < 0 ? '' :+      '<span>Phase <b>' + esc(cs.phase || '-') + '</b></span>' ++      '<span>Visit <b>' + Math.max(1, cs.visits) + '</b></span>' ++      '<span>Time so far <b>' + fmtMin(cs.minutes) + ' min</b></span>';++    if (m.current !== lastCurrent) {+      const changed = lastCurrent !== null || !firstRender;+      lastCurrent = m.current;+      if (changed && !firstRender) setTimeout(() => scrollCurrentIntoView(true), 650);+    }++    if (firstRender) document.body.classList.add('notrans');+    layout();+    if (firstRender) { drawOverlay(); requestAnimationFrame(() => requestAnimationFrame(() => document.body.classList.remove('notrans'))); }+    animateOverlay(firstRender ? 400 : 1100);+    m.loops.forEach(l => seenLoops.add(l.key));+    firstRender = false;+  }++  // the run line under the title: board file, lane, AI thread, run folder. It never wraps: the+  // thread ellipsizes, and when the line is still too wide the run folder, then the lane, then the+  // board are dropped (the AI thread always stays).+  function runline(m) {+    const items = [['rl-board', m.board ? 'Board ' + m.board : ''], ['rl-lane', m.engine], ['rl-thread', m.aiThread ? 'AI thread: ' + m.aiThread : ''], ['rl-folder', m.runFolder ? 'Run ' + m.runFolder : '']];+    items.forEach(([id, text]) => { const el = $(id); if (el.textContent !== text) el.textContent = text; el.hidden = !text; });+    $('runline').dataset.detail = [m.board, m.engine, m.aiThread && 'AI thread ' + m.aiThread, m.runDir].filter(Boolean).join(' \u00b7 ');+    fitRunline();+  }+  function fitRunline() {+    const line = $('runline');+    ['rl-board', 'rl-lane', 'rl-folder'].forEach(id => { $(id).hidden = !$(id).textContent; });+    const firstShown = () => { let f = true; line.querySelectorAll('span').forEach(el => { el.classList.toggle('first', f && !el.hidden); if (!el.hidden) f = false; }); };+    firstShown();+    for (const id of ['rl-folder', 'rl-lane', 'rl-board']) {+      if (line.scrollWidth <= line.clientWidth + 1) break;+      $(id).hidden = true;+      firstShown();+    }+  }+  window.addEventListener('resize', fitRunline);+  function titleOf(m, name) { const i = m.idx[name]; return i == null ? name : m.steps[i].title; }+  function setText(id, v) { const el = $(id); const s = String(v); if (el.textContent !== s) el.textContent = s; }+  function flash(el, cls, ms) { el.classList.remove(cls); void el.offsetWidth; el.classList.add(cls); setTimeout(() => el.classList.remove(cls), ms); }++  // ---------------- layout ----------------+  let wide = false, laneH = 42, laneW = 26;+  function lanesFor(m) {+    // interval colouring: shortest loops hug the spine, longer ones go outside them+    const items = m.loops.map(l => {+      const a = m.idx[l.to], b = m.idx[l.from];+      return { l, lo: Math.min(a, b), hi: Math.max(a, b) };+    }).sort((x, y) => (x.hi - x.lo) - (y.hi - y.lo) || x.lo - y.lo);+    const placed = [];+    items.forEach(it => {+      let lane = 0;+      const taken = new Set(placed.filter(p => !(p.hi < it.lo || p.lo > it.hi)).map(p => p.lane));+      while (taken.has(lane)) lane++;+      it.lane = lane;+      placed.push(it);+    });+    const map = new Map(placed.map(p => [p.l.key, p.lane]));+    const count = placed.length ? Math.max(...placed.map(p => p.lane)) + 1 : 0;+    return { map, count };+  }++  function layout() {+    if (!model) return;+    wide = innerWidth > WIDE_AT;+    document.body.classList.toggle('wide', wide);+    const lanes = lanesFor(model);+    const root = document.documentElement.style;+    if (wide) {+      const phaseBand = 40;+      const nl = Math.max(2, lanes.count);+      const setZone = lh => { laneH = lh; root.setProperty('--htop', (phaseBand + 40 + nl * lh) + 'px'); };+      setZone(42);+      // spend spare height on taller loop arcs so the page is balanced on a big screen+      const spare = innerHeight - ($('lower').getBoundingClientRect().bottom + scrollY) - 28;+      if (spare > 0) setZone(Math.min(84, 42 + spare / nl));+      const arcZone = 40 + nl * laneH;+      root.setProperty('--arcZone', arcZone + 'px');+      $('steps').style.gridTemplateColumns = model.steps.map(s => s.name === model.current ? '1.5fr' : '1fr').join(' ') + ' 1.3fr';+    } else {+      $('steps').style.gridTemplateColumns = '';+      // fixed gutter so a new loop never shifts the list sideways; lanes tighten instead+      laneW = Math.min(26, 66 / Math.max(1, lanes.count));+      root.setProperty('--gut', '96px');+      fitRows();+    }+  }++  function fitRows() {+    // make every step fit the pane height when possible, without going below a readable row+    const stage = $('stage');+    const top = stage.getBoundingClientRect().top + scrollY;+    const n = model.steps.length + 1;+    const curH = innerWidth <= 620 ? 96 : 86;+    const avail = innerHeight - top - 14;+    let row = Math.floor((avail - curH) / (n - 1));+    row = Math.max(26, Math.min(44, row));+    const rs = document.documentElement.style;+    if (rs.getPropertyValue('--row') !== row + 'px' || rs.getPropertyValue('--row-cur') !== curH + 'px') {+      rs.setProperty('--row', row + 'px');+      rs.setProperty('--row-cur', curH + 'px');+      animUntil = Math.max(animUntil, performance.now() + 750);+    }+  }++  // positions of every node centre, relative to the stage box+  function measure() {+    const st = $('stage').getBoundingClientRect();+    const pts = new Map();+    rows.forEach((r, name) => {+      const b = r.node.getBoundingClientRect();+      pts.set(name, { x: b.left + b.width / 2 - st.left, y: b.top + b.height / 2 - st.top, r: b.width / 2, li: r.li.getBoundingClientRect() });+    });+    const fb = finishLi.querySelector('.node').getBoundingClientRect();+    pts.set('__finish', { x: fb.left + fb.width / 2 - st.left, y: fb.top + fb.height / 2 - st.top, r: fb.width / 2, li: finishLi.getBoundingClientRect() });+    return { st, pts };+  }++  let animUntil = 0, rafOn = false;+  function animateOverlay(ms) {+    animUntil = Math.max(animUntil, performance.now() + ms);+    if (!rafOn) { rafOn = true; requestAnimationFrame(tick); }+  }+  function tick() {+    if (model && !wide) fitRows();+    drawOverlay();+    if (performance.now() < animUntil) requestAnimationFrame(tick);+    else rafOn = false;+  }++  function drawOverlay() {+    if (!model) return;+    const { st, pts } = measure();+    const m = model;+    const first = pts.get(m.steps[0].name), fin = pts.get('__finish');+    const spine = $('spine'), fill = $('spine-fill');+    const curName = m.current || (m.steps.every(s => s.status === 'done') ? '__finish' : null);+    const cur = curName ? pts.get(curName) : null;+    // progress reaches the last contiguous done step, or the current step+    let reachName = curName;+    if (!reachName) {+      let k = -1; m.steps.forEach((s, i) => { if (s.status === 'done' && k === i - 1) k = i; });+      reachName = k >= 0 ? m.steps[k].name : m.steps[0].name;+    }+    const reach = pts.get(reachName);++    if (wide) {+      setBox(spine, { left: first.x, top: first.y - 1.5, width: fin.x - first.x, height: 3 });+      setBox(fill, { left: first.x, top: first.y - 1.5, width: Math.max(0, reach.x - first.x), height: 3 });+      fill.style.background = 'linear-gradient(90deg, rgba(0, 184, 177,.85), var(--accent))';+    } else {+      setBox(spine, { left: first.x - 1.5, top: first.y, width: 3, height: fin.y - first.y });+      setBox(fill, { left: first.x - 1.5, top: first.y, width: 3, height: Math.max(0, reach.y - first.y) });+      fill.style.background = '';+    }++    const halo = $('halo');+    if (cur && curName !== '__finish') {+      halo.classList.remove('hide');+      const t = 'translate(' + cur.x.toFixed(1) + 'px,' + cur.y.toFixed(1) + 'px)';+      if (halo._t !== t) { halo.style.transform = t; halo._t = t; }+    } else halo.classList.add('hide');++    drawPhases(pts, st);+    drawArcs(pts);+  }++  function setBox(el, b) {+    for (const k in b) {+      const v = b[k].toFixed(1) + 'px';+      if (el.style[k] !== v) el.style[k] = v;+    }+  }++  function drawPhases(pts, st) {+    groupsOf(model).forEach(g => {+      const el = phaseEls.get(g.key);+      if (!el) return;+      const a = pts.get(model.steps[g.start].name), b = pts.get(model.steps[g.end].name);+      const bar = el.querySelector('.bar'), nm = el.querySelector('.nm'), ct = el.querySelector('.ct');+      if (wide) {+        const half = (a.li.width) / 2;+        const left = a.x - half + 10, right = b.x + half - 10;+        setBox(el, { left, top: 0, width: right - left, height: 30 });+        Object.assign(bar.style, { left: '0px', right: '0px', top: '24px', height: '3px', width: '', bottom: '' });+        Object.assign(nm.style, { left: '2px', top: '0px', right: '', fontSize: '' });+        Object.assign(ct.style, { right: '2px', top: '2px', left: '' });+        ct.style.display = '';+        ct.style.display = (nm.offsetWidth + ct.offsetWidth + 18) < (right - left) ? '' : 'none';+      } else {+        const top = a.li.top - st.top + 5, bottom = b.li.bottom - st.top - 5;+        const left = a.li.right - st.left - parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--phase-col')) + 8;+        setBox(el, { left, top, width: 100, height: Math.max(10, bottom - top) });+        Object.assign(bar.style, { left: '0px', top: '0px', bottom: '0px', width: '3px', height: '', right: '' });+        Object.assign(nm.style, { left: '13px', top: '-2px', right: '' });+        Object.assign(ct.style, { left: '13px', top: '17px', right: '' });+        ct.style.display = (bottom - top) > 44 ? '' : 'none';+      }+    });+  }++  function placePill(l, bx, by, isNew) {+    const pill = $('arcpill');+    if (!wide) return;+    if (pill._k !== l.key) {+      pill._k = l.key;+      pill.dataset.tipkey = 'loop:' + l.key;+      pill.querySelector('.num').textContent = l.n;+      pill.querySelector('.tx').innerHTML = '<b>' + esc(titleOf(model, l.from)) + ' sent the AI back to ' + esc(titleOf(model, l.to)) + ':</b> ' + esc(l.why);+      if (isNew && !reduced.matches) flash(pill, 'new', 1700);+    }+    const W = $('stage').clientWidth, w = pill.offsetWidth, h = pill.offsetHeight;+    const x = Math.max(8, Math.min(W - w - 8, bx - w / 2));+    setBox(pill, { left: x, top: by - h / 2 });+  }++  function drawArcs(pts) {+    const svg = $('overlay');+    $('arcpill').classList.toggle('on', wide && model.loops.length > 0);+    const m = model;+    const lanes = lanesFor(m);+    const latest = m.loops[m.loops.length - 1];+    const want = new Set(m.loops.map(l => l.key));+    svg.querySelectorAll('g.arcgrp').forEach(g => { if (!want.has(g.dataset.key)) g.remove(); });+    m.loops.forEach(l => {+      let g = svg.querySelector('g.arcgrp[data-key="' + CSS.escape(l.key) + '"]');+      const isNew = !g && !firstRender && !seenLoops.has(l.key);+      if (!g) {+        g = svgEl('g', { class: 'arcgrp', 'data-key': l.key, 'data-tipkey': 'loop:' + l.key });+        g.appendChild(svgEl('path', { class: 'arc' + (isNew && !reduced.matches ? ' new' : ''), pathLength: '1' }));+        g.appendChild(svgEl('path', { class: 'arrow' + (isNew && !reduced.matches ? ' new' : '') }));+        g.appendChild(svgEl('path', { class: 'arc-hit', 'data-tipkey': 'loop:' + l.key }));+        const b = svgEl('g', { class: 'loopbadge' + (isNew && !reduced.matches ? ' new' : ''), 'data-tipkey': 'loop:' + l.key });+        b.appendChild(svgEl('circle', { r: '10' }));+        const t = svgEl('text', {}); t.textContent = l.n; b.appendChild(t);+        g.appendChild(b);+        svg.appendChild(g);+        if (isNew) setTimeout(() => g.querySelectorAll('.new').forEach(e => e.classList.remove('new')), 1700);+      }+      const isLatest = l === latest;+      g.querySelector('.arc').classList.toggle('latest', isLatest);+      g.querySelector('.arrow').classList.toggle('latest', isLatest);+      g.querySelector('.loopbadge').classList.toggle('latest', isLatest);+      if (isLatest) svg.appendChild(g); // latest on top++      const A = pts.get(l.from), B = pts.get(l.to); // A later step, B earlier step it sent the AI to+      const lane = lanes.map.get(l.key) || 0;+      let d, arrow, bx, by;+      if (wide) {+        const y0 = A.y - A.r - 4, y1 = B.y - B.r - 9;+        const peak = 26 + (lane + 1) * laneH;+        const k = peak / 0.75;+        d = 'M' + A.x + ' ' + y0 + ' C' + A.x + ' ' + (y0 - k) + ' ' + B.x + ' ' + (y1 - k) + ' ' + B.x + ' ' + y1;+        arrow = 'M' + (B.x - 6) + ' ' + (y1 - 8) + ' L' + B.x + ' ' + (y1 + 1) + ' L' + (B.x + 6) + ' ' + (y1 - 8) + 'Z';+        bx = (A.x + B.x) / 2; by = (y0 + y1) / 2 - peak;+      } else {+        const x0 = A.x - A.r - 5, x1 = B.x - B.r - 10;+        const depth = 20 + (lane + 1) * laneW;+        const k = depth / 0.75;+        d = 'M' + x0 + ' ' + A.y + ' C' + (x0 - k) + ' ' + A.y + ' ' + (x1 - k) + ' ' + B.y + ' ' + x1 + ' ' + B.y;+        arrow = 'M' + (x1 - 8) + ' ' + (B.y - 6) + ' L' + (x1 + 1) + ' ' + B.y + ' L' + (x1 - 8) + ' ' + (B.y + 6) + 'Z';+        bx = (x0 + x1) / 2 - depth; by = (A.y + B.y) / 2;+      }+      const arc = g.querySelector('.arc');+      if (arc.getAttribute('d') !== d) { arc.setAttribute('d', d); g.querySelector('.arc-hit').setAttribute('d', d); g.querySelector('.arrow').setAttribute('d', arrow); }+      g.querySelector('.loopbadge').setAttribute('transform', 'translate(' + bx.toFixed(1) + ' ' + by.toFixed(1) + ')');+      if (isLatest) placePill(l, bx, by, isNew);+    });+  }++  function scrollCurrentIntoView(smooth) {+    if (!model || wide) return;+    const name = model.current;+    if (!name || !rows.has(name)) return;+    const r = rows.get(name).li.getBoundingClientRect();+    if (r.top < 0 || r.bottom > innerHeight) {+      window.scrollTo({ top: Math.max(0, scrollY + r.top - innerHeight / 2 + r.height / 2), behavior: smooth && !reduced.matches ? 'smooth' : 'auto' });+    }+  }++  // ---------------- tooltips ----------------+  const tip = $('tip');+  let tipTimer = 0, tipFor = null, cx = 0, cy = 0;+  function tipHtml(key) {+    const m = model;+    if (!m) return null;+    if (key.startsWith('step:')) {+      const s = m.steps[m.idx[key.slice(5)]];+      if (!s) return null;+      const i = m.idx[s.name];+      const sentBack = m.loops.filter(l => l.to === s.name);+      const sentFrom = m.loops.filter(l => l.from === s.name);+      let h = '<div class="tt">' + (i + 1) + '. ' + esc(s.title) + '<span class="s ' + s.status + '">' + STATUS_LABEL[s.status] + '</span></div>';+      if (s.does) h += '<div>' + esc(s.does) + '</div>';+      sentFrom.forEach(l => { h += '<div style="margin-top:6px;color:var(--purple)">Sent the AI back to ' + esc(titleOf(m, l.to)) + ': <span style="color:var(--text-body)">' + esc(l.why) + '</span></div>'; });+      sentBack.forEach(l => { h += '<div style="margin-top:6px;color:var(--purple)">Reworked after ' + esc(titleOf(m, l.from)) + ': <span style="color:var(--text-body)">' + esc(l.why) + '</span></div>'; });+      h += '<div class="ft">' + esc(s.phase || 'No phase') + ' · visited ' + s.visits + (s.visits === 1 ? ' time' : ' times') + ' · ' + fmtMin(s.minutes) + ' AI min</div>';+      return { h };+    }+    if (key.startsWith('loop:')) {+      const l = m.loops.find(x => x.key === key.slice(5));+      if (!l) return null;+      return { loopy: true, h: '<div class="tt">Loop back ' + l.n + '<span class="s redo">' + esc(fmtClock(l.at)) + '</span></div>' ++        '<div><b style="color:var(--text)">' + esc(titleOf(m, l.from)) + '</b> found a problem and sent the AI back to <b style="color:var(--text)">' + esc(titleOf(m, l.to)) + '</b>.</div>' ++        '<div style="margin-top:6px">' + esc(l.why) + '</div>' ++        '<div class="ft">Every step from ' + esc(titleOf(m, l.to)) + ' on gets redone.</div>' };+    }+    if (key.startsWith('phase:')) {+      const g = groupsOf(m).find(x => x.key === key.slice(6));+      if (!g) return null;+      const ss = m.steps.slice(g.start, g.end + 1);+      return { h: '<div class="tt">' + esc(g.phase || 'Steps') + ' phase</div><div>' + ss.map(s => esc(s.title)).join(', ') + '</div><div class="ft">' + ss.filter(s => s.status === 'done').length + ' of ' + ss.length + ' done</div>' };+    }+    const fixed = {+      brand: '<div class="tt">AI Flow Timeline</div><div>Live map of this AI Flow run: every step, where the AI is now, and every time it looped back to rework.</div><div class="ft">Engine ' + esc(m.engine || 'unknown') + ' \u00b7 reads ' + esc(SRC) + ' every 1 s</div>',+      runline: '<div class="tt">This run</div><div>' + esc($('runline').dataset.detail || 'No board attached yet') + '</div><div class="ft">The AI thread is the conversation that last drove this run.</div>',+      engine: '<div class="tt">CAD engine</div><div>The design tool the AI is driving for this board.</div>',+      live: '<div class="tt">Live</div><div>This page reads timeline.json every second. The time is the last update the flow wrote.</div>',+      finish: '<div class="tt">Finish line</div><div>The flow is done when this is true: ' + esc(m.finish) + '.</div>',+      stepno: '<div class="tt">Where we are</div><div>The step the AI is working on, counted along the timeline.</div>',+      togo: '<div class="tt">Steps to go</div><div>Steps not done yet, including steps a loop back sent us to redo.</div>',+      minutes: '<div class="tt">AI minutes</div><div>Total time the AI has spent across every step and every visit.</div>',+      loops: '<div class="tt">Loop backs</div><div>Times a later step found a problem and sent the AI back to rework an earlier step. This is normal: it is how the flow converges on a good board.</div>',+      ring: '<div class="tt">Progress</div><div>Share of steps done. The purple part is work a loop back reopened.</div>',+      callout: '<div class="tt">Latest loop back</div><div>The most recent time the AI went back to rework an earlier step, and why.</div>',+    };+    return fixed[key] ? { h: fixed[key], loopy: key === 'callout' || key === 'loops' } : null;+  }+  function placeTip() {+    const W = document.documentElement.clientWidth, H = document.documentElement.clientHeight, M = 8, O = 16;+    const w = tip.offsetWidth, h = tip.offsetHeight;+    let x = cx + O, y = cy + O;+    if (x + w > W - M) x = cx - w - O;   // flip left+    if (y + h > H - M) y = cy - h - O;   // flip above+    x = Math.max(M, Math.min(x, W - w - M));+    y = Math.max(M, Math.min(y, H - h - M));+    tip.style.transform = 'translate(' + Math.round(x) + 'px,' + Math.round(y) + 'px)';+  }+  function showTip(key) {+    const t = tipHtml(key);+    if (!t) return hideTip();+    tip.innerHTML = t.h;+    tip.classList.toggle('loopy', !!t.loopy);+    tip.classList.add('on');+    placeTip();+    requestAnimationFrame(() => tip.classList.add('shown'));+  }+  function hideTip() { clearTimeout(tipTimer); tipFor = null; tip.classList.remove('on', 'shown'); highlightLoop(null); }+  function highlightLoop(k) {+    document.querySelectorAll('g.arcgrp').forEach(g => {+      g.classList.toggle('hl', !!k && g.dataset.key === k);+      g.classList.toggle('dim', !!k && g.dataset.key !== k);+    });+  }+  function keyFrom(target) {+    const el = target && target.closest ? target.closest('[data-tipkey]') : null;+    return el ? el.getAttribute('data-tipkey') : null;+  }+  function loopKeyFor(k) {+    if (!k || !model) return null;+    if (k.startsWith('loop:')) return k.slice(5);+    if (k === 'callout' && model.loops.length) return model.loops[model.loops.length - 1].key;+    return null;+  }+  document.addEventListener('pointermove', e => {+    if (e.pointerType === 'touch') return;+    cx = e.clientX; cy = e.clientY;+    const k = keyFrom(e.target);+    if (k !== tipFor) {+      hideTip();+      tipFor = k;+      if (k) {+        highlightLoop(loopKeyFor(k));+        tipTimer = setTimeout(() => showTip(k), 600);+      }+    } else if (tip.classList.contains('on')) placeTip();+  }, { passive: true });+  document.addEventListener('pointerdown', e => {+    if (e.pointerType !== 'touch' && e.pointerType !== 'pen') return;+    cx = e.clientX; cy = e.clientY;+    const k = keyFrom(e.target);+    if (k && k !== tipFor) { hideTip(); tipFor = k; highlightLoop(loopKeyFor(k)); showTip(k); }+    else hideTip();+  });+  document.addEventListener('pointerleave', hideTip);+  addEventListener('scroll', () => { if (tipFor) hideTip(); }, { passive: true });++  // One live page per run. CLI writes are delivered without navigation or reload.+  let liveChannel = 'connecting';+  let livePresentation = null;+  function setLive(state) {+    const el = $('live');+    el.classList.toggle('stale', state === 'stale');+    el.classList.toggle('idle', state === 'idle');+    el.querySelector('.lbl').textContent = state === 'stale' ? 'Reconnecting' : (state === 'idle' ? 'Waiting' : 'Live');+  }+  function receiveTimeline(data) {+    const raw = JSON.stringify(data);+    const m = normalize(data);+    if (!m) return;+    lastOkAt = Date.now();+    if (raw !== lastRaw) {+      lastRaw = raw; model = m; everOk = true;+      document.body.classList.remove('waiting-on'); render(m);+    }+    setLive('live');+  }+  async function poll() {+    // Compatibility only for static exports/older servers; SSE owns normal updates.+    if (liveChannel !== 'sse') {+      try {+        const sep = SRC.includes('?') ? '&' : '?';+        const res = await fetch(SRC + sep + '_=' + Date.now(), {cache:'no-store',signal:AbortSignal.timeout(3000)});+        if (!res.ok) throw new Error('HTTP ' + res.status);+        const snapshot = await res.json();+        if (liveChannel !== 'sse') {+          receiveTimeline(snapshot);+          await refreshPresentation();+        }+      } catch (_) { setLive(everOk ? 'stale' : 'idle'); }+    }+    if (liveChannel === 'sse' && livePresentation) await refreshPresentation(livePresentation);+    setTimeout(poll, 3000);+  }+  if (typeof EventSource !== 'undefined' && SRC === 'timeline.json') {+    const channel = new EventSource('events');+    channel.addEventListener('state', event => {+      try {+        const state = JSON.parse(event.data);+        receiveTimeline(state.timeline);+        if (state.presentation) { livePresentation = state.presentation; refreshPresentation(livePresentation); }+        liveChannel = 'sse';+      } catch (_) { setLive('stale'); }+    });+    channel.onerror = () => { liveChannel = 'reconnecting'; setLive('stale'); };+    addEventListener('pagehide', () => channel.close(), {once:true});+  }++  let rz = 0;+  addEventListener('resize', () => {+    cancelAnimationFrame(rz);+    rz = requestAnimationFrame(() => { layout(); animateOverlay(700); });+  });+  if (document.fonts && document.fonts.ready) document.fonts.ready.then(() => { if (model) { layout(); animateOverlay(300); } });++  // test and driving hook: window.flowTimeline.state() returns what is on screen+  window.flowTimeline = {+    state: () => model && ({ current: model.current, liveChannel, wide, steps: model.steps.length, loops: model.loops.length }),+    tipCheck: () => { const r = tip.getBoundingClientRect(); return { on: tip.classList.contains('on'), left: r.left, top: r.top, right: r.right, bottom: r.bottom, vw: innerWidth, vh: innerHeight }; },+    showTip: (key, x, y) => { cx = x; cy = y; tipFor = key; showTip(key); },+  };++  poll();+})();+</script>+</body>+</html>
build.shadded+9
@@ -0,0 +1,9 @@+#!/bin/bash+# Build the Linux binary from source and put it where the package ships it (bin/). Anyone can run this.+set -e+HERE="$(cd "$(dirname "$0")" && pwd)"; cd "$HERE"+NAME="$(sed -n 's/^name = "\(.*\)"/\1/p' Cargo.toml | head -1)"+cargo test --release+cargo build --release+mkdir -p bin && cp "target/release/$NAME" "bin/$NAME"+echo "OK: bin/$NAME ($(bin/$NAME --version))"
flows/flow.jsonadded+87
@@ -0,0 +1,87 @@+{+  "name": "reverse",+  "description": "Reverse engineer a PCB from photos. The program measures (board outline, straightening, scale from hole pitches and part bodies, part bodies and packages, hole rows, every island of copper on both faces, vias, what each pad and via joins), generates the KiCad copy with the photographed pours and tracks, routes what cannot be seen on its own N-layer router, jumps the layer count until every connection closes and the DRC passes, and proposes the stackup. The AI names (part identities, markings, nets, functions). Every claim carries an evidence tag, and every stage records its interactive PCB or schematic in the flow window.",+  "steps": [+    {+      "name": "intake",+      "done": [+        "state.json",+        "checks/prep.ok"+      ],+      "do": "Run `init --top <photo...> --bottom <photo...>`, start the flow window (`serve` in the background, open its URL in a Hydrogen webview), then `prep`: it finds the board outline in every photo (connectors hanging over an edge are left out of the fit), straightens each photo, rates its sharpness, finds the plated holes and rows of holes, finds part bodies on the top, and works out how the bottom photo maps onto the top. Look at evidence/outline-*.jpg and evidence/prep-*.jpg. Run `check prep`. If a photo is soft or a marking is unreadable, ask the human for another shot now."+    },+    {+      "name": "scale",+      "done": [+        "scale.json",+        "board/top.jpg"+      ],+      "do": "Run `scale`: it solves pixels per mm from the hole-row pitches (the scale at which every row lands on a standard pitch) and lists the candidates. Confirm it with a body whose size a datasheet gives: `scale --body <prep body id>=<length>x<width> --source \"<datasheet, package drawing>\"` (the body ids are on evidence/prep-<top photo>.jpg). It then writes both faces into one board frame (board/top.jpg, board/bottom.jpg, gridded copies for sketching) and aligns the bottom to the top on their shared holes."+    },+    {+      "name": "parts",+      "done": [+        "checks/parts.ok"+      ],+      "do": "STEP 1: identify as many parts as possible. `detect parts` measures every body and hole row on both faces in board mm, matches a package by size, proposes a reference and rotation, and writes crops (evidence/parts/) and contact sheets. `parts init` turns the candidates into parts.json. Then read every crop: set the reference, value (`decode <marking>` reads resistor codes), marking, MPN, function, footprint where the guess is wrong, and the pin-1 mark for every part that fits one way; tag each field by how it is known. Dismiss each candidate that is not a part (silkscreen, logo, reflection) in parts.json `dismissed` with the reason. Run `check parts`."+    },+    {+      "name": "blocks",+      "done": [+        "checks/blocks.ok"+      ],+      "do": "STEP 2: identify the functional blocks. `blocks propose` groups parts that sit together; name each block, say what it does, move parts between blocks where the topology says so, and write blocks.json. Run `check blocks`."+    },+    {+      "name": "copper",+      "done": [+        "checks/copper.ok"+      ],+      "do": "STEP 3: identify zones and routing. `copper prep` writes gap maps, gridded board images and the via candidates (domes). `copper trace` reads every island of copper on both faces from the photos: copper is lighter than laminate relative to its own neighbourhood, and a dark-line filter adds the thin gaps. Each island becomes a pour (a zone with its exact copper) or a track (a centre line at its measured width). Pads and vias are joined to the islands they touch, screw lugs name their pours, and through vias carry nets to unnamed islands. It writes copper.json, detect/copper-measured.json (groups, disagreements with the nets so far, vias that lead only inward) and evidence/copper-islands-*.jpg. Look at the evidence images against the photos, and at the disagreements. Run `check copper`."+    },+    {+      "name": "mimic",+      "done": [+        "checks/mimic.ok"+      ],+      "do": "STEP 4: copy everything visible. `build` writes the KiCad project: KiCad library footprints at the measured positions, generated lug footprints, the outline with its corner radius, the traced pours filled with the copper the photo shows, tracks and vias, all at the rules set at `init`. It registers the project with the flow window. Run `check mimic --pcb <board>`: every part within 0.5 mm of where it was measured, every zone on the board and filled."+    },+    {+      "name": "snapshot",+      "done": [+        "snapshots/mimic/manifest.json"+      ],+      "do": "STEP 5a: save the project as copied, before anything inferred is added: `snapshot --name mimic --dir <kicad project folder>`."+    },+    {+      "name": "connect",+      "done": [+        "checks/nets.ok",+        "checks/layers.ok"+      ],+      "do": "STEP 5b: make the final connections and propose the layer count. Add the connections nobody can see from the datasheets (tag DATASHEET) or the circuit (tag INFERRED, with a test) to nets.json. `connect route` starts at the fewest layers the evidence allows and routes every open connection on the program's own router, on every signal layer of the stackup, keeping what the photo already joins. Pads, holes, seen vias and the board edge block; seen copper of another net costs extra and the length crossed is reported. It runs KiCad's DRC on each result against the same board unrouted. A stackup that leaves connections open or fails the DRC is a reason to jump: it moves on two layers at a time, up to `--max-layers`, and records every result and the proposal in layers.json. `build --layers <n>` writes the routed copy. Run `check nets` and `check layers --pcb <board>`."+    },+    {+      "name": "review",+      "done": [+        "checks/review.ok"+      ],+      "do": "STEP 6: review in a loop. `review measure` re-runs every measurement against the current files (part positions, pour fits, fills, connectivity) and lists what disagrees. Fix it, then `review record --changes <n> --confidence confidence.json --note \"...\"`. Done after at least two passes, the last making no change. Run `confidence` for the evidence counts to base the levels on, then `check review`."+    },+    {+      "name": "tests",+      "done": [+        "checks/tests.ok"+      ],+      "do": "STEP 7: `tests generate` writes a test for every INFERRED or UNKNOWN item (continuity for nets and pin maps, raking-light photos for markings, an edge view or X-ray for layers and planes, rail voltages for regulators), with the expected result and what changes if it differs. Edit what the generator could not know, then `check tests`."+    },+    {+      "name": "report",+      "done": [+        "report.md",+        "checks/report.ok"+      ],+      "do": "Run `report` for the generated tables, write report.md around them (what the board is, the blocks, the layer proposal and its evidence, confidence per category, copied versus inferred, the tests), and hand the KiCad project and the report to the human."+    }+  ]+}
install.shadded+18
@@ -0,0 +1,18 @@+#!/bin/bash+# adom-wiki package install hook: the binary to ~/.local/bin, the skill to ~/.claude/skills (and ~/.codex/skills).+# Builds from source when no prebuilt binary ships or it does not run here.+set -e+HERE="$(cd "$(dirname "$0")" && pwd)"+NAME="$(sed -n 's/^name = "\(.*\)"/\1/p' "$HERE/Cargo.toml" | head -1)"+if ! "$HERE/bin/$NAME" --version >/dev/null 2>&1; then+  command -v cargo >/dev/null || { echo "ERROR: no prebuilt binary for this machine and no cargo to build one (https://rustup.rs)"; 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"+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+command -v adom-bridge >/dev/null 2>&1 || echo "Hint: desktop steps need adom-bridge (the Adom Bridge CLI)"+echo "OK: $NAME installed. Run '$NAME --help'."
skills/adom-aiflow-reverse/SKILL.mdadded+46
@@ -0,0 +1,46 @@+---+name: adom-aiflow-reverse+description: "Reverse engineer a PCB from photos into a KiCad copy: identify parts and functional blocks, trace every pour, track and via from the pictures, route the hidden connections, propose the layer count, review with a confidence level, and list tests for what is unknown. Use when asked to reverse engineer this board, clone a PCB from pictures, copy a board into KiCad, identify the parts on a PCB, work out how many layers a board has, or infer its inner layers."+user-invocable: true+---++# Reverse engineer a board from photos++Run the `adom-aiflow-reverse` binary. It measures and generates; you name what takes judgement. Do not edit copper, part positions or routes by hand, and do not write your own image code: if a measurement is wrong, fix the program or say so.++```bash+mkdir my-board && cd my-board+adom-aiflow-reverse init --top a.jpg b.jpg --bottom c.jpg --rules kicad-default --ai-thread "<your thread>"+adom-aiflow-reverse serve --port 8891 &+adom-aiflow-reverse next --ai-thread "<your thread>"+```++Then repeat: do what `next` says, run its check, read the hints.++## Displaying this app (REQUIRED, read before showing anything)++To show the flow window, open `http://localhost:<port>` in a Hydrogen webview tab, loading the `webview` skill first, and keep it open for the whole run. Never open it in VS Code's Simple Browser: it is not a webview in the Adom ecosystem, with no live channel or console read-back.++Surfaces: **wv**, a Hydrogen webview panel; **pup**, see the `pup` skill; the user's **real signed-in browser**, through the Adom browser extension; **hdbw**, Hydrogen's standalone browser window, only under Hydrogen.++## What you supply++- **Scale:** one part body with a datasheet size, from a document fetched this session.+- **Parts:** read markings from the crops, then identity and value, each with its evidence.+- **Blocks:** names and functions for the groups it proposes.+- **Nets:** pin names and functions from datasheets. Tag each one `DATASHEET` or `INFERRED`. An inference needs a test.+- **Confidence:** per category, from the measurements, in the review.++## Tags are the contract++`SEEN` and `MEASURED` come from the photos. `DATASHEET` needs the document. `INFERRED` needs a test. `UNKNOWN` is a good answer. Never upgrade a tag to make a check pass.++## Read these results before believing a run++- `evidence/copper-islands-*.jpg` against the photos. This is what the board was built from.+- `detect/copper-measured.json`: `pinClashes` and `viaClashes` are where the copper disagrees with the nets. Each is either a wrong net or a misread island, and both need a test.+- `layers.json` candidates: `open`, `drcErrors`, `kicadOpen`, `seenCrossingMm`. The router's `open` and KiCad's `kicadOpen` should agree. If they do not, the copy's seen copper is not joined the way the measurement said.++## Layer count++`connect route` starts at the fewest layers the evidence allows and jumps two at a time when connections stay open or the DRC fails. Report what it found, including when nothing closed. Never present a proposal without its candidates.
src/board.rsadded+134
@@ -0,0 +1,134 @@+//! The board frame. `prep` measures every photo (outline, straightened image, sharpness, holes, rows of+//! holes, part bodies) and decides how the bottom maps onto the top. `scale` solves pixels per mm from+//! the hole-row pitches (which standard pitches explain every row at one scale) and a part body whose+//! size a datasheet gives, then writes both sides into one KiCad board frame: x right, y down, mm,+//! origin offset (100, 100), `RES` pixels per mm, bottom mirrored into the top view.+use crate::geom::{self, P};+use crate::prep;+use image::RgbImage;+use serde_json::{json, Value};+use std::path::Path;++pub const RES: f64 = 40.0;+pub const ORIGIN: f64 = 100.0;+pub const PITCHES: [f64; 11] = [1.0, 1.25, 1.27, 1.5, 2.0, 2.5, 2.54, 3.5, 3.81, 5.0, 5.08];++fn f(v: &Value, k: &str) -> f64 { v[k].as_f64().unwrap_or(0.0) }++/// Board-frame mm for a board-image pixel, and back.+pub fn to_mm(u: f64, v: f64) -> P { (ORIGIN + u / RES, ORIGIN + v / RES) }+pub fn to_px(x: f64, y: f64) -> P { ((x - ORIGIN) * RES, (y - ORIGIN) * RES) }++/// Measure one photo. Returns its record and its straightened image (photo-native scale, not mirrored).+pub fn prep_photo(path: &Path, side: &str, ev_dir: &Path) -> Result<Value, String> {+    let img = image::open(path).map_err(|e| format!("{}: {e}", path.display()))?.to_rgb8();+    let c = prep::board_corners(&img)?;+    let w = ((geom::dist(c[0], c[1]) + geom::dist(c[3], c[2])) / 2.0).round() as u32;+    let h = ((geom::dist(c[0], c[3]) + geom::dist(c[1], c[2])) / 2.0).round() as u32;+    let (r, _) = prep::rectify(&img, &c, w, h, false)?;+    let holes = prep::holes(&r, 20.0, 200.0);+    let rows = prep::hole_rows(&holes);+    let bodies = if side == "top" { prep::bodies(&r, 4.0, 18.0) } else { vec![] };+    let stem = path.file_stem().unwrap().to_string_lossy().to_string();+    // evidence: the outline on the photo, and the straightened photo with holes, rows and bodies numbered+    let mut ov = img.clone(); crate::draw::poly(&mut ov, &c, [255, 0, 255], 7);+    image::imageops::resize(&ov, img.width() / 3, img.height() / 3, image::imageops::FilterType::Triangle).save(ev_dir.join(format!("outline-{stem}.jpg"))).map_err(|e| e.to_string())?;+    let mut rv = r.clone();+    for (k, b) in bodies.iter().enumerate() { draw_rect(&mut rv, b, [0, 255, 255], 3); crate::draw::label(&mut rv, (f(b, "x") - 10.0, f(b, "y") - 8.0), &format!("{k}"), [255, 90, 0], 4); }+    for (k, hh) in holes.iter().enumerate() { crate::draw::circle(&mut rv, (f(hh, "x"), f(hh, "y")), f(hh, "hole") / 2.0 + 6.0, [255, 0, 255], 4); crate::draw::label(&mut rv, (f(hh, "x") + 22.0, f(hh, "y") - 10.0), &format!("{k}"), [255, 255, 0], 5); }+    for rw in &rows { let ids: Vec<usize> = rw["holes"].as_array().unwrap().iter().map(|x| x.as_u64().unwrap() as usize).collect();+        crate::draw::line(&mut rv, (f(&holes[ids[0]], "x"), f(&holes[ids[0]], "y")), (f(&holes[ids[ids.len() - 1]], "x"), f(&holes[ids[ids.len() - 1]], "y")), [255, 255, 0], 3); }+    rv.save(ev_dir.join(format!("prep-{stem}.jpg"))).map_err(|e| e.to_string())?;+    Ok(json!({"file": path.file_name().unwrap().to_string_lossy(), "side": side, "corners": c.iter().map(|p| json!([p.0, p.1])).collect::<Vec<_>>(),+              "rectPx": [w, h], "sharpness": prep::sharpness(&r), "holes": holes, "rows": rows, "bodies": bodies,+              "evidence": [format!("evidence/outline-{stem}.jpg"), format!("evidence/prep-{stem}.jpg")]}))+}++pub fn draw_rect(img: &mut RgbImage, b: &Value, c: [u8; 3], th: i64) {+    let (cx, cy, l, s, a) = (f(b, "x"), f(b, "y"), f(b, "long"), f(b, "short"), f(b, "angle").to_radians());+    let (co, sn) = (a.cos(), a.sin());+    let pts: Vec<P> = [(-1.0, -1.0), (1.0, -1.0), (1.0, 1.0), (-1.0, 1.0)].iter().map(|(u, v)| (cx + u * l / 2.0 * co - v * s / 2.0 * sn, cy + u * l / 2.0 * sn + v * s / 2.0 * co)).collect();+    crate::draw::poly(img, &pts, c, th);+}++/// Scale candidates: for each row and standard pitch, the px/mm it implies, scored by how many rows+/// (weighted by hole count) land on some standard pitch at that scale. Close candidates merge.+pub fn scale_candidates(rows: &[Value]) -> Vec<Value> {+    let mut cands: Vec<(f64, f64, Vec<Value>)> = vec![];+    for r in rows {+        for s in PITCHES {+            let k = f(r, "pitch") / s;+            if cands.iter().any(|c| (c.0 - k).abs() / k < 0.01) { continue; }+            let mut score = 0.0; let mut explain = vec![];+            for q in rows {+                let mm = f(q, "pitch") / k;+                if let Some(std) = PITCHES.iter().find(|p| ((mm - **p) / **p).abs() < 0.012) {+                    score += f(q, "count"); explain.push(json!({"holes": q["holes"], "pitchPx": f(q, "pitch"), "mm": std}));+                }+            }+            cands.push((k, score, explain));+        }+    }+    cands.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap().then(a.0.partial_cmp(&b.0).unwrap()));+    cands.into_iter().take(6).map(|(k, s, e)| json!({"pxPerMm": (k * 1000.0).round() / 1000.0, "score": s, "rows": e})).collect()+}++/// Corners of a photo mapped into the board frame (pixels at RES), mirrored for the bottom.+pub fn frame_corners(wmm: f64, hmm: f64, side: &str, axis: &str) -> [P; 4] {+    let (w, h) = (wmm * RES, hmm * RES);+    match (side, axis) {+        ("bottom", "y") => [(0.0, h), (w, h), (w, 0.0), (0.0, 0.0)],+        ("bottom", _) => [(w, 0.0), (0.0, 0.0), (0.0, h), (w, h)],+        _ => [(0.0, 0.0), (w, 0.0), (w, h), (0.0, h)],+    }+}++/// Warp a photo into the board frame through its own corners.+pub fn to_frame(path: &Path, corners: &[P; 4], dst: &[P; 4], wmm: f64, hmm: f64, shift: P) -> Result<RgbImage, String> {+    let img = image::open(path).map_err(|e| e.to_string())?.to_rgb8();+    let d: [P; 4] = dst.map(|p| (p.0 + shift.0, p.1 + shift.1));+    let hm = geom::homography(corners, &d).ok_or("degenerate corners")?;+    let inv = geom::invert(&hm).ok_or("not invertible")?;+    let (w, h) = ((wmm * RES).round() as u32, (hmm * RES).round() as u32);+    let planes: Vec<crate::raster::Plane> = (0..3).map(|c| crate::raster::Plane { w: img.width() as usize, h: img.height() as usize, d: img.pixels().map(|p| p[c] as f32).collect() }).collect();+    Ok(RgbImage::from_fn(w, h, |u, v| {+        let q = geom::apply(&inv, (u as f64 + 0.5, v as f64 + 0.5));+        image::Rgb([0, 1, 2].map(|c| planes[c].sample(q.0 as f32 - 0.5, q.1 as f32 - 0.5).clamp(0.0, 255.0) as u8))+    }))+}++/// Translation that best lays the bottom holes onto the top holes (board-frame pixels), with the+/// number of pairs and the residual after the shift.+pub fn align(top: &[Value], bot: &[Value]) -> (P, usize, f64) {+    let pairs = |shift: P| -> Vec<(P, P)> {+        bot.iter().filter_map(|b| {+            let q = (f(b, "x") + shift.0, f(b, "y") + shift.1);+            top.iter().map(|t| (f(t, "x"), f(t, "y"))).min_by(|a, c| geom::dist(*a, q).partial_cmp(&geom::dist(*c, q)).unwrap())+                .filter(|t| geom::dist(*t, q) < 1.5 * RES).map(|t| (t, (f(b, "x"), f(b, "y"))))+        }).collect()+    };+    let mut shift = (0.0, 0.0);+    for _ in 0..3 {+        let p = pairs(shift);+        if p.is_empty() { break; }+        let n = p.len() as f64;+        shift = (p.iter().map(|(t, b)| t.0 - b.0).sum::<f64>() / n, p.iter().map(|(t, b)| t.1 - b.1).sum::<f64>() / n);+    }+    let p = pairs(shift);+    let res = if p.is_empty() { f64::NAN } else { (p.iter().map(|(t, b)| geom::dist(*t, (b.0 + shift.0, b.1 + shift.1)).powi(2)).sum::<f64>() / p.len() as f64).sqrt() };+    (shift, p.len(), res)+}++#[cfg(test)]+mod tests {+    use super::*;+    #[test]+    fn driver_pitches_solve_to_one_scale() {+        // the Driver top photo: rows at 93.3 px (six of them) and one at 118.5 px+        let rows = vec![json!({"pitch": 93.2, "count": 6}), json!({"pitch": 93.2, "count": 7}), json!({"pitch": 118.5, "count": 4}), json!({"pitch": 93.5, "count": 4}), json!({"pitch": 745.4, "count": 3})];+        let c = scale_candidates(&rows);+        let k = c[0]["pxPerMm"].as_f64().unwrap();+        assert!((k - 46.6).abs() < 0.3, "{c:?}");+        assert_eq!(c[0]["rows"].as_array().unwrap().len(), 4);+    }+}
src/bridge.rsadded+82
@@ -0,0 +1,82 @@+#![allow(dead_code)] // template helpers: keep the ones your stage uses+//! Adom Bridge from a flow app: every desktop action (a browser through pup, a native app, a window+//! recording, files to and from the desktop) is one `adom-bridge` call. The app runs on Linux; the+//! desktop it drives is whatever `--target` names. Copied from aiflow-bridge; keep what you use.+use serde_json::{json, Value};+use std::path::{Path, PathBuf};+use std::process::Command;++#[derive(Clone)]+pub struct Bridge { pub target: String, pub ai_thread: String }++pub struct ClipStop { pub remote: Option<String>, pub local: Option<PathBuf>, pub pulled: bool, pub reply: Value }++impl Bridge {+    /// One verb with JSON args. Gated verbs need a per-call `reason` inside `args`.+    pub fn call(&self, verb: &str, args: &Value) -> Value {+        let out = Command::new("adom-bridge").args(["--ai-thread", &self.ai_thread, "--target", &self.target, verb, &args.to_string()]).output();+        match out {+            Ok(o) => {+                let text = String::from_utf8_lossy(&o.stdout).to_string();+                let v: Value = serde_json::from_str(&text).unwrap_or_else(|_| json!({"status": "parse_error", "raw": text.chars().take(400).collect::<String>()}));+                // some verbs wrap their answer as a JSON string in `output`+                match v.get("output").and_then(|o| o.as_str()).and_then(|s| serde_json::from_str::<Value>(s).ok()) { Some(inner) => inner, None => v }+            }+            Err(e) => json!({"status": "error", "error": format!("adom-bridge not runnable: {e}")}),+        }+    }++    /// A field from a reply, at the top level or under `data` (verbs answer both ways).+    pub fn field<'a>(v: &'a Value, k: &str) -> Option<&'a Value> {+        v.get(k).or_else(|| v.get("data").and_then(|d| d.get(k)))+    }++    pub fn ok(v: &Value) -> bool {+        v.get("success").and_then(|s| s.as_bool()) == Some(true) || (v.get("status").and_then(|s| s.as_str()) == Some("ok") && v.get("success").is_none())+    }++    /// Record ONE window (Windows Graphics Capture: works in the background, nobody at the desk is+    /// disturbed). Capped at an hour whatever the AI forgets. Returns the recording id.+    pub fn record_window_start(&self, hwnd: i64, reason: &str) -> Result<String, Value> {+        let r = self.call("desktop_record_window_start", &json!({"hwnd": hwnd, "fps": 30, "reason": reason, "maxDurationMs": 3_600_000}));+        Self::field(&r, "recordingId").or_else(|| Self::field(&r, "id")).and_then(|v| v.as_str()).map(str::to_string).ok_or(r)+    }++    /// Stop a recording and pull the file into `save_to`, checked: the local copy must exist and be+    /// non-empty. The desktop cleans its recordings folder, so pull now or lose the clip.+    pub fn record_stop(&self, id: &str, save_to: &Path) -> ClipStop {+        let r = self.call("desktop_record_stop", &json!({"recordingId": id}));+        let remote = Self::field(&r, "filePath").or_else(|| Self::field(&r, "path"))+            .and_then(|v| v.as_str()).map(|s| s.replace('\\', "/"));+        match remote {+            Some(p) => { let (pulled, local) = self.pull(&p, save_to, 3); ClipStop { remote: Some(p), local: Some(local), pulled, reply: r } }+            None => ClipStop { remote: None, local: None, pulled: false, reply: r },+        }+    }++    /// pull_file one desktop file into `save_to` until it lands non-empty (at most `attempts`).+    pub fn pull(&self, remote: &str, save_to: &Path, attempts: usize) -> (bool, PathBuf) {+        let local = save_to.join(remote.rsplit(['/', '\\']).next().unwrap_or(remote));+        for k in 0..attempts.max(1) {+            self.call("pull_file", &json!({"filePaths": [remote], "saveTo": save_to.display().to_string(), "reason": "flow app: bring a desktop file into the project"}));+            if std::fs::metadata(&local).map(|m| m.len() > 0).unwrap_or(false) { return (true, local); }+            if k + 1 < attempts { std::thread::sleep(std::time::Duration::from_secs(3)); }+        }+        (false, local)+    }++    /// A background screenshot of one window, copied to `dest`.+    pub fn screenshot_window(&self, hwnd: i64, dest: &Path) -> Option<PathBuf> {+        let r = self.call("desktop_screenshot_window", &json!({"hwnd": hwnd, "reason": "flow app: evidence screenshot"}));+        let d = r.get("data").cloned().unwrap_or(r.clone());+        let shots = d.get("screenshots").and_then(|s| s.as_array()).cloned().unwrap_or_else(|| vec![d.clone()]);+        let local = shots.iter().find_map(|s| s.get("localPath").or_else(|| s.get("savedTo")).and_then(|v| v.as_str()).map(str::to_string))?;+        std::fs::copy(&local, dest).ok()?;+        Some(dest.to_path_buf())+    }++    /// JavaScript in a pup (Puppeteer) browser window. No OS cursor moves, so recordings stay clean.+    pub fn pup_eval(&self, window: &str, expression: &str, reason: &str) -> Value {+        self.call("pup_eval", &json!({"window": window, "expression": expression, "reason": reason}))+    }+}
src/checks.rsadded+374
@@ -0,0 +1,374 @@+//! The step checks. Each reads its artifact, applies the rules the Driver board taught us, and on a+//! pass writes `checks/<name>.ok` with the sha256 of every file it read. A check whose files changed+//! afterwards is stale, so `next` sends the AI back to it: editing parts.json after the review undoes+//! the review.+use crate::items::{self, Item};+use crate::{kicad_in as kicad, ledger, sha256};+use serde_json::{json, Value};+use std::collections::{BTreeMap, HashMap, HashSet};+use std::path::Path;++pub struct Pass { pub summary: Value, pub hints: Vec<String>, pub files: Vec<String> }+pub type Outcome = Result<Pass, Vec<String>>;++/// Every artifact the review covers (and that a later edit would make stale).+pub const REVIEWED: [&str; 7] = ["prep.json", "scale.json", "parts.json", "blocks.json", "copper.json", "nets.json", "layers.json"];+pub const CATEGORIES: [&str; 7] = ["identity", "values", "blocks", "placement", "copper", "layers", "connectivity"];+pub const LEVELS: [&str; 3] = ["High", "Medium", "Low"];+pub const VIA_KINDS: [&str; 7] = ["stitch", "signal", "thermal", "inner", "unknown", "seen", "seen-net"];++fn doc(flow: &Path, name: &str) -> Result<Value, Vec<String>> {+    items::read_json(&flow.join(name)).map_err(|e| vec![format!("{e} (write it first; schema in docs/schemas.md)")])+}++fn s<'a>(v: &'a Value, k: &str) -> &'a str { v[k].as_str().unwrap_or("") }++fn pos(v: &Value) -> Option<(f64, f64)> {+    let a = v["pos_mm"].as_array()?;+    Some((a.first()?.as_f64()?, a.get(1)?.as_f64()?))+}++fn tag_counts(items: &[Item]) -> Value {+    let mut m: BTreeMap<String, usize> = BTreeMap::new();+    for it in items { *m.entry(it.tag.clone()).or_default() += 1; }+    json!(m)+}++fn validate_all(flow: &Path, items: &[Item], errs: &mut Vec<String>) {+    let mut seen = HashSet::new();+    for it in items {+        items::validate(flow, it, errs);+        if !it.id.is_empty() && !seen.insert(it.id.clone()) { errs.push(format!("{}: listed twice in {}", it.id, it.artifact)); }+    }+}++/// A part that only fits one way needs its orientation mark checked before anything is routed: the+/// Driver's two QFNs were placed 180 degrees out (the router failed on every pin of one), and its+/// MOSFETs' high or low side was read from which way the tab faced. ICs, transistors, MOSFETs,+/// regulators, diodes and LEDs: `pin1` names the mark (pin-1 dot, notch, bevel, tab, cathode band).+pub fn needs_pin1(p: &Value) -> bool {+    let pk = s(p, "package").to_uppercase();+    let r = s(p, "ref").to_uppercase();+    const PKG: [&str; 22] = ["QFN", "QFP", "SOIC", "SOP", "MSOP", "BGA", "DFN", "SON", "LGA", "DIP", "PLCC", "SOT-23", "SOT23", "SOT-223", "SOT-89", "DPAK", "TO-2", "SMA", "SMB", "SMC", "SOD", "LED"];+    PKG.iter().any(|k| pk.contains(k)) || ["U", "IC", "Q", "D", "LED"].iter().any(|x| r.starts_with(x) && r[x.len()..].starts_with(|c: char| c.is_ascii_digit()))+}++/// Every registered photo measured, and the bottom-to-top mapping decided clearly.+pub fn prep(flow: &Path, state: &Value) -> Outcome {+    let d = doc(flow, "prep.json")?;+    let mut errs = vec![];+    let done: Vec<String> = d["photos"].as_array().into_iter().flatten().map(|p| s(p, "file").to_string()).collect();+    for p in state["photos"].as_array().into_iter().flatten() { if !done.iter().any(|f| f == s(p, "file")) { errs.push(format!("{} was registered but not measured: run `prep`", s(p, "file"))); } }+    let (m, o) = (d["flip"]["matches"].as_u64().unwrap_or(0), d["flip"]["other"].as_u64().unwrap_or(0));+    if d["flip"].is_object() && m < 3 * o.max(1) { errs.push(format!("the bottom-to-top mapping is not clear ({m} holes agree one way, {o} the other): check the photos show the whole board")); }+    if !errs.is_empty() { return Err(errs); }+    let mut hints = vec![];+    for p in d["photos"].as_array().into_iter().flatten() { hints.push(format!("{} ({}): sharpness {:.0}, {} holes, {} rows", s(p, "file"), s(p, "side"), p["sharpness"].as_f64().unwrap_or(0.0), p["holes"].as_array().map(|a| a.len()).unwrap_or(0), p["rows"].as_array().map(|a| a.len()).unwrap_or(0))); }+    Ok(Pass { summary: json!({"photos": done.len(), "flip": d["flip"]}), hints, files: vec!["prep.json".into()] })+}++pub fn parts(flow: &Path) -> Outcome {+    let d = doc(flow, "parts.json")?;+    let list = d["parts"].as_array().cloned().unwrap_or_default();+    if list.is_empty() { return Err(vec!["parts.json has no parts".into()]); }+    let its = items::items_of("parts.json", &d);+    let mut errs = vec![];+    validate_all(flow, &its, &mut errs);+    let mut placed: Vec<(String, String, f64, f64)> = vec![];+    for p in &list {+        let r = s(p, "ref");+        if r.is_empty() { continue; }+        if !matches!(s(p, "side"), "top" | "bottom") { errs.push(format!("{r}: side must be top or bottom")); }+        match pos(p) { Some((x, y)) => placed.push((r.into(), s(p, "side").into(), x, y)), None => errs.push(format!("{r}: pos_mm [x, y] missing (board frame, mm, y down, as KiCad)")) }+        if p["rot"].as_f64().is_none() { errs.push(format!("{r}: rot (degrees, KiCad convention) missing")); }+        if s(p, "package").is_empty() && s(p, "footprint").is_empty() { errs.push(format!("{r}: give the package or the footprint")); }+        if needs_pin1(p) {+            let pin = &p["pin1"];+            if s(pin, "mark").is_empty() { errs.push(format!("{r}: a part that fits only one way, without pin1.mark: name the mark you saw (pin-1 dot, notch, bevel, tab, cathode band, silk triangle) and where it sits, before routing")); }+            else if let Some(e) = pin["evidence"].as_str() { if items::resolve(flow, e).is_none() { errs.push(format!("{r}: pin1 evidence {e} is not there")); } }+            else if is_seen(s(pin, "tag")) { errs.push(format!("{r}: pin1 tagged {} without an evidence crop", s(pin, "tag"))); }+        }+    }+    for i in 0..placed.len() {+        for j in i + 1..placed.len() {+            let (a, b) = (&placed[i], &placed[j]);+            if a.1 == b.1 && ((a.2 - b.2).powi(2) + (a.3 - b.3).powi(2)).sqrt() < 0.5 {+                errs.push(format!("{} and {} sit within 0.5 mm of each other on the {}: one part counted twice?", a.0, b.0, a.1));+            }+        }+    }+    // every candidate the program measured is accounted for: used by a part, or dismissed with a reason+    if let Ok(det) = items::read_json(&flow.join("detect/parts.json")) {+        let used: std::collections::HashSet<String> = list.iter().filter_map(|p| p["candidate"].as_str().map(str::to_string)).collect();+        let dismissed: HashMap<String, String> = d["dismissed"].as_array().into_iter().flatten().map(|x| (s(x, "candidate").to_string(), s(x, "why").to_string())).collect();+        for (c, why) in &dismissed { if why.is_empty() { errs.push(format!("dismissed {c}: say why (silkscreen, logo, reflection, part of another body)")); } }+        let open_c: Vec<String> = det["candidates"].as_array().into_iter().flatten().filter_map(|c| c["id"].as_str()).filter(|c| !used.contains(*c) && !dismissed.contains_key(*c)).map(str::to_string).collect();+        if !open_c.is_empty() { errs.push(format!("{} measured candidate(s) neither used by a part nor dismissed: {}", open_c.len(), open_c.iter().take(30).cloned().collect::<Vec<_>>().join(", "))); }+    }+    if !errs.is_empty() { return Err(errs); }+    let open = its.iter().filter(|i| i.open()).count();+    let hints = vec![format!("{} parts, {open} with something inferred or unknown. Every one of those needs a test in step 7.", its.len())];+    Ok(Pass { summary: json!({"parts": its.len(), "tags": tag_counts(&its), "open": open}), hints, files: vec!["parts.json".into()] })+}++fn is_seen(t: &str) -> bool { matches!(t, "SEEN" | "MEASURED") }++pub fn blocks(flow: &Path) -> Outcome {+    let d = doc(flow, "blocks.json")?;+    let p = doc(flow, "parts.json")?;+    let refs: Vec<String> = p["parts"].as_array().into_iter().flatten().map(|x| s(x, "ref").to_string()).collect();+    let its = items::items_of("blocks.json", &d);+    let mut errs = vec![];+    if its.is_empty() { errs.push("blocks.json has no blocks".into()); }+    validate_all(flow, &its, &mut errs);+    let mut owner: HashMap<String, String> = HashMap::new();+    for b in d["blocks"].as_array().into_iter().flatten() {+        let id = s(b, "id");+        if s(b, "name").is_empty() || s(b, "function").is_empty() { errs.push(format!("block:{id}: give a name and a function (what it does)")); }+        for r in b["parts"].as_array().into_iter().flatten().filter_map(|x| x.as_str()) {+            if !refs.iter().any(|x| x == r) { errs.push(format!("block:{id}: part {r} is not in parts.json")); }+            if let Some(o) = owner.insert(r.into(), id.into()) { errs.push(format!("{r} is in two blocks: {o} and {id}")); }+        }+    }+    let loose: Vec<&String> = refs.iter().filter(|r| !owner.contains_key(*r)).collect();+    if !loose.is_empty() { errs.push(format!("parts in no block (holes, logos and fiducials go in a `mechanical` block): {}", loose.iter().map(|x| x.as_str()).collect::<Vec<_>>().join(", "))); }+    if !errs.is_empty() { return Err(errs); }+    Ok(Pass { summary: json!({"blocks": its.len(), "tags": tag_counts(&its)}), hints: vec![], files: vec!["blocks.json".into(), "parts.json".into()] })+}++pub fn inner_vias(copper: &Value) -> usize {+    copper["vias"].as_array().into_iter().flatten().filter(|v| s(v, "kind") == "inner").count()+}++pub fn copper(flow: &Path) -> Outcome {+    let d = doc(flow, "copper.json")?;+    let its = items::items_of("copper.json", &d);+    let mut errs = vec![];+    validate_all(flow, &its, &mut errs);+    match d["outline"]["points_mm"].as_array() {+        Some(a) if a.len() >= 3 => {}+        _ => errs.push("outline.points_mm needs at least three [x, y] points (the measured board edge)".into()),+    }+    for (key, need) in [("zones", ["net", "layer"]), ("traces", ["net", "layer"])] {+        for v in d[key].as_array().into_iter().flatten() {+            for k in need { if s(v, k).is_empty() { errs.push(format!("{key} {}: `{k}` missing (UNKNOWN is a valid net name only with an UNKNOWN tag)", s(v, "id"))); } }+        }+    }+    for z in d["zones"].as_array().into_iter().flatten() {+        let id = s(z, "id");+        let pts = z["polygon_mm"].as_array().map(|a| a.len()).unwrap_or(0);+        if pts < 3 { errs.push(format!("zone {id}: no polygon_mm: sketch it (sketch_mm) and run `copper fit`")); continue; }+        // an outline traced from measured regions is measured by construction; a hand sketch must land on gaps+        if matches!(s(z, "tag"), "SEEN" | "MEASURED") && s(z, "from") != "regions" && s(z, "from") != "islands" {+            let share = z["fit"]["edgeShare"].as_f64().unwrap_or(0.0);+            if share < 0.6 { errs.push(format!("zone {id}: only {:.0}% of its outline sits on a measured gap line: re-sketch it closer to the photo, or tag it INFERRED with a test if its edges are hidden", share * 100.0)); }+        }+    }+    for v in d["vias"].as_array().into_iter().flatten() {+        if pos(v).is_none() { errs.push(format!("via {}: pos_mm missing", s(v, "id"))); }+        if !VIA_KINDS.contains(&s(v, "kind")) { errs.push(format!("via {}: kind must be one of {}", s(v, "id"), VIA_KINDS.join(", "))); }+    }+    if !errs.is_empty() { return Err(errs); }+    let n = inner_vias(&d);+    let mut hints = vec![];+    if n > 0 { hints.push(format!("{n} inner via(s): ringed on both outer faces with no trace on either, so they continue on an inner layer. The board has at least 4 layers.")); }+    else { hints.push("No inner vias recorded. If both faces are fully visible, 2 layers is possible; say so in step 5 only after looking under the large parts and pours.".into()); }+    Ok(Pass { summary: json!({"zones": d["zones"].as_array().map(|a| a.len()).unwrap_or(0), "traces": d["traces"].as_array().map(|a| a.len()).unwrap_or(0), "vias": d["vias"].as_array().map(|a| a.len()).unwrap_or(0), "innerVias": n, "tags": tag_counts(&its)}), hints, files: vec!["copper.json".into()] })+}++fn find_glob(flow: &Path, prefix: &str) -> bool {+    for base in [flow.to_path_buf(), flow.parent().map(Path::to_path_buf).unwrap_or_default()] {+        if let Ok(rd) = std::fs::read_dir(base.join("evidence")) {+            if rd.flatten().any(|e| e.file_name().to_string_lossy().starts_with(prefix)) { return true; }+        }+    }+    false+}++fn ang(a: f64, b: f64, m: f64) -> f64 { let d = (a - b).rem_euclid(m); d.min(m - d) }++pub fn mimic(flow: &Path, pcb: &Path, state: &Value) -> Outcome {+    let d = doc(flow, "parts.json")?;+    let text = std::fs::read_to_string(pcb).map_err(|e| vec![format!("{}: {e}", pcb.display())])?;+    let fps = kicad::footprints(&text);+    let mut errs = vec![];+    let on_board: HashMap<String, &kicad::Footprint> = fps.iter().map(|f| (f.reference.clone(), f)).collect();+    let mut wanted = HashSet::new();+    for p in d["parts"].as_array().into_iter().flatten() {+        let r = s(p, "ref");+        if p["onBoard"] == json!(false) { continue; }+        wanted.insert(r.to_string());+        let Some(f) = on_board.get(r) else { errs.push(format!("{r}: in parts.json but not on the board")); continue };+        let side_ok = (s(p, "side") == "top") == f.layer.starts_with("F.");+        if !side_ok { errs.push(format!("{r}: parts.json says {} but the footprint is on {}", s(p, "side"), f.layer)); }+        if let Some((x, y)) = pos(p) {+            let dd = ((f.x - x).powi(2) + (f.y - y).powi(2)).sqrt();+            if dd > 0.5 { errs.push(format!("{r}: on the board at ({:.2}, {:.2}), measured at ({x:.2}, {y:.2}): {dd:.2} mm apart (limit 0.5)", f.x, f.y)); }+        }+        if needs_pin1(p) && s(p, "side") == "top" {+            if let Some(rot) = p["rot"].as_f64() {+                if ang(f.rot, rot, 360.0) > 2.0 { errs.push(format!("{r}: rotated {} on the board, {rot} in parts.json: check pin 1 against the photo", f.rot)); }+            }+        }+    }+    for f in &fps {+        if f.reference.is_empty() || f.reference.starts_with("REF**") { continue; }+        if !wanted.contains(&f.reference) { errs.push(format!("{}: on the board but not in parts.json (add it with its evidence, or remove it)", f.reference)); }+    }+    if !find_glob(flow, "overlay-top") { errs.push("evidence/overlay-top.* missing: overlay the rendered board on the top photo and look at every part".into()); }+    let has_bottom = state["photos"].as_array().into_iter().flatten().any(|p| s(p, "side") == "bottom");+    if has_bottom && !find_glob(flow, "overlay-bottom") { errs.push("evidence/overlay-bottom.* missing: overlay the bottom render on the bottom photo (mirror one of them)".into()); }+    if !errs.is_empty() { return Err(errs); }+    let zones = text.matches("\n\t(zone").count();+    let unfilled: Vec<String> = text.split("\n\t(zone").skip(1).filter(|z| !z.contains("filled_polygon")).map(|z| z.split("(name \"").nth(1).and_then(|x| x.split('"').next()).unwrap_or("?").to_string()).collect();+    // a traced island under 5 mm2 can be cut away whole by routed copper and its clearance; a pour must never be+    let areas: HashMap<String, f64> = items::read_json(&flow.join("copper.json")).ok().map(|c| c["zones"].as_array().into_iter().flatten().map(|z| (s(z, "id").to_string(), z["areaMm2"].as_f64().unwrap_or(f64::MAX))).collect()).unwrap_or_default();+    let lost: Vec<String> = unfilled.iter().filter(|z| areas.get(*z).copied().unwrap_or(f64::MAX) >= 5.0).cloned().collect();+    if !lost.is_empty() { return Err(vec![format!("zones with no fill: {} (build fills every zone: rebuild, and check the zone is not cut away entirely by other-net copper)", lost.join(", "))]); }+    let cz = items::read_json(&flow.join("copper.json")).ok().and_then(|c| c["zones"].as_array().map(|a| a.len())).unwrap_or(0);+    let mut hints = vec![];+    if !unfilled.is_empty() { hints.push(format!("{} small island(s) under 5 mm2 cut away entirely by other copper: {}", unfilled.len(), unfilled.join(", "))); }+    if zones < cz { return Err(vec![format!("the board has {zones} zone(s), copper.json lists {cz}: every seen zone must be on the board")]); }+    Ok(Pass { summary: json!({"pcb": pcb.display().to_string(), "pcbSha256": sha256(pcb), "footprints": fps.len(), "zones": zones}), hints, files: vec!["parts.json".into(), "copper.json".into()] })+}++pub fn nets(flow: &Path) -> Outcome {+    let d = doc(flow, "nets.json")?;+    let p = doc(flow, "parts.json")?;+    let refs: HashSet<String> = p["parts"].as_array().into_iter().flatten().map(|x| s(x, "ref").to_string()).collect();+    let its = items::items_of("nets.json", &d);+    let mut errs = vec![];+    if its.is_empty() { errs.push("nets.json has no nets".into()); }+    validate_all(flow, &its, &mut errs);+    let mut pin_net: HashMap<String, String> = HashMap::new();+    for n in d["nets"].as_array().into_iter().flatten() {+        let name = s(n, "name");+        let pins: Vec<&str> = n["pins"].as_array().into_iter().flatten().filter_map(|x| x.as_str()).collect();+        if pins.is_empty() { errs.push(format!("net:{name}: no pins")); }+        for pin in pins {+            let Some((r, _)) = pin.rsplit_once('.') else { errs.push(format!("net:{name}: pin {pin} is not REF.PIN")); continue };+            if !refs.contains(r) { errs.push(format!("net:{name}: {r} is not in parts.json")); }+            if let Some(o) = pin_net.insert(pin.into(), name.into()) { errs.push(format!("{pin} is on two nets: {o} and {name}")); }+        }+    }+    if !errs.is_empty() { return Err(errs); }+    Ok(Pass { summary: json!({"nets": its.len(), "pins": pin_net.len(), "tags": tag_counts(&its)}), hints: vec![], files: vec!["nets.json".into(), "parts.json".into()] })+}++pub fn layers(flow: &Path, pcb: Option<&Path>) -> Outcome {+    let d = doc(flow, "layers.json")?;+    let c = doc(flow, "copper.json")?;+    let its = items::items_of("layers.json", &d);+    let mut errs = vec![];+    validate_all(flow, &its, &mut errs);+    let cands: Vec<&Value> = d["candidates"].as_array().into_iter().flatten().collect();+    if cands.is_empty() { errs.push("no candidates: `connect route` tries each stackup and records the result".into()); }+    for k in &cands {+        if k["layers"].as_u64().is_none() || k["open"].as_u64().is_none() { errs.push(format!("candidate {k}: needs `layers` and `open` (connections left after routing)")); }+        if s(k, "run").is_empty() { errs.push(format!("candidate {}: `run` must name the route folder or report it came from", k["layers"])); }+    }+    let proposed = d["proposed"].as_u64().unwrap_or(0);+    if proposed == 0 { errs.push("`proposed` (the layer count you propose) missing".into()); }+    if s(&d, "why").is_empty() { errs.push("`why` missing: the evidence for the proposal (inner vias, routing results, planes)".into()); }+    let inner = inner_vias(&c);+    let floor = if inner > 0 { 4 } else { 2 };+    let tried = cands.iter().filter_map(|k| k["layers"].as_u64()).min().unwrap_or(0);+    if tried > floor { errs.push(format!("the fewest layers routed is {tried}, but the evidence allows {floor}: route at {floor} first, so the proposal is the smallest count that works")); }+    if inner > 0 && proposed > 0 && proposed < 4 { errs.push(format!("{inner} inner via(s) in copper.json prove at least 4 layers; {proposed} proposed")); }+    let chosen = cands.iter().find(|k| k["layers"].as_u64() == Some(proposed));+    if proposed > 0 && chosen.is_none() { errs.push(format!("{proposed} layers proposed but no candidate routed at {proposed}")); }+    if let Some(k) = chosen {+        if k["open"].as_u64().unwrap_or(0) > 0 && s(&d, "openNote").is_empty() {+            errs.push(format!("the proposed {proposed}-layer route leaves {} connection(s) open: route more, propose more layers, or explain in `openNote` (and list them in tests)", k["open"]));+        }+    }+    let nets: HashSet<String> = items::read_json(&flow.join("nets.json")).ok().map(|n| n["nets"].as_array().into_iter().flatten().map(|x| s(x, "name").to_string()).collect()).unwrap_or_default();+    for pl in d["planes"].as_array().into_iter().flatten() {+        let l = s(pl, "layer");+        let idx = l.strip_prefix("In").and_then(|x| x.strip_suffix(".Cu")).and_then(|x| x.parse::<u64>().ok());+        match idx { Some(i) if proposed >= 2 && i <= proposed - 2 => {}, _ => errs.push(format!("plane {l}: not an inner layer of a {proposed}-layer board (In1.Cu ..)")) }+        if !nets.contains(s(pl, "net")) { errs.push(format!("plane {l}: net {} is not in nets.json", s(pl, "net"))); }+        if s(pl, "why").is_empty() { errs.push(format!("plane {l}: `why` missing (which vias and pins say this layer is {})", s(pl, "net"))); }+    }+    if let Some(p) = pcb {+        let t = std::fs::read_to_string(p).map_err(|e| vec![format!("{}: {e}", p.display())])?;+        let n = kicad::copper_layers(&t);+        if n as u64 != proposed { errs.push(format!("{} has {n} copper layers, {proposed} proposed: save the project with the proposed stackup", p.display())); }+    }+    if !errs.is_empty() { return Err(errs); }+    let min_closed = cands.iter().filter(|k| k["open"].as_u64() == Some(0)).filter_map(|k| k["layers"].as_u64()).min();+    let mut hints = vec![];+    if let Some(m) = min_closed { if proposed > m.max(if inner > 0 { 4 } else { 2 }) { hints.push(format!("{m} layers already closed every connection; {proposed} proposed: make sure `why` says what the extra layers are for")); } }+    Ok(Pass { summary: json!({"proposed": proposed, "innerVias": inner, "candidates": cands.len(), "planes": d["planes"].as_array().map(|a| a.len()).unwrap_or(0)}), hints, files: vec!["layers.json".into(), "nets.json".into(), "copper.json".into()] })+}++pub fn current_hashes(flow: &Path) -> BTreeMap<String, String> {+    REVIEWED.iter().filter(|f| flow.join(f).is_file()).map(|f| (f.to_string(), sha256(&flow.join(f)))).collect()+}++pub fn confidence_errors(c: &Value) -> Vec<String> {+    let mut errs = vec![];+    for k in CATEGORIES {+        let e = &c[k];+        if !LEVELS.contains(&s(e, "level")) { errs.push(format!("confidence.{k}.level must be one of {}", LEVELS.join(", "))); }+        if s(e, "why").is_empty() { errs.push(format!("confidence.{k}.why missing")); }+    }+    errs+}++pub fn review(flow: &Path) -> Outcome {+    let d = doc(flow, "review.json")?;+    let passes = d["passes"].as_array().cloned().unwrap_or_default();+    let mut errs = vec![];+    if passes.len() < 2 { errs.push(format!("{} review pass(es) recorded; the loop needs at least two (`review record`)", passes.len())); }+    if let Some(last) = passes.last() {+        if last["changes"].as_u64() != Some(0) { errs.push(format!("the last pass made {} change(s): review again until a pass changes nothing", last["changes"])); }+        let now = current_hashes(flow);+        let then: BTreeMap<String, String> = serde_json::from_value(last["hashes"].clone()).unwrap_or_default();+        let changed: Vec<&String> = now.keys().filter(|k| then.get(*k) != now.get(*k)).collect();+        if !changed.is_empty() { errs.push(format!("changed since the last pass: {}; review again", changed.iter().map(|x| x.as_str()).collect::<Vec<_>>().join(", "))); }+        errs.extend(confidence_errors(&last["confidence"]));+    }+    if !errs.is_empty() { return Err(errs); }+    let mut files: Vec<String> = REVIEWED.iter().filter(|f| flow.join(f).is_file()).map(|f| f.to_string()).collect();+    files.push("review.json".into());+    Ok(Pass { summary: json!({"passes": passes.len(), "confidence": passes.last().unwrap()["confidence"]}), hints: vec![], files })+}++pub fn tests(flow: &Path) -> Outcome {+    let d = doc(flow, "tests.json")?;+    let all = items::all(flow);+    let ids: HashSet<&str> = all.iter().map(|i| i.id.as_str()).collect();+    let known = |x: &str| ids.contains(x) || x.rsplit_once('.').map(|(a, _)| ids.contains(a)).unwrap_or(false);+    let mut errs = vec![];+    let mut test_ids = HashSet::new();+    let mut settled: HashSet<String> = HashSet::new();+    for t in d["tests"].as_array().into_iter().flatten() {+        let id = s(t, "id");+        if id.is_empty() || !test_ids.insert(id.to_string()) { errs.push(format!("test {id:?}: empty or repeated id")); }+        for k in ["method", "equipment", "expected", "ifNot"] { if s(t, k).is_empty() { errs.push(format!("test {id}: `{k}` missing")); } }+        let st: Vec<&str> = t["settles"].as_array().into_iter().flatten().filter_map(|x| x.as_str()).collect();+        if st.is_empty() { errs.push(format!("test {id}: settles nothing")); }+        for x in st {+            if !known(x) { errs.push(format!("test {id}: settles {x}, which is no item (part ref, block:, zone:, trace:, via:, net:, plane:, outline, layers)")); }+            settled.insert(x.split('.').next().unwrap_or(x).to_string());+            settled.insert(x.to_string());+        }+    }+    for it in &all {+        for t in &it.tests { if !test_ids.contains(t) { errs.push(format!("{}: names test {t}, which is not in tests.json", it.id)); } }+        let named = it.tests.iter().any(|t| test_ids.contains(t));+        if it.open() && !settled.contains(&it.id) && !named { errs.push(format!("{} ({}) is settled by no test in tests.json", it.id, it.open_what().join(", "))); }+    }+    if !errs.is_empty() { return Err(errs); }+    let mut files: Vec<String> = items::ARTIFACTS.iter().filter(|f| flow.join(f).is_file()).map(|f| f.to_string()).collect();+    files.push("tests.json".into());+    Ok(Pass { summary: json!({"tests": test_ids.len(), "openItems": all.iter().filter(|i| i.open()).count()}), hints: vec![], files })+}++pub fn write_ok(flow: &Path, name: &str, pass: &Pass) -> std::io::Result<()> {+    std::fs::create_dir_all(flow.join("checks"))?;+    let files: BTreeMap<String, String> = pass.files.iter().map(|f| (f.clone(), sha256(&flow.join(f)))).collect();+    let v = json!({"check": name, "at": ledger::now(), "files": files, "summary": pass.summary});+    std::fs::write(flow.join(format!("checks/{name}.ok")), serde_json::to_string_pretty(&v)? + "\n")+}
src/connect.rsadded+138
@@ -0,0 +1,138 @@+//! Connectivity measured on the photos. A pad lying inside a pour outline proves nothing (a GND pour+//! covers pads of every net); what proves it is the clearance ring. Around each pad and via inside a+//! fitted pour, the gap map is sampled on a ring just outside the copper: a continuous gap means+//! isolated, a gap broken by spokes means a thermal connection, no gap means a solid one. Connected+//! items are joined (pads of one through-hole pin on both faces, vias between faces, trace ends on+//! pads) into nets. A via ringed on both faces with no trace is evidence of an inner layer, and the+//! pins beside such vias name the plane.+use crate::kicad_out::{Grid, Pad};+use crate::raster::Plane;+use serde_json::{json, Value};+use std::collections::HashMap;++fn pt(v: &Value) -> (f64, f64) { (v[0].as_f64().unwrap_or(0.0), v[1].as_f64().unwrap_or(0.0)) }++pub fn inside(poly: &[(f64, f64)], p: (f64, f64)) -> bool {+    let mut c = false;+    for i in 0..poly.len() { let (a, b) = (poly[i], poly[(i + 1) % poly.len()]);+        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 { c = !c; } }+    c+}++/// Share of a ring at distance `d` mm outside a pad (rectangle or circle) where the gap map is strong.+pub fn ring_share(gap: &Plane, p: &Pad, d: f64, strength: f32) -> f64 {+    let (hw, hh) = (p.w / 2.0 + d, p.h / 2.0 + d);+    let (c, s) = (p.rot.to_radians().cos(), p.rot.to_radians().sin());+    let n = 48; let mut hit = 0;+    for k in 0..n {+        let t = k as f64 / n as f64 * std::f64::consts::TAU;+        let (lx, ly) = if p.round { (hw * t.cos(), hh * t.sin()) } else {+            // walk the rectangle perimeter evenly+            let per = 4.0 * (hw + hh); let u = t / std::f64::consts::TAU * per;+            if u < 2.0 * hw { (-hw + u, -hh) } else if u < 2.0 * hw + 2.0 * hh { (hw, -hh + (u - 2.0 * hw)) } else if u < 4.0 * hw + 2.0 * hh { (hw - (u - 2.0 * hw - 2.0 * hh), hh) } else { (-hw, hh - (u - 4.0 * hw - 2.0 * hh)) }+        };+        // KiCad screen rotation: x' = x cos + y sin, y' = -x sin + y cos+        let (x, y) = (p.x + lx * c + ly * s, p.y - lx * s + ly * c);+        let (u, v) = crate::board::to_px(x, y);+        // best gap within +-0.12 mm radially, to tolerate fitting error+        let mut best = 0f32;+        for o in [-0.12, -0.06, 0.0, 0.06, 0.12] { let r = 1.0 + o / (hw.max(hh)); let (uu, vv) = crate::board::to_px(p.x + (x - p.x) * r, p.y + (y - p.y) * r); let _ = (u, v);+            if uu >= 0.0 && vv >= 0.0 && uu < gap.w as f64 && vv < gap.h as f64 { best = best.max(gap.sample(uu as f32, vv as f32)); } }+        if best >= strength { hit += 1; }+    }+    hit as f64 / n as f64+}++pub fn classify(share: f64) -> &'static str { if share >= 0.82 { "isolated" } else if share >= 0.25 { "thermal" } else { "solid" } }++struct Uf { p: Vec<usize> }+impl Uf { fn find(&mut self, x: usize) -> usize { let mut r = x; while self.p[r] != r { r = self.p[r]; } let mut y = x; while self.p[y] != r { let n = self.p[y]; self.p[y] = r; y = n; } r }+          fn join(&mut self, a: usize, b: usize) { let (a, b) = (self.find(a), self.find(b)); if a != b { self.p[a] = b; } } }++/// Measure every pad and via inside every pour on the photographed faces and join what connects.+/// `gaps` maps "F.Cu"/"B.Cu" to that face's gap map and edge strength.+pub fn extract(pads: &[Pad], copper: &Value, gaps: &HashMap<String, (Plane, f32)>, _g: &Grid) -> Value {+    let zones: Vec<Value> = copper["zones"].as_array().cloned().unwrap_or_default();+    let vias: Vec<Value> = copper["vias"].as_array().cloned().unwrap_or_default();+    let traces: Vec<Value> = copper["traces"].as_array().cloned().unwrap_or_default();+    // item index: pads, then zones, vias, traces+    let (np, nz, nv) = (pads.len(), zones.len(), vias.len());+    let mut uf = Uf { p: (0..np + nz + nv + traces.len()).collect() };+    // one pin = one item: pads with the same part and number (through-hole on both faces, split pads) join+    let mut by_pin: HashMap<String, usize> = HashMap::new();+    for (i, p) in pads.iter().enumerate() { if let Some(j) = by_pin.insert(format!("{}.{}", p.part, p.num), i) { uf.join(i, j); } }+    let mut meas = vec![];+    for (zi, z) in zones.iter().enumerate() {+        let layer = z["layer"].as_str().unwrap_or("");+        // only pours with a known net measure joins; a placeholder (unknown-net) pour joins nothing+        if z["net"].as_str().unwrap_or("").is_empty() || z["net"].as_str().unwrap_or("").starts_with("UNK") { continue; }+        let Some((gap, st)) = gaps.get(layer) else { continue };+        let poly: Vec<(f64, f64)> = z["polygon_mm"].as_array().map(|a| a.iter().map(pt).collect()).unwrap_or_default();+        if poly.len() < 3 { continue; }+        let clr = z["clearance_mm"].as_f64().unwrap_or(0.25);+        for (i, p) in pads.iter().enumerate() {+            // the ring test reads a clearance gap round plated holes; round an SMD pad the mask edge looks the+            // same whether the pad is joined or not, so SMD pads are not measured this way+            if p.drill <= 0.0 || !p.on(layer) || !inside(&poly, (p.x, p.y)) { continue; }+            let share = ring_share(gap, p, clr / 2.0 + 0.02, *st);+            let k = classify(share);+            if k != "isolated" { uf.join(i, np + zi); }+            meas.push(json!({"item": format!("{}.{}", p.part, p.num), "zone": z["id"], "layer": layer, "gapShare": (share * 100.0).round() / 100.0, "connection": k}));+        }+        for (vi, v) in vias.iter().enumerate() {+            let (x, y) = pt(&v["pos_mm"]); if !inside(&poly, (x, y)) { continue; }+            let d = v["diameter_mm"].as_f64().unwrap_or(0.6);+            let vp = Pad { part: "via".into(), num: vi.to_string(), x, y, w: d, h: d, rot: 0.0, round: true, drill: 0.3, layers: vec!["*.Cu".into()], net: String::new() };+            let share = ring_share(gap, &vp, clr / 2.0 + 0.02, *st);+            let k = classify(share);+            if k != "isolated" { uf.join(np + nz + vi, np + zi); }+            meas.push(json!({"item": format!("via:{}", v["id"].as_str().unwrap_or(&vi.to_string())), "zone": z["id"], "layer": layer, "gapShare": (share * 100.0).round() / 100.0, "connection": k}));+        }+    }+    // trace ends on pads and vias+    for (ti, t) in traces.iter().enumerate() {+        let path: Vec<(f64, f64)> = t["path_mm"].as_array().map(|a| a.iter().map(pt).collect()).unwrap_or_default();+        let layer = t["layer"].as_str().unwrap_or("");+        for e in [path.first(), path.last()].into_iter().flatten() {+            for (i, p) in pads.iter().enumerate() { if p.on(layer) && (e.0 - p.x).abs() <= p.w.max(p.h) / 2.0 + 0.15 && (e.1 - p.y).abs() <= p.w.max(p.h) / 2.0 + 0.15 { uf.join(np + nz + nv + ti, i); } }+            for (vi, v) in vias.iter().enumerate() { let (x, y) = pt(&v["pos_mm"]); if crate::geom::dist((x, y), *e) < 0.4 { uf.join(np + nz + nv + ti, np + nz + vi); } }+        }+    }+    // groups -> nets, named by the names the AI gave zones, traces and vias, else N$k+    let mut groups: HashMap<usize, Vec<usize>> = HashMap::new();+    for i in 0..uf.p.len() { let r = uf.find(i); groups.entry(r).or_default().push(i); }+    let mut nets = vec![]; let mut conflicts = vec![];+    let mut k = 0;+    for (_, items) in groups {+        let pins: Vec<String> = items.iter().filter(|i| **i < np).map(|i| format!("{}.{}", pads[*i].part, pads[*i].num)).collect::<std::collections::BTreeSet<_>>().into_iter().collect();+        if pins.len() < 2 { continue; }+        let mut names: Vec<String> = items.iter().filter_map(|i| {+            if *i >= np && *i < np + nz { zones[i - np]["net"].as_str() } else if *i >= np + nz && *i < np + nz + nv { vias[i - np - nz]["net"].as_str() } else if *i >= np + nz + nv { traces[i - np - nz - nv]["net"].as_str() } else { None }+        }).filter(|n| !n.is_empty() && !n.starts_with("N$")).map(str::to_string).collect();+        names.sort(); names.dedup();+        if names.len() > 1 { conflicts.push(json!({"names": names, "pins": pins})); }+        k += 1;+        let name = names.first().cloned().unwrap_or(format!("N${k}"));+        nets.push(json!({"name": name, "pins": pins, "tag": "SEEN", "evidence": ["evidence/connectivity-bottom.jpg"], "why": "joined by copper measured on the photo (pour clearance rings, trace ends, vias)"}));+    }+    json!({"nets": nets, "measurements": meas, "conflicts": conflicts})+}++/// Planes from vias: a via ringed on both faces with no trace touching it continues on an inner layer;+/// the nearest pins (within `reach` mm) say which net that layer carries.+pub fn plane_evidence(vias: &[Value], pads: &[Pad], pin_net: &HashMap<String, String>, reach: f64) -> Value {+    let mut by_net: HashMap<String, Vec<String>> = HashMap::new();+    let mut unknown = 0;+    for v in vias.iter().filter(|v| v["kind"].as_str() == Some("inner")) {+        let (x, y) = pt(&v["pos_mm"]);+        let near = pads.iter().filter(|p| crate::geom::dist((p.x, p.y), (x, y)) < reach)+            .min_by(|a, b| crate::geom::dist((a.x, a.y), (x, y)).partial_cmp(&crate::geom::dist((b.x, b.y), (x, y))).unwrap());+        match near.and_then(|p| pin_net.get(&format!("{}.{}", p.part, p.num))) {+            Some(n) => by_net.entry(n.clone()).or_default().push(v["id"].as_str().unwrap_or("").to_string()),+            None => unknown += 1,+        }+    }+    let mut v: Vec<(String, Vec<String>)> = by_net.into_iter().collect();+    v.sort_by(|a, b| b.1.len().cmp(&a.1.len()));+    json!({"byNet": v.iter().map(|(n, vs)| json!({"net": n, "vias": vs.len(), "ids": vs})).collect::<Vec<_>>(), "unattributed": unknown})+}
src/copper.rsadded+333
@@ -0,0 +1,333 @@+//! Copper from the photos. Copper under solder mask cannot be told from bare laminate by colour in a+//! single photo (measured on the Driver bottom, 2026-11-03: the two overlap in hue, saturation and+//! lightness once the lighting varies). What the photo does show reliably is the edges: a pour's+//! outline is a narrow dark clearance gap with a light ridge beside it. So the AI sketches each pour+//! or trace roughly, in board millimetres, on the gridded board image, and this module fits the sketch+//! to the photo: every edge moves to the strongest gap line within reach, corners are re-solved as edge+//! intersections, and the share of the outline that sits on a measured gap is reported as the evidence.+use crate::board::{to_mm, to_px, RES};+use crate::geom::{self, P};+use crate::raster::{self, Plane};+use image::{Rgb, RgbImage};+use serde_json::{json, Value};++/// Grey-level max filter with a square window of half-size r (separable).+fn max_filter(p: &Plane, r: usize, min: bool) -> Plane {+    let (w, h) = (p.w, p.h);+    let pick = |a: f32, b: f32| if min { a.min(b) } else { a.max(b) };+    let mut t = vec![0f32; w * h];+    for y in 0..h { for x in 0..w {+        let (a, b) = (x.saturating_sub(r), (x + r).min(w - 1));+        let mut m = p.d[y * w + a]; for k in a + 1..=b { m = pick(m, p.d[y * w + k]); } t[y * w + x] = m;+    } }+    let mut o = vec![0f32; w * h];+    for y in 0..h { for x in 0..w {+        let (a, b) = (y.saturating_sub(r), (y + r).min(h - 1));+        let mut m = t[a * w + x]; for k in a + 1..=b { m = pick(m, t[k * w + x]); } o[y * w + x] = m;+    } }+    Plane { w, h, d: o }+}++/// The gap map: a black-hat (closing minus image) of the blurred lightness, about 0.4 mm wide, so+/// narrow dark lines (clearance gaps, trace edges) stand out and broad areas do not.+pub fn gap_map(img: &RgbImage) -> Plane {+    let l = raster::blur(&raster::luma(img), 2, 3);+    let r = (0.2 * RES) as usize;+    let closed = max_filter(&max_filter(&l, r, false), r, true);+    Plane { w: l.w, h: l.h, d: closed.d.iter().zip(&l.d).map(|(c, v)| c - v).collect() }+}++/// The board image with a labelled millimetre grid every `step` mm (minor lines every mm), for sketching.+pub fn grid_image(img: &RgbImage, step: f64) -> RgbImage {+    let mut g = img.clone();+    let (w, h) = (g.width() as f64, g.height() as f64);+    let (x1, y1) = to_mm(w, h);+    let mut x = (crate::board::ORIGIN).ceil();+    while x <= x1 {+        let u = to_px(x, 0.0).0; let major = ((x - crate::board::ORIGIN) % step).abs() < 1e-6;+        if major { crate::draw::line(&mut g, (u, 0.0), (u, h), [255, 255, 255], 1); crate::draw::label(&mut g, (u + 3.0, 4.0), &format!("{}", x as i64), [255, 255, 0], 3); }+        else { for v in (0..h as i64).step_by(6) { crate::draw::px(&mut g, u as i64, v, [200, 200, 200]); } }+        x += 1.0;+    }+    let mut y = (crate::board::ORIGIN).ceil();+    while y <= y1 {+        let v = to_px(0.0, y).1; let major = ((y - crate::board::ORIGIN) % step).abs() < 1e-6;+        if major { crate::draw::line(&mut g, (0.0, v), (w, v), [255, 255, 255], 1); crate::draw::label(&mut g, (4.0, v + 3.0), &format!("{}", y as i64), [255, 255, 0], 3); }+        else { for u in (0..w as i64).step_by(6) { crate::draw::px(&mut g, u, v as i64, [200, 200, 200]); } }+        y += 1.0;+    }+    g+}++/// Fit one sketched polygon (board mm) to the gap map. Returns the fitted polygon (mm), the share of+/// sampled edge points that found a gap (0..1) and the largest corner move (mm).+pub fn snap_polygon(gap: &Plane, poly_mm: &[P], reach_mm: f64, strength: f32) -> (Vec<P>, f64, f64) { snap_polygon_in(gap, poly_mm, reach_mm, strength, None) }++/// As `snap_polygon`, with the board box: an edge lying along the board outline has no gap line to+/// find, so it is kept as sketched (clipped to the board later) and left out of the share.+pub fn snap_polygon_in(gap: &Plane, poly_mm: &[P], reach_mm: f64, strength: f32, board: Option<(f64, f64, f64, f64)>) -> (Vec<P>, f64, f64) {+    let n = poly_mm.len();+    let pts: Vec<P> = poly_mm.iter().map(|p| to_px(p.0, p.1)).collect();+    let reach = reach_mm * RES;+    let mut lines: Vec<Option<(P, P)>> = vec![]; // fitted edge as (point, unit direction)+    // which side of each edge is inside: the left normal points in for a counter-clockwise ring (y down)+    let signed: f64 = (0..n).map(|i| { let (a, b) = (pts[i], pts[(i + 1) % n]); a.0 * b.1 - b.0 * a.1 }).sum();+    let inward = if signed > 0.0 { 1.0 } else { -1.0 };+    let (mut hit, mut tried) = (0usize, 0usize);+    for i in 0..n {+        let (a, b) = (pts[i], pts[(i + 1) % n]);+        let len = geom::dist(a, b); if len < 1.0 { lines.push(None); continue; }+        if let Some((x0, y0, x1, y1)) = board {+            let on = |p: P| { let q = to_mm(p.0, p.1); (q.0 - x0).abs() < 0.6 || (q.0 - x1).abs() < 0.6 || (q.1 - y0).abs() < 0.6 || (q.1 - y1).abs() < 0.6 };+            let (am, bm) = (to_mm(a.0, a.1), to_mm(b.0, b.1));+            let same_side = ((am.0 - bm.0).abs() < 0.6 && ((am.0 - x0).abs() < 0.6 || (am.0 - x1).abs() < 0.6)) || ((am.1 - bm.1).abs() < 0.6 && ((am.1 - y0).abs() < 0.6 || (am.1 - y1).abs() < 0.6));+            if on(a) && on(b) && same_side { lines.push(None); continue; }+        }+        let d = ((b.0 - a.0) / len, (b.1 - a.1) / len); let nrm = (-d.1, d.0);+        let mut found: Vec<(f64, f64)> = vec![]; // (along, offset)+        let samples = ((len / (0.25 * RES)) as usize).max(4);+        for k in 1..samples { // skip the corners themselves+            let t = k as f64 / samples as f64 * len;+            let base = (a.0 + d.0 * t, a.1 + d.1 * t);+            // score each offset by the gap averaged along the edge (+-0.4 mm): a continuous line beats the+            // dimples of a via field; a small penalty per mm keeps the fit near the sketch on a tie+            let along = (0.4 * RES) as i64;+            let (mut best, mut bo, mut braw) = (f32::MIN, 0f64, 0f32);+            let mut o = -reach;+            while o <= reach {+                let (mut s, mut n) = (0f32, 0f32);+                for k in (-along..=along).step_by(2) {+                    let q = (base.0 + nrm.0 * o + d.0 * k as f64, base.1 + nrm.1 * o + d.1 * k as f64);+                    if q.0 >= 0.0 && q.1 >= 0.0 && q.0 < gap.w as f64 && q.1 < gap.h as f64 { s += gap.sample(q.0 as f32, q.1 as f32); n += 1.0; }+                }+                if n > 0.0 {+                    let g = s / n;+                    // a channel between two pours has two edges: the pour's own is on its inside, so an+                    // outward move costs three times an inward one+                    let outward = o * inward < 0.0;+                    let score = g - strength * (if outward { 0.45 } else { 0.15 }) * (o.abs() / RES) as f32;+                    if score > best { best = score; bo = o; braw = g; }+                }+                o += 1.0;+            }+            tried += 1;+            if braw >= strength * 0.7 { hit += 1; found.push((t, bo)); }+        }+        // a robust straight fit of offset against position along the edge+        let line = if found.len() >= 3 { geom::robust_line(&found, 0.06 * RES) } else { None };+        lines.push(line.map(|(m, c)| {+            let p0 = (a.0 + nrm.0 * c, a.1 + nrm.1 * c);+            let dir = (d.0 + nrm.0 * m, d.1 + nrm.1 * m); let l = (dir.0 * dir.0 + dir.1 * dir.1).sqrt();+            (p0, (dir.0 / l, dir.1 / l))+        }));+    }+    // corners: intersect neighbouring fitted edges; keep the sketch where an edge found nothing+    let mut out = vec![];+    let mut moved: f64 = 0.0;+    for i in 0..n {+        let prev = &lines[(i + n - 1) % n]; let cur = &lines[i];+        let p = match (prev, cur) {+            (Some((p1, d1)), Some((p2, d2))) => {+                let den = d1.0 * d2.1 - d1.1 * d2.0;+                if den.abs() < 0.05 { pts[i] } else { let t = ((p2.0 - p1.0) * d2.1 - (p2.1 - p1.1) * d2.0) / den; (p1.0 + d1.0 * t, p1.1 + d1.1 * t) }+            }+            (Some((p0, d0)), None) | (None, Some((p0, d0))) => { // project onto the one fitted edge+                let v = (pts[i].0 - p0.0, pts[i].1 - p0.1); let t = v.0 * d0.0 + v.1 * d0.1; (p0.0 + d0.0 * t, p0.1 + d0.1 * t)+            }+            _ => pts[i],+        };+        let p = if geom::dist(p, pts[i]) > 2.0 * reach { pts[i] } else { p };+        moved = moved.max(geom::dist(p, pts[i]) / RES);+        out.push(to_mm(p.0, p.1));+    }+    (out.iter().map(|p| ((p.0 * 1000.0).round() / 1000.0, (p.1 * 1000.0).round() / 1000.0)).collect(), if tried > 0 { hit as f64 / tried as f64 } else { 0.0 }, moved)+}++/// The gap strength that counts as an edge: a high percentile of the gap map over the board.+pub fn edge_strength(gap: &Plane) -> f32 {+    let mut v: Vec<f32> = gap.d.iter().step_by(7).copied().collect();+    v.sort_by(|a, b| a.partial_cmp(b).unwrap());+    v[(v.len() as f64 * 0.93) as usize]+}++/// Via candidates: small round features of the right size (difference of two blurs), reported with+/// whether a clearance ring surrounds them on this face (a dark annulus against the copper around it).+pub fn vias(img: &RgbImage, drill_mm: (f64, f64)) -> Vec<Value> {+    let l = raster::luma(img);+    let r0 = drill_mm.0 * RES / 2.0;+    let a = raster::blur(&l, (r0 * 0.5).max(1.0) as usize, 3);+    let b = raster::blur(&l, (r0 * 1.6) as usize, 3);+    let dog = Plane { w: l.w, h: l.h, d: a.d.iter().zip(&b.d).map(|(x, y)| (x - y).abs()).collect() };+    let mut out = vec![];+    let win = (r0 * 2.0) as i64;+    let mut vals: Vec<f32> = dog.d.iter().step_by(11).copied().collect(); vals.sort_by(|x, y| x.partial_cmp(y).unwrap());+    let thr = vals[(vals.len() as f64 * 0.985) as usize];+    for y in (win as usize..l.h - win as usize).step_by(2) { for x in (win as usize..l.w - win as usize).step_by(2) {+        let v = dog.at(x, y); if v < thr { continue; }+        let mut is_max = true;+        'n: for dy in -win..=win { for dx in -win..=win { if dx * dx + dy * dy > win * win || (dx == 0 && dy == 0) { continue; }+            if dog.at((x as i64 + dx) as usize, (y as i64 + dy) as usize) > v { is_max = false; break 'n; } } }+        if !is_max { continue; }+        // ring test: mean lightness of the annulus 1.3..2.2 r0 against 2.6..3.6 r0+        let ring = |r1: f64, r2: f64| { let (mut s, mut n) = (0f64, 0f64);+            let rr = r2.ceil() as i64;+            for dy in -rr..=rr { for dx in -rr..=rr { let d = ((dx * dx + dy * dy) as f64).sqrt(); if d < r1 || d > r2 { continue; }+                let (px, py) = (x as i64 + dx, y as i64 + dy); if px < 0 || py < 0 || px >= l.w as i64 || py >= l.h as i64 { continue; }+                s += l.at(px as usize, py as usize) as f64; n += 1.0; } }+            s / n.max(1.0) };+        let (inner, outer) = (ring(1.3 * r0, 2.2 * r0), ring(2.6 * r0, 3.6 * r0));+        let (mx, my) = to_mm(x as f64, y as f64);+        out.push(json!({"pos_mm": [(mx * 1000.0).round() / 1000.0, (my * 1000.0).round() / 1000.0], "response": v, "ringContrast": ((outer - inner) / outer.max(1.0) * 1000.0).round() / 1000.0}));+    } }+    out+}++pub fn draw_poly_mm(img: &mut RgbImage, p: &[P], c: [u8; 3], th: i64) {+    let px: Vec<P> = p.iter().map(|q| to_px(q.0, q.1)).collect();+    crate::draw::poly(img, &px, c, th);+}++pub fn gap_image(g: &Plane) -> RgbImage {+    let mut v: Vec<f32> = g.d.iter().step_by(7).copied().collect(); v.sort_by(|a, b| a.partial_cmp(b).unwrap());+    let hi = v[(v.len() as f64 * 0.99) as usize].max(1.0);+    RgbImage::from_fn(g.w as u32, g.h as u32, |x, y| { let t = (g.at(x as usize, y as usize) / hi * 255.0).clamp(0.0, 255.0) as u8; Rgb([t, t, t]) })+}++#[cfg(test)]+mod tests {+    use super::*;+    #[test]+    fn a_rough_sketch_snaps_to_the_drawn_pour() {+        // a light pour 20 x 12 mm with a 0.3 mm dark clearance gap around it, on a mid background+        let (w, h) = ((40.0 * RES) as u32, (30.0 * RES) as u32);+        let pour = [(105.0, 105.0), (125.0, 105.0), (125.0, 117.0), (105.0, 117.0)];+        let img = RgbImage::from_fn(w, h, |x, y| {+            let (mx, my) = to_mm(x as f64 + 0.5, y as f64 + 0.5);+            let inside = mx > 105.0 && mx < 125.0 && my > 105.0 && my < 117.0;+            let near = mx > 104.7 && mx < 125.3 && my > 104.7 && my < 117.3;+            if inside { Rgb([60, 120, 90]) } else if near { Rgb([20, 50, 35]) } else { Rgb([55, 110, 85]) }+        });+        let g = gap_map(&img);+        let sketch = [(105.6, 104.5), (124.4, 105.5), (125.5, 116.4), (104.6, 117.6)];+        let (fit, share, moved) = snap_polygon(&g, &sketch, 1.0, edge_strength(&g));+        for i in 0..4 { assert!(geom::dist(fit[i], (pour[i].0 + if i == 1 || i == 2 { 0.15 } else { -0.15 }, pour[i].1 + if i >= 2 { 0.15 } else { -0.15 })) < 0.2, "{i}: {:?}", fit[i]); }+        assert!(share > 0.8, "{share}"); assert!(moved < 1.5);+    }+}++/// Running min or max over a window of `len` pixels along one direction (dx, dy in {-1,0,1}), van Herk /+/// Gil-Werman: constant work per pixel whatever the window length.+pub fn line_filter(p: &Plane, dx: i64, dy: i64, len: usize, max: bool) -> Plane {+    let (w, h) = (p.w as i64, p.h as i64);+    let mut out = p.clone();+    let pick = |a: f32, b: f32| if max { a.max(b) } else { a.min(b) };+    let k = len.max(1); let half = (k / 2) as i64;+    // every line in this direction starts on the image border+    let mut starts = vec![];+    if dy == 0 { for y in 0..h { starts.push((0, y)); } }+    else if dx == 0 { for x in 0..w { starts.push((x, 0)); } }+    else if dx == 1 { for x in 0..w { starts.push((x, 0)); } for y in 1..h { starts.push((0, y)); } }+    else { for x in 0..w { starts.push((x, 0)); } for y in 1..h { starts.push((w - 1, y)); } }+    let mut vals: Vec<f32> = vec![]; let mut idx: Vec<usize> = vec![];+    for (sx, sy) in starts {+        vals.clear(); idx.clear();+        let (mut x, mut y) = (sx, sy);+        while x >= 0 && y >= 0 && x < w && y < h { let i = (y * w + x) as usize; vals.push(p.d[i]); idx.push(i); x += dx; y += dy; }+        let n = vals.len(); if n == 0 { continue; }+        let mut g = vec![0f32; n]; let mut hh = vec![0f32; n];+        for i in 0..n { g[i] = if i % k == 0 { vals[i] } else { pick(g[i - 1], vals[i]) }; }+        for i in (0..n).rev() { hh[i] = if i == n - 1 || (i + 1) % k == 0 { vals[i] } else { pick(hh[i + 1], vals[i]) }; }+        for i in 0..n {+            let lo = (i as i64 - half).max(0) as usize; let hi = ((i as i64 - half) + k as i64 - 1).min(n as i64 - 1) as usize;+            // window [lo, hi]: combine the suffix of lo's block and the prefix of hi's block+            let v = if lo / k == hi / k { let mut m = vals[lo]; for j in lo + 1..=hi { m = pick(m, vals[j]); } m } else { pick(hh[lo], g[hi]) };+            out.d[idx[i]] = v;+        }+    }+    out+}++fn bin_line(m: &crate::raster::Mask, dx: i64, dy: i64, len: usize, close: bool) -> crate::raster::Mask {+    let p = Plane { w: m.w, h: m.h, d: m.d.iter().map(|b| if *b { 1.0 } else { 0.0 }).collect() };+    let (a, b) = if close { (true, false) } else { (false, true) };+    let q = line_filter(&line_filter(&p, dx, dy, len, a), dx, dy, len, b);+    crate::raster::Mask { w: m.w, h: m.h, d: q.d.iter().map(|v| *v > 0.5).collect() }+}++/// Pour boundaries as a mask: the black-hat's long straight dark lines (0, 45, 90, 135 degrees: pours on+/// boards are drawn that way; round via dimples are not lines), each closed along its own direction to+/// bridge small breaks.+pub fn line_barrier(img: &RgbImage) -> crate::raster::Mask {+    // two features: thin dark gaps (black-hat) and the step from a pour to a wide bare channel (the+    // brightness gradient across each direction); each kept only where it runs straight for 1.6 mm,+    // then closed along its own direction over 1.2 mm breaks+    let g = gap_map(img);+    let l = raster::blur(&raster::luma(img), 2, 3);+    let (w, h) = (l.w, l.h);+    let at = |x: usize, y: usize| l.d[y.min(h - 1) * w + x.min(w - 1)];+    let mut gx = Plane::new(w, h, 0.0); let mut gy = Plane::new(w, h, 0.0);+    for y in 1..h - 1 { for x in 1..w - 1 {+        gx.d[y * w + x] = (at(x + 1, y - 1) + 2.0 * at(x + 1, y) + at(x + 1, y + 1)) - (at(x - 1, y - 1) + 2.0 * at(x - 1, y) + at(x - 1, y + 1));+        gy.d[y * w + x] = (at(x - 1, y + 1) + 2.0 * at(x, y + 1) + at(x + 1, y + 1)) - (at(x - 1, y - 1) + 2.0 * at(x, y - 1) + at(x + 1, y - 1));+    } }+    // (direction of the line, response across it)+    let resp = |k: usize| -> Plane { Plane { w, h, d: (0..w * h).map(|i| match k { 0 => gy.d[i].abs(), 1 => gx.d[i].abs(), 2 => (gx.d[i] - gy.d[i]).abs() / 1.414, _ => (gx.d[i] + gy.d[i]).abs() / 1.414 }).collect() } };+    let dirs = [(1i64, 0i64), (0, 1), (1, 1), (-1, 1)];+    let open_len = (1.6 * RES) as usize; let close_len = (1.2 * RES) as usize;+    let mut bar = crate::raster::Mask::new(w, h);+    for (k, (dx, dy)) in dirs.iter().enumerate() {+        for src in [resp(k), g.clone()] {+            let o = line_filter(&line_filter(&src, *dx, *dy, open_len, false), *dx, *dy, open_len, true);+            let mut s: Vec<f32> = o.d.iter().step_by(11).copied().collect(); s.sort_by(|a, b| a.partial_cmp(b).unwrap());+            let thr = s[(s.len() as f64 * 0.93) as usize].max(4.0);+            let b = crate::raster::Mask { w, h, d: o.d.iter().map(|v| *v > thr).collect() };+            bar = bar.or(&bin_line(&b, *dx, *dy, close_len, true));+        }+    }+    bar.dilate(1.5)+}++/// Tented vias: small bright domes (difference of two blurs, about 0.11 and 0.35 mm), local maxima+/// within 0.27 mm, above 8 x the median absolute deviation. Returns board-mm positions.+pub fn domes(img: &RgbImage) -> Vec<(f64, f64)> {+    let l = raster::luma(img);+    let a = raster::blur(&l, 4, 3); let b = raster::blur(&l, 14, 3);+    let d: Vec<f32> = a.d.iter().zip(&b.d).map(|(x, y)| x - y).collect();+    let mut s: Vec<f32> = d.iter().step_by(7).copied().collect(); s.sort_by(|x, y| x.partial_cmp(y).unwrap());+    let med = s[s.len() / 2];+    let mut dev: Vec<f32> = s.iter().map(|v| (v - med).abs()).collect(); dev.sort_by(|x, y| x.partial_cmp(y).unwrap());+    let thr = med + 8.0 * dev[dev.len() / 2];+    let dp = Plane { w: l.w, h: l.h, d };+    let r = (0.27 * RES) as usize;+    let mx = line_filter(&line_filter(&dp, 1, 0, 2 * r + 1, true), 0, 1, 2 * r + 1, true);+    let mut out = vec![];+    for y in r..l.h - r { for x in r..l.w - r { let i = y * l.w + x; if dp.d[i] > thr && dp.d[i] >= mx.d[i] { out.push(to_mm(x as f64 + 0.5, y as f64 + 0.5)); } } }+    out+}++/// Candidate pours: the regions the line barrier encloses, with outline, area and via density.+pub fn regions(img: &RgbImage, side: &str) -> (Vec<Value>, Vec<u32>, usize, usize) {+    let bar = line_barrier(img);+    let free = bar.not();+    // regions joined only through a neck under ~1 mm (a break in a boundary line) are split there+    let (lab, cs) = crate::prep::split_components(&free, (0.5 * RES) as f32);+    let ds = domes(img);+    let w = free.w;+    let mut dens = std::collections::HashMap::<u32, usize>::new();+    for (x, y) in &ds { let (u, v) = to_px(*x, *y); let (u, v) = (u as usize, v as usize); if u < w && v < free.h { *dens.entry(lab[v * w + u]).or_default() += 1; } }+    let l = raster::luma(img);+    let mut out = vec![];+    for c in cs.iter().filter(|c| c.area as f64 > 2.0 * RES * RES && (c.area as f64) < 0.6 * (free.w * free.h) as f64) {+        let ring = geom::simplify(&raster::trace_boundary(&lab, w, free.h, c), 2.5);+        if ring.len() < 3 { continue; }+        let mut sum = 0f64; for y in c.y0..=c.y1 { for x in c.x0..=c.x1 { if lab[y * w + x] == c.label { sum += l.at(x, y) as f64; } } }+        let area_mm = c.area as f64 / (RES * RES);+        let nd = dens.get(&c.label).copied().unwrap_or(0);+        let cm = to_mm(c.cx, c.cy);+        let poly: Vec<Value> = ring.iter().map(|p| { let m = to_mm(p.0, p.1); json!([(m.0 * 1000.0).round() / 1000.0, (m.1 * 1000.0).round() / 1000.0]) }).collect();+        out.push(json!({"id": format!("{side}:{}", c.label), "side": side, "area_mm2": (area_mm * 10.0).round() / 10.0, "centre_mm": [(cm.0 * 100.0).round() / 100.0, (cm.1 * 100.0).round() / 100.0],+                        "domes": nd, "domesPerMm2": ((nd as f64 / area_mm) * 1000.0).round() / 1000.0, "luma": (sum / c.area as f64 * 10.0).round() / 10.0,+                        "polygon_mm": poly}));+    }+    (out, lab, w, ds.len())+}
src/draw.rsadded+50
@@ -0,0 +1,50 @@+//! Overlays drawn onto evidence images, so every measurement can be checked by eye.+use image::{Rgb, RgbImage};++pub fn px(img: &mut RgbImage, x: i64, y: i64, c: [u8; 3]) {+    if x >= 0 && y >= 0 && (x as u32) < img.width() && (y as u32) < img.height() { img.put_pixel(x as u32, y as u32, Rgb(c)); }+}++pub fn line(img: &mut RgbImage, a: (f64, f64), b: (f64, f64), c: [u8; 3], th: i64) {+    let n = ((b.0 - a.0).abs().max((b.1 - a.1).abs()) as i64).max(1);+    for i in 0..=n {+        let t = i as f64 / n as f64;+        let (x, y) = ((a.0 + (b.0 - a.0) * t) as i64, (a.1 + (b.1 - a.1) * t) as i64);+        for dy in -th / 2..=th / 2 { for dx in -th / 2..=th / 2 { px(img, x + dx, y + dy, c); } }+    }+}++pub fn poly(img: &mut RgbImage, p: &[(f64, f64)], c: [u8; 3], th: i64) {+    for i in 0..p.len() { line(img, p[i], p[(i + 1) % p.len()], c, th); }+}++pub fn circle(img: &mut RgbImage, ctr: (f64, f64), r: f64, c: [u8; 3], th: i64) {+    let n = ((r * 6.3) as usize).max(16);+    let pts: Vec<(f64, f64)> = (0..n).map(|i| { let a = i as f64 / n as f64 * std::f64::consts::TAU; (ctr.0 + r * a.cos(), ctr.1 + r * a.sin()) }).collect();+    poly(img, &pts, c, th);+}++/// Tint the pixels of a mask.+pub fn tint(img: &mut RgbImage, m: &crate::raster::Mask, c: [u8; 3], a: f32) {+    for (i, p) in img.pixels_mut().enumerate() {+        if m.d[i] { for k in 0..3 { p[k] = (p[k] as f32 * (1.0 - a) + c[k] as f32 * a) as u8; } }+    }+}++/// A tiny 3x5 digit font for labels (ids are letters + digits; letters map to a block).+const DIG: [[u8; 5]; 10] = [[7, 5, 5, 5, 7], [2, 6, 2, 2, 7], [7, 1, 7, 4, 7], [7, 1, 7, 1, 7], [5, 5, 7, 1, 1], [7, 4, 7, 1, 7], [7, 4, 7, 5, 7], [7, 1, 1, 1, 1], [7, 5, 7, 5, 7], [7, 5, 7, 1, 7]];++pub fn label(img: &mut RgbImage, at: (f64, f64), text: &str, c: [u8; 3], scale: i64) {+    let (mut x, y) = (at.0 as i64, at.1 as i64);+    for ch in text.chars() {+        let g = ch.to_digit(10).map(|d| DIG[d as usize]).unwrap_or([7, 7, 7, 7, 7]);+        if ch.is_ascii_digit() || ch.is_ascii_alphabetic() {+            for (r, bits) in g.iter().enumerate() { for col in 0..3 {+                if bits >> (2 - col) & 1 == 1 && (ch.is_ascii_digit() || r == 0 || r == 4 || col == 0) {+                    for dy in 0..scale { for dx in 0..scale { px(img, x + col * scale + dx, y + r as i64 * scale + dy, c); } }+                }+            } }+        }+        x += 4 * scale;+    }+}
src/findings.rsadded+126
@@ -0,0 +1,126 @@+//! What each step found, read from its artifacts: a one-line summary (shown on the step's line in the+//! AI Flow timeline) and a table (shown in the flow window's Findings tab). Nothing here is+//! judgement; it lists what the files say, so the window always matches the record.+use crate::items::{self, read_json};+use serde_json::{json, Value};+use std::collections::BTreeMap;+use std::path::Path;++fn s(v: &Value, k: &str) -> String {+    match &v[k] { Value::String(x) => x.clone(), Value::Null => String::new(), Value::Array(a) => a.iter().map(|x| x.as_str().map(str::to_string).unwrap_or_else(|| x.to_string())).collect::<Vec<_>>().join(", "), x => x.to_string() }+}++fn table(cols: &[&str], rows: Vec<Vec<String>>) -> Value { json!({"cols": cols, "rows": rows}) }++fn tags(list: &[Value]) -> String {+    let mut m: BTreeMap<String, usize> = BTreeMap::new();+    for v in list { *m.entry(s(v, "tag").to_lowercase()).or_default() += 1; }+    items::TAGS.iter().filter_map(|k| m.get(&k.to_lowercase()).map(|n| format!("{n} {}", k.to_lowercase()))).collect::<Vec<_>>().join(", ")+}++fn arr(d: &Value, k: &str) -> Vec<Value> { d[k].as_array().cloned().unwrap_or_default() }++/// {step: {summary, tables: [{title, cols, rows}]}} for every step with something to show.+pub fn all(flow: &Path) -> Value {+    let mut out = serde_json::Map::new();+    let mut put = |step: &str, summary: String, tables: Vec<(String, Value)>| {+        let t: Vec<Value> = tables.into_iter().map(|(title, mut v)| { v["title"] = json!(title); v }).collect();+        out.insert(step.into(), json!({"summary": summary, "tables": t}));+    };++    if let Ok(d) = read_json(&flow.join("photos.json")) {+        let p = arr(&d, "photos");+        let needs = p.iter().filter(|x| !s(x, "needs").is_empty()).count();+        put("intake", format!("{} photos described{}", p.len(), if needs > 0 { format!(", {needs} with better shots requested") } else { String::new() }),+            vec![("Photos".into(), table(&["Photo", "Side", "Covers", "Quality", "Needs"], p.iter().map(|x| vec![s(x, "file"), s(x, "side"), s(x, "covers"), s(x, "quality"), s(x, "needs")]).collect()))]);+    }+    if let Ok(d) = read_json(&flow.join("prep.json")) {+        let ph = arr(&d, "photos");+        let rows = ph.iter().map(|x| vec![s(x, "file"), s(x, "side"), format!("{:.0}", x["sharpness"].as_f64().unwrap_or(0.0)), x["holes"].as_array().map(|a| a.len()).unwrap_or(0).to_string(), x["rows"].as_array().map(|a| a.len()).unwrap_or(0).to_string(), x["bodies"].as_array().map(|a| a.len()).unwrap_or(0).to_string()]).collect();+        put("intake", format!("{} photos measured; bottom turned over {} ({} holes agree)", ph.len(), if s(&d["flip"], "axis") == "y" { "top-to-bottom" } else { "left-to-right" }, s(&d["flip"], "matches")),+            vec![("Photos".into(), table(&["Photo", "Side", "Sharpness", "Holes", "Hole rows", "Bodies"], rows))]);+    }+    if let Ok(d) = read_json(&flow.join("scale.json")).map(|d| if d["pxPerMm"].is_number() { d } else { Value::Null }) { if d.is_object() {+        put("scale", format!("{} px/mm; board {} x {} mm, corners {} mm; bottom aligned on {} holes ({} mm)", s(&d, "pxPerMm"), d["board_mm"][0], d["board_mm"][1], s(&d, "corner_mm"), s(&d["alignment"], "pairs"), s(&d["alignment"], "residual_mm")),+            vec![("Scale".into(), table(&["Method", "Body", "Body says", "Source"], vec![vec![s(&d, "method"), s(&d["body"], "id"), s(&d["body"], "pxPerMm"), s(&d["body"], "source")]]))]);+    } }+    if let Ok(d) = read_json(&flow.join("scale.json")).map(|d| if d["pxPerMm"].is_number() { Value::Null } else { d }) { if d.is_object() {+        let ph = d["photos"].as_object().cloned().unwrap_or_default();+        let rows: Vec<Vec<String>> = ph.iter().map(|(k, v)| vec![k.clone(), s(v, "pxPerMm"), format!("{:.1}%", v["spread"].as_f64().unwrap_or(0.0) * 100.0), v["refs"].as_array().map(|a| a.iter().map(|r| s(r, "what")).collect::<Vec<_>>().join("; ")).unwrap_or_default()]).collect();+        let sum = ph.iter().map(|(k, v)| format!("{k} {} px/mm", s(v, "pxPerMm"))).collect::<Vec<_>>().join(", ");+        put("scale", sum, vec![("Scale per photo".into(), table(&["Photo", "px/mm", "Spread", "References"], rows))]);+    } }+    if let Ok(d) = read_json(&flow.join("parts.json")) {+        let p = arr(&d, "parts");+        let open = items::items_of("parts.json", &d).iter().filter(|i| i.open()).count();+        let rows = p.iter().map(|x| vec![s(x, "ref"), s(x, "value"), s(x, "package"), s(x, "marking"), s(x, "function"), s(x, "tag"), s(&x["pin1"], "mark"), s(x, "tests")]).collect();+        put("parts", format!("{} parts: {}; {open} need a test", p.len(), tags(&p)),+            vec![("Parts".into(), table(&["Ref", "Value", "Package", "Marking", "Function", "Tag", "Pin 1", "Tests"], rows))]);+    }+    if let Ok(d) = read_json(&flow.join("blocks.json")) {+        let b = arr(&d, "blocks");+        let rows = b.iter().map(|x| vec![s(x, "name"), x["parts"].as_array().map(|a| a.len()).unwrap_or(0).to_string(), s(x, "function"), s(x, "tag")]).collect();+        put("blocks", format!("{} blocks: {}", b.len(), b.iter().map(|x| s(x, "name")).collect::<Vec<_>>().join(", ")),+            vec![("Blocks".into(), table(&["Block", "Parts", "Function", "Tag"], rows))]);+    }+    if let Ok(d) = read_json(&flow.join("copper.json")) {+        let (z, t, v) = (arr(&d, "zones"), arr(&d, "traces"), arr(&d, "vias"));+        let mut kinds: BTreeMap<String, usize> = BTreeMap::new();+        for x in &v { *kinds.entry(s(x, "kind")).or_default() += 1; }+        let kinds_s = kinds.iter().map(|(k, n)| format!("{n} {k}")).collect::<Vec<_>>().join(", ");+        put("copper", format!("{} zones, {} traces, {} vias ({kinds_s})", z.len(), t.len(), v.len()), vec![+            ("Zones".into(), table(&["Zone", "Net", "Layer", "Tag"], z.iter().map(|x| vec![s(x, "id"), s(x, "net"), s(x, "layer"), s(x, "tag")]).collect())),+            ("Vias by kind".into(), table(&["Kind", "Count"], kinds.iter().map(|(k, n)| vec![k.clone(), n.to_string()]).collect())),+            ("Traces".into(), table(&["Trace", "Net", "Layer", "Width mm", "Tag"], t.iter().map(|x| vec![s(x, "id"), s(x, "net"), s(x, "layer"), s(x, "width_mm"), s(x, "tag")]).collect())),+        ]);+    }+    if let Ok(d) = read_json(&flow.join("checks/mimic.ok")) {+        let m = &d["summary"];+        put("mimic", format!("{} footprints on the board, {} zones, matched to parts.json", s(m, "footprints"), s(m, "zones")),+            vec![("Board".into(), table(&["File", "sha256"], vec![vec![s(m, "pcb"), s(m, "pcbSha256").chars().take(16).collect()]]))]);+    }+    if let Ok(rd) = std::fs::read_dir(flow.join("snapshots")) {+        let mut rows = vec![];+        for e in rd.flatten() {+            if let Ok(m) = read_json(&e.path().join("manifest.json")) { rows.push(vec![s(&m, "name"), m["files"].as_object().map(|o| o.len()).unwrap_or(0).to_string(), s(&m, "at"), s(&m, "source")]); }+        }+        if !rows.is_empty() { put("snapshot", format!("{} snapshot(s) saved", rows.len()), vec![("Snapshots".into(), table(&["Name", "Files", "At", "From"], rows))]); }+    }+    let nets = read_json(&flow.join("nets.json")).ok();+    let layers = read_json(&flow.join("layers.json")).ok();+    if nets.is_some() || layers.is_some() {+        let mut tabs = vec![];+        let mut sum = vec![];+        if let Some(l) = &layers {+            sum.push(format!("{} layers proposed", s(l, "proposed")));+            tabs.push(("Layer candidates".into(), table(&["Layers", "Open", "DRC errors", "Run"], arr(l, "candidates").iter().map(|c| vec![s(c, "layers"), s(c, "open"), s(c, "drcErrors"), s(c, "run")]).collect())));+            tabs.push(("Planes (inferred)".into(), table(&["Layer", "Net", "Tag", "Why"], arr(l, "planes").iter().map(|p| vec![s(p, "layer"), s(p, "net"), s(p, "tag"), s(p, "why")]).collect())));+        }+        if let Some(n) = &nets {+            let list = arr(n, "nets");+            sum.push(format!("{} nets ({})", list.len(), tags(&list)));+            tabs.push(("Nets".into(), table(&["Net", "Pins", "Tag", "Tests", "Why"], list.iter().map(|x| vec![s(x, "name"), x["pins"].as_array().map(|a| a.len()).unwrap_or(0).to_string(), s(x, "tag"), s(x, "tests"), s(x, "why")]).collect())));+        }+        put("connect", sum.join(", "), tabs);+    }+    if let Ok(r) = read_json(&flow.join("review.json")) {+        let p = arr(&r, "passes");+        let last = p.last().cloned().unwrap_or(json!({}));+        let conf = crate::checks::CATEGORIES.iter().map(|k| vec![k.to_string(), s(&last["confidence"][*k], "level"), s(&last["confidence"][*k], "why")]).collect();+        let lows: Vec<String> = crate::checks::CATEGORIES.iter().filter(|k| s(&last["confidence"][**k], "level") == "Low").map(|k| k.to_string()).collect();+        put("review", format!("{} pass(es), last made {} change(s){}", p.len(), s(&last, "changes"), if lows.is_empty() { String::new() } else { format!("; low confidence: {}", lows.join(", ")) }), vec![+            ("Confidence (last pass)".into(), table(&["Category", "Level", "Why"], conf)),+            ("Passes".into(), table(&["Pass", "Changes", "Note", "At"], p.iter().map(|x| vec![s(x, "n"), s(x, "changes"), s(x, "note"), s(x, "at")]).collect())),+        ]);+    }+    if let Ok(t) = read_json(&flow.join("tests.json")) {+        let list = arr(&t, "tests");+        let open = items::all(flow).iter().filter(|i| i.open()).count();+        put("tests", format!("{} tests settle {open} open item(s)", list.len()),+            vec![("Tests".into(), table(&["Test", "Settles", "Method", "Equipment", "Expected", "If not"], list.iter().map(|x| vec![s(x, "id"), s(x, "settles"), s(x, "method"), s(x, "equipment"), s(x, "expected"), s(x, "ifNot")]).collect()))]);+    }+    if flow.join("report.md").is_file() {+        put("report", "report.md and report-tables.md written".into(), vec![]);+    }+    Value::Object(out)+}
src/flow.rsadded+68
@@ -0,0 +1,68 @@+//! The flow file (flows/flow.json, compiled in): the ordered steps, the artifacts that prove each one+//! is done, and what the AI must do in it. `next` names the first step whose artifacts are missing.+//! The binary does the deterministic parts; the AI does the thinking the `do` text asks for.+use serde::Deserialize;+use std::path::Path;++#[derive(Deserialize, Clone)]+pub struct Step { pub name: String, pub done: Vec<String>, #[serde(rename = "do")] pub todo: String }++#[derive(Deserialize)]+#[allow(dead_code)]+pub struct Flow { pub name: String, pub description: String, pub steps: Vec<Step> }++pub fn load() -> Flow { serde_json::from_str(include_str!("../flows/flow.json")).expect("flows/flow.json is valid") }++/// The files a passed check read that changed since (empty when the check is fresh).+pub fn stale(flow_dir: &Path, pat: &str) -> Vec<String> {+    let Ok(t) = std::fs::read_to_string(flow_dir.join(pat)) else { return vec![] };+    let v: serde_json::Value = serde_json::from_str(&t).unwrap_or_default();+    v["files"].as_object().into_iter().flatten()+        .filter(|(f, h)| h.as_str() != Some(crate::sha256(&flow_dir.join(f.as_str())).as_str()))+        .map(|(f, _)| f.clone()).collect()+}++/// One artifact pattern, relative to the flow folder; `*` matches within the last path segment.+/// A `checks/<name>.ok` counts only while every file that check read is unchanged.+pub fn exists(flow_dir: &Path, pat: &str) -> bool {+    if pat.starts_with("checks/") && pat.ends_with(".ok") {+        return flow_dir.join(pat).is_file() && stale(flow_dir, pat).is_empty();+    }+    let full = flow_dir.join(pat);+    let name = full.file_name().and_then(|n| n.to_str()).unwrap_or("");+    if !name.contains('*') { return full.exists(); }+    let (pre, post) = name.split_once('*').unwrap_or((name, ""));+    let dir = full.parent().unwrap_or(flow_dir);+    std::fs::read_dir(dir).map(|rd| rd.flatten().any(|e| {+        let n = e.file_name().to_string_lossy().to_string();+        n.starts_with(pre) && n.ends_with(post) && n.len() >= pre.len() + post.len()+    })).unwrap_or(false)+}++pub fn current<'a>(flow: &'a Flow, flow_dir: &Path) -> Option<&'a Step> {+    flow.steps.iter().find(|s| !s.done.iter().all(|p| exists(flow_dir, p)))+}++#[cfg(test)]+mod tests {+    use super::*;++    #[test]+    fn current_step_is_the_first_with_missing_artifacts() {+        let d = std::env::temp_dir().join(format!("flow-test-{}", std::process::id()));+        std::fs::create_dir_all(d.join("checks")).unwrap();+        std::fs::write(d.join("state.json"), "{}").unwrap();+        let f = load();+        assert_eq!(current(&f, &d).map(|s| s.name.as_str()), Some("intake"));+        std::fs::write(d.join("state.json"), "{}").unwrap();+        std::fs::write(d.join("prep.json"), "{}").unwrap();+        let h = crate::sha256(&d.join("prep.json"));+        std::fs::write(d.join("checks/prep.ok"), format!("{{\"files\": {{\"prep.json\": \"{h}\"}}}}")).unwrap();+        assert_eq!(current(&f, &d).map(|s| s.name.as_str()), Some("scale"));+        // editing the artifact after its check makes the check stale and the step current again+        std::fs::write(d.join("prep.json"), "{\"photos\": []}").unwrap();+        assert_eq!(stale(&d, "checks/prep.ok"), vec!["prep.json".to_string()]);+        assert_eq!(current(&f, &d).map(|s| s.name.as_str()), Some("intake"));+        std::fs::remove_dir_all(&d).ok();+    }+}
src/geom.rsadded+191
@@ -0,0 +1,191 @@+//! Geometry: robust line fits, the photo-to-board homography, convex hulls, minimum-area rectangles and+//! polygon simplification.++pub type P = (f64, f64);++/// Fit `u = a * t + b` to (t, u) pairs, dropping outliers (residual over 3 x the median absolute+/// residual, floor `min_tol`) and refitting. Used for board edges, where a connector can hang over.+pub fn robust_line(pts: &[(f64, f64)], min_tol: f64) -> Option<(f64, f64)> {+    let fit = |p: &[(f64, f64)]| -> Option<(f64, f64)> {+        let n = p.len() as f64; if n < 2.0 { return None; }+        let (st, su) = p.iter().fold((0.0, 0.0), |a, q| (a.0 + q.0, a.1 + q.1));+        let (mt, mu) = (st / n, su / n);+        let (mut sxx, mut sxy) = (0.0, 0.0);+        for q in p { sxx += (q.0 - mt) * (q.0 - mt); sxy += (q.0 - mt) * (q.1 - mu); }+        if sxx < 1e-12 { return None; }+        let a = sxy / sxx; Some((a, mu - a * mt))+    };+    // consensus first (RANSAC over deterministic pairs): the edge with the most points within tolerance,+    // so a long overhanging connector cannot drag the fit; then refine on those inliers+    let mut cur: Vec<(f64, f64)> = pts.to_vec();+    if pts.len() >= 10 {+        let mut seed = 0x9e3779b97f4a7c15u64;+        let mut rnd = |n: usize| { seed ^= seed << 13; seed ^= seed >> 7; seed ^= seed << 17; (seed % n as u64) as usize };+        let mut best: (usize, f64, f64) = (0, 0.0, 0.0);+        for _ in 0..400 {+            let (i, j) = (rnd(pts.len()), rnd(pts.len()));+            let (p, q) = (pts[i], pts[j]);+            if (q.0 - p.0).abs() < 1e-9 || (q.0 - p.0).abs() < (pts.len() as f64).sqrt() { continue; }+            let a = (q.1 - p.1) / (q.0 - p.0); let b = p.1 - a * p.0;+            let n = pts.iter().filter(|r| (r.1 - (a * r.0 + b)).abs() <= min_tol).count();+            if n > best.0 { best = (n, a, b); }+        }+        if best.0 >= 2 { cur = pts.iter().filter(|r| (r.1 - (best.1 * r.0 + best.2)).abs() <= min_tol * 1.5).copied().collect(); }+    }+    let mut line = fit(&cur)?;+    for _ in 0..6 {+        let mut res: Vec<f64> = cur.iter().map(|q| (q.1 - (line.0 * q.0 + line.1)).abs()).collect();+        let mut s = res.clone(); s.sort_by(|a, b| a.partial_cmp(b).unwrap());+        let mad = s[s.len() / 2];+        let tol = (3.0 * mad).max(min_tol);+        let keep: Vec<(f64, f64)> = cur.iter().zip(res.drain(..)).filter(|(_, r)| *r <= tol).map(|(q, _)| *q).collect();+        if keep.len() == cur.len() || keep.len() < 2 { break; }+        cur = keep; line = fit(&cur)?;+    }+    Some(line)+}++/// 3x3 homography (row-major, h[8] = 1) mapping src[i] -> dst[i] for four point pairs.+pub fn homography(src: &[P; 4], dst: &[P; 4]) -> Option<[f64; 9]> {+    let mut a = [[0f64; 9]; 8];+    for i in 0..4 {+        let ((x, y), (u, v)) = (src[i], dst[i]);+        a[2 * i] = [x, y, 1.0, 0.0, 0.0, 0.0, -u * x, -u * y, u];+        a[2 * i + 1] = [0.0, 0.0, 0.0, x, y, 1.0, -v * x, -v * y, v];+    }+    // Gaussian elimination with partial pivoting on the 8x8 system+    for c in 0..8 {+        let p = (c..8).max_by(|i, j| a[*i][c].abs().partial_cmp(&a[*j][c].abs()).unwrap())?;+        if a[p][c].abs() < 1e-12 { return None; }+        a.swap(c, p);+        for r in 0..8 {+            if r == c { continue; }+            let f = a[r][c] / a[c][c];+            for k in c..9 { a[r][k] -= f * a[c][k]; }+        }+    }+    let mut h = [0f64; 9];+    for i in 0..8 { h[i] = a[i][8] / a[i][i]; }+    h[8] = 1.0;+    Some(h)+}++pub fn apply(h: &[f64; 9], p: P) -> P {+    let w = h[6] * p.0 + h[7] * p.1 + h[8];+    ((h[0] * p.0 + h[1] * p.1 + h[2]) / w, (h[3] * p.0 + h[4] * p.1 + h[5]) / w)+}++pub fn invert(h: &[f64; 9]) -> Option<[f64; 9]> {+    let m = h;+    let det = m[0] * (m[4] * m[8] - m[5] * m[7]) - m[1] * (m[3] * m[8] - m[5] * m[6]) + m[2] * (m[3] * m[7] - m[4] * m[6]);+    if det.abs() < 1e-15 { return None; }+    let inv = [+        (m[4] * m[8] - m[5] * m[7]) / det, (m[2] * m[7] - m[1] * m[8]) / det, (m[1] * m[5] - m[2] * m[4]) / det,+        (m[5] * m[6] - m[3] * m[8]) / det, (m[0] * m[8] - m[2] * m[6]) / det, (m[2] * m[3] - m[0] * m[5]) / det,+        (m[3] * m[7] - m[4] * m[6]) / det, (m[1] * m[6] - m[0] * m[7]) / det, (m[0] * m[4] - m[1] * m[3]) / det,+    ];+    Some(inv.map(|v| v / inv[8]))+}++/// Intersection of x = a1*y + b1 (a near-vertical edge) and y = a2*x + b2 (a near-horizontal edge).+pub fn cross_vh(v: (f64, f64), hz: (f64, f64)) -> P {+    // x = a1*(a2*x + b2) + b1  =>  x (1 - a1 a2) = a1 b2 + b1+    let x = (v.0 * hz.1 + v.1) / (1.0 - v.0 * hz.0);+    (x, hz.0 * x + hz.1)+}++pub fn dist(a: P, b: P) -> f64 { ((a.0 - b.0).powi(2) + (a.1 - b.1).powi(2)).sqrt() }++/// Convex hull (monotone chain), counter-clockwise in y-down coordinates' mirror; order is consistent.+pub fn hull(pts: &[P]) -> Vec<P> {+    let mut p: Vec<P> = pts.to_vec();+    p.sort_by(|a, b| a.partial_cmp(b).unwrap());+    p.dedup();+    if p.len() < 3 { return p; }+    let cross = |o: P, a: P, b: P| (a.0 - o.0) * (b.1 - o.1) - (a.1 - o.1) * (b.0 - o.0);+    let mut lo: Vec<P> = vec![];+    for q in &p { while lo.len() >= 2 && cross(lo[lo.len() - 2], lo[lo.len() - 1], *q) <= 0.0 { lo.pop(); } lo.push(*q); }+    let mut up: Vec<P> = vec![];+    for q in p.iter().rev() { while up.len() >= 2 && cross(up[up.len() - 2], up[up.len() - 1], *q) <= 0.0 { up.pop(); } up.push(*q); }+    lo.pop(); up.pop(); lo.extend(up); lo+}++/// Minimum-area enclosing rectangle: (centre, long side, short side, angle of the long side in degrees, 0..180).+pub fn min_rect(pts: &[P]) -> (P, f64, f64, f64) {+    let h = hull(pts);+    if h.len() < 3 { let c = h.first().copied().unwrap_or((0.0, 0.0)); return (c, 0.0, 0.0, 0.0); }+    let mut best = (f64::MAX, (0.0, 0.0), 0.0, 0.0, 0.0);+    for i in 0..h.len() {+        let (a, b) = (h[i], h[(i + 1) % h.len()]);+        let ang = (b.1 - a.1).atan2(b.0 - a.0);+        let (c, s) = (ang.cos(), ang.sin());+        let (mut x0, mut x1, mut y0, mut y1) = (f64::MAX, f64::MIN, f64::MAX, f64::MIN);+        for q in &h { let u = q.0 * c + q.1 * s; let v = -q.0 * s + q.1 * c; x0 = x0.min(u); x1 = x1.max(u); y0 = y0.min(v); y1 = y1.max(v); }+        let area = (x1 - x0) * (y1 - y0);+        if area < best.0 {+            let (cu, cv) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0);+            let centre = (cu * c - cv * s, cu * s + cv * c);+            let (w, hh) = (x1 - x0, y1 - y0);+            let (long, short, a) = if w >= hh { (w, hh, ang) } else { (hh, w, ang + std::f64::consts::FRAC_PI_2) };+            best = (area, centre, long, short, a.to_degrees().rem_euclid(180.0));+        }+    }+    (best.1, best.2, best.3, best.4)+}++/// Douglas-Peucker simplification of a closed ring.+pub fn simplify(ring: &[P], tol: f64) -> Vec<P> {+    if ring.len() < 4 { return ring.to_vec(); }+    fn rec(p: &[P], tol: f64, out: &mut Vec<P>) {+        let (a, b) = (p[0], p[p.len() - 1]);+        let (mut idx, mut dmax) = (0, 0.0);+        let l = dist(a, b).max(1e-9);+        for (i, q) in p.iter().enumerate().take(p.len() - 1).skip(1) {+            let d = ((b.0 - a.0) * (a.1 - q.1) - (a.0 - q.0) * (b.1 - a.1)).abs() / l;+            if d > dmax { dmax = d; idx = i; }+        }+        if dmax > tol { rec(&p[..=idx], tol, out); out.pop(); rec(&p[idx..], tol, out); } else { out.push(a); out.push(b); }+    }+    // split the ring at its farthest point from the start so both halves are open chains+    let far = (1..ring.len()).max_by(|i, j| dist(ring[0], ring[*i]).partial_cmp(&dist(ring[0], ring[*j])).unwrap()).unwrap();+    let mut a = vec![]; rec(&ring[..=far], tol, &mut a);+    let mut second: Vec<P> = ring[far..].to_vec(); second.push(ring[0]);+    let mut b = vec![]; rec(&second, tol, &mut b);+    a.pop(); b.pop(); a.extend(b); a+}++pub fn polygon_area(p: &[P]) -> f64 {+    let mut s = 0.0;+    for i in 0..p.len() { let (a, b) = (p[i], p[(i + 1) % p.len()]); s += a.0 * b.1 - b.0 * a.1; }+    s.abs() / 2.0+}++#[cfg(test)]+mod tests {+    use super::*;+    #[test]+    fn homography_round_trip() {+        let src = [(10.0, 12.0), (410.0, 5.0), (420.0, 330.0), (3.0, 320.0)];+        let dst = [(0.0, 0.0), (400.0, 0.0), (400.0, 320.0), (0.0, 320.0)];+        let h = homography(&src, &dst).unwrap();+        for i in 0..4 { let q = apply(&h, src[i]); assert!(dist(q, dst[i]) < 1e-6, "{q:?}"); }+        let hi = invert(&h).unwrap();+        assert!(dist(apply(&hi, (200.0, 160.0)), apply(&hi, apply(&h, apply(&hi, (200.0, 160.0))))) < 1e-6);+    }+    #[test]+    fn rect_and_line() {+        // a 30 x 10 rectangle turned 30 degrees+        let (c, s) = (30f64.to_radians().cos(), 30f64.to_radians().sin());+        let mut pts = vec![];+        for i in 0..=30 { for j in 0..=10 { let (x, y) = (i as f64 - 15.0, j as f64 - 5.0); pts.push((50.0 + x * c - y * s, 40.0 + x * s + y * c)); } }+        let (ctr, l, w, a) = min_rect(&pts);+        assert!((l - 30.0).abs() < 0.01 && (w - 10.0).abs() < 0.01 && (a - 30.0).abs() < 0.01 && dist(ctr, (50.0, 40.0)) < 0.01);+        let mut line: Vec<(f64, f64)> = (0..50).map(|t| (t as f64, 2.0 * t as f64 + 1.0)).collect();+        line.push((25.0, 500.0)); // an overhanging connector+        let (a, b) = robust_line(&line, 0.5).unwrap();+        assert!((a - 2.0).abs() < 1e-6 && (b - 1.0).abs() < 1e-6);+        let sq = vec![(0.0, 0.0), (5.0, 0.01), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)];+        assert_eq!(simplify(&sq, 0.1).len(), 4);+        assert_eq!(polygon_area(&simplify(&sq, 0.1)), 100.0);+    }+}
src/islands.rsadded+429
@@ -0,0 +1,429 @@+//! From traced islands to board objects and measured connectivity. Each island becomes a pour (a zone with its+//! photographed outline and exact copper) or a track (a centre line with a measured width). A pad joins an island+//! when the copper around it is that island and not a clearance gap. A via seen on both faces, or on one face+//! with the other hidden by a part, joins the island it sits in on each face. An island takes the net of the+//! pins it touches, directly; through vias then carry nets to islands that have none. Two different nets+//! meeting at a via are reported and not merged, since a leak in the photo must not short two nets. A via+//! whose own small ring is isolated on both faces leads nowhere visible: it is direct evidence of an inner layer.+use crate::board;+use crate::kicad_out::Pad;+use crate::raster::{self, Mask};+use crate::trace::Traced;+use serde_json::{json, Value};+use std::collections::{BTreeSet, HashMap};++const MM2: f64 = board::RES * board::RES;+/// Above this share a ring around a pad is a clearance gap: measured on the Driver photos' plated pads.+const GAP_CLOSED: f64 = 0.75;++pub struct Face<'a> { pub side: &'a str, pub layer: &'a str, pub t: Traced }++/// Small islands enclosed by one larger island are dimples in its surface, unless a dark ring isolates them.+pub fn absorb_dimples(t: &mut Traced) -> usize {+    let (w, h) = (t.w, t.h);+    let small: HashMap<u32, usize> = t.regions.iter().filter(|r| (r.area as f64) < 1.2 * MM2).map(|r| (r.id, r.area)).collect();+    let mut seen: HashMap<u32, (HashMap<u32, u32>, u32)> = HashMap::new();+    let step = 6isize;+    let offs = [step, -step, step * w as isize, -step * w as isize, step + step * w as isize, step - step * w as isize, -step + step * w as isize, -step - step * w as isize];+    for y in 7..h - 7 { for x in 7..w - 7 { let i = y * w + x; let a = t.labels[i]; if a == 0 || !small.contains_key(&a) { continue; }+        for o in offs { let j = (i as isize + o) as usize; let b = t.labels[j]; if b == a { continue; }+            let e = seen.entry(a).or_default();+            if b != 0 { *e.0.entry(b).or_default() += 1; } else if t.dark.d[j] { e.1 += 1; } } } }+    let mut map: HashMap<u32, u32> = HashMap::new();+    for (a, (votes, dark)) in &seen {+        let Some((b, c)) = votes.iter().max_by_key(|x| *x.1) else { continue };+        let total: u32 = votes.values().sum::<u32>() + dark;+        if *c as f64 >= 0.6 * total as f64 && t.regions.iter().any(|r| r.id == *b && r.area > small[a] * 3) { map.insert(*a, *b); }+    }+    let n = map.len();+    for l in t.labels.iter_mut() { if let Some(b) = map.get(l) { *l = *b; } }+    // chains: an absorbed island's target may itself have been absorbed+    for _ in 0..3 { let mut any = false; for l in t.labels.iter_mut() { if let Some(b) = map.get(l) { *l = *b; any = true; } } if !any { break; } }+    t.regions = crate::trace::measure_regions(&mut t.labels, &t.ratio, &t.other, w, h);+    n+}++fn ring(p: &Pad, d: f64, n: usize) -> Vec<(f64, f64)> {+    let (hw, hh) = (p.w / 2.0 + d, p.h / 2.0 + d);+    let (c, s) = (p.rot.to_radians().cos(), p.rot.to_radians().sin());+    (0..n).map(|k| { let t = k as f64 / n as f64;+        let (lx, ly) = if p.round { let a = t * std::f64::consts::TAU; (hw * a.cos(), hh * a.sin()) } else {+            let per = 4.0 * (hw + hh); let u = t * per;+            if u < 2.0 * hw { (-hw + u, -hh) } else if u < 2.0 * hw + 2.0 * hh { (hw, -hh + (u - 2.0 * hw)) } else if u < 4.0 * hw + 2.0 * hh { (hw - (u - 2.0 * hw - 2.0 * hh), hh) } else { (-hw, hh - (u - 4.0 * hw - 2.0 * hh)) } };+        (p.x + lx * c + ly * s, p.y - lx * s + ly * c) }).collect()+}++/// How closed the darkest ring around a pad is: at each radius, the share of what can be seen that is darker than+/// its neighbourhood. A clearance gap is dark all the way round at some radius; thermal spokes break it only a little.+pub fn gap_share(t: &Traced, p: &Pad) -> f64 {+    let mut best = 0f64;+    for d in [0.08, 0.12, 0.16, 0.20, 0.24, 0.28, 0.32, 0.36, 0.40] {+        let (mut dark, mut seen) = (0u32, 0u32);+        for q in ring(p, d, 40) {+            let (u, v) = board::to_px(q.0, q.1);+            if u < 0.0 || v < 0.0 || u >= t.w as f64 || v >= t.h as f64 { continue; }+            let i = v as usize * t.w + u as usize;+            if t.other.d[i] { continue; }+            seen += 1; if t.ratio.d[i] < 0.94 || t.resp.d[i] > 8.0 { dark += 1; }+        }+        if seen >= 16 { best = best.max(dark as f64 / seen as f64); }+    }+    best+}++/// The island a pad connects to on this face: the copper around it, when no closed gap stands between them.+pub fn pad_island(t: &Traced, p: &Pad) -> Option<(u32, f64)> {+    let mut totals: HashMap<u32, u32> = HashMap::new();+    for d in [0.16, 0.24, 0.32, 0.40, 0.48] { for q in ring(p, d, 40) {+        let (u, v) = board::to_px(q.0, q.1);+        if u < 0.0 || v < 0.0 || u >= t.w as f64 || v >= t.h as f64 { continue; }+        let l = t.labels[v as usize * t.w + u as usize]; if l != 0 { *totals.entry(l).or_default() += 1; }+    } }+    let (l, c) = totals.into_iter().max_by_key(|x| x.1)?;+    if c < 12 { return None; }+    let g = gap_share(t, p);+    // a screw lug is bolted to its pour whatever the ring around it looks like+    if g < GAP_CLOSED || (p.drill > 0.0 && p.w * p.h >= 9.0) { Some((l, g)) } else { None }+}++/// The island at a point: the most common label within `r` mm.+pub fn island_at(t: &Traced, x: f64, y: f64, r: f64) -> Option<u32> {+    let (cu, cv) = board::to_px(x, y); let rr = (r * board::RES) as i64; let mut votes: HashMap<u32, u32> = HashMap::new();+    for dv in -rr..=rr { for du in -rr..=rr { if du * du + dv * dv > rr * rr { continue; }+        let (u, v) = (cu as i64 + du, cv as i64 + dv); if u < 0 || v < 0 || u >= t.w as i64 || v >= t.h as i64 { continue; }+        let l = t.labels[v as usize * t.w + u as usize]; if l != 0 { *votes.entry(l).or_default() += 1; } } }+    votes.into_iter().max_by_key(|x| x.1).map(|x| x.0)+}++fn covered(t: &Traced, x: f64, y: f64) -> bool {+    let (u, v) = board::to_px(x, y);+    if u < 0.0 || v < 0.0 || u >= t.w as f64 || v >= t.h as f64 { return true; }+    t.other.d[v as usize * t.w + u as usize]+}++/// Open polyline simplification.+pub fn rdp(p: &[(f64, f64)], tol: f64) -> Vec<(f64, f64)> {+    if p.len() < 3 { return p.to_vec(); }+    let (a, b) = (p[0], p[p.len() - 1]); let l = crate::geom::dist(a, b).max(1e-9);+    let (mut idx, mut dm) = (0, 0.0);+    for (i, q) in p.iter().enumerate().take(p.len() - 1).skip(1) { let d = if crate::geom::dist(a, b) < 1e-9 { crate::geom::dist(a, *q) } else { ((b.0 - a.0) * (a.1 - q.1) - (a.0 - q.0) * (b.1 - a.1)).abs() / l }; if d > dm { dm = d; idx = i; } }+    if dm > tol { let mut x = rdp(&p[..=idx], tol); x.pop(); x.extend(rdp(&p[idx..], tol)); x } else { vec![a, b] }+}++/// Walk a thinned mask into chains between line ends and junctions.+pub fn chains(sk: &[bool], w: usize, h: usize) -> Vec<Vec<(usize, usize)>> {+    let nb = |i: usize| -> Vec<usize> { let (x, y) = (i % w, i / w); let mut v = vec![]; for dy in -1i64..=1 { for dx in -1i64..=1 { if dx == 0 && dy == 0 { continue; } let (nx, ny) = (x as i64 + dx, y as i64 + dy); if nx >= 0 && ny >= 0 && nx < w as i64 && ny < h as i64 { let j = ny as usize * w + nx as usize; if sk[j] { v.push(j); } } } } v };+    let nodes: Vec<usize> = (0..w * h).filter(|i| sk[*i] && nb(*i).len() != 2).collect();+    let is_node: std::collections::HashSet<usize> = nodes.iter().copied().collect();+    let mut used: std::collections::HashSet<(usize, usize)> = Default::default();+    let mut out = vec![];+    for &s in &nodes { for f in nb(s) {+        if used.contains(&(s, f)) { continue; }+        let mut path = vec![s, f]; used.insert((s, f)); used.insert((f, s));+        let (mut prev, mut cur) = (s, f);+        while !is_node.contains(&cur) {+            let Some(next) = nb(cur).into_iter().find(|n| *n != prev && !used.contains(&(cur, *n))) else { break };+            used.insert((cur, next)); used.insert((next, cur)); path.push(next); prev = cur; cur = next;+        }+        out.push(path.into_iter().map(|i| (i % w, i / w)).collect());+    } }+    out+}++/// `ox`, `oy`: where the sub-image's pixel 0 sits on the board image. A sub-image has a margin, so these can be -1.+fn poly_mm(ring: &[(f64, f64)], ox: f64, oy: f64) -> Vec<(f64, f64)> { ring.iter().map(|p| { let m = board::to_mm(p.0 + ox, p.1 + oy); ((m.0 * 1000.0).round() / 1000.0, (m.1 * 1000.0).round() / 1000.0) }).collect() }++fn r3(v: f64) -> f64 { (v * 1000.0).round() / 1000.0 }++struct Isl { face: usize, id: u32, net: Option<String>, pins: Vec<usize>, votes: HashMap<String, u32>, lugs: HashMap<String, u32>, mixed: bool, area: usize }++pub struct Options { pub track: f64, pub via: (f64, f64), pub clearance: f64, pub hole_hole: f64, pub edge_clear: f64, /// x0, y0, x1, y1 of the board+    pub edge: (f64, f64, f64, f64) }++/// Everything measured: zones, tracks, vias, groups and what disagreed. `pin_net` is what the AI has inferred so far.+pub fn measure(faces: &mut [Face], pads: &[Pad], domes: &[(f64, f64, usize)], pin_net: &HashMap<String, String>, opt: &Options) -> Value {+    let mut absorbed = 0;+    for f in faces.iter_mut() { absorbed += absorb_dimples(&mut f.t); }+    let mut isl: Vec<Isl> = vec![]; let mut index: HashMap<(usize, u32), usize> = HashMap::new();+    for (fi, f) in faces.iter().enumerate() { for r in &f.t.regions { index.insert((fi, r.id), isl.len()); isl.push(Isl { face: fi, id: r.id, net: None, pins: vec![], votes: HashMap::new(), lugs: HashMap::new(), mixed: false, area: r.area }); } }+    // pins onto islands, face by face+    let mut profile = vec![];+    let mut joins = vec![]; let mut pad_on: Vec<Vec<usize>> = vec![vec![]; pads.len()];+    for (pi, p) in pads.iter().enumerate() {+        for (fi, f) in faces.iter().enumerate() {+            if !p.on(f.layer) { continue; }+            profile.push(json!({"pin": format!("{}.{}", p.part, p.num), "side": f.side, "plated": p.drill > 0.0, "net": pin_net.get(&format!("{}.{}", p.part, p.num)), "gap": (gap_share(&f.t, p) * 100.0).round() / 100.0}));+            if let Some((l, share)) = pad_island(&f.t, p) {+                let k = index[&(fi, l)]; isl[k].pins.push(pi); pad_on[pi].push(k);+                joins.push(json!({"pin": format!("{}.{}", p.part, p.num), "side": f.side, "island": l, "gap": (share * 100.0).round() / 100.0}));+                if let Some(n) = pin_net.get(&format!("{}.{}", p.part, p.num)) {+                    // a large pad says more about its pour than a small one that happens to sit in it+                    *isl[k].votes.entry(n.clone()).or_default() += (p.w * p.h).ceil().max(1.0) as u32;+                    // a screw lug is bolted to its pour: nothing else says as much about what the pour carries+                    if p.drill > 0.0 && p.w * p.h >= 9.0 { *isl[k].lugs.entry(n.clone()).or_default() += 1; }+                }+            }+        }+    }+    // an island takes the net that clearly dominates the pins it touches+    let mut mixed = vec![];+    for i in isl.iter_mut() {+        if i.lugs.len() == 1 { i.net = i.lugs.keys().next().cloned(); continue; }+        let total: u32 = i.votes.values().sum(); if total == 0 { continue; }+        let (n, c) = i.votes.iter().max_by_key(|x| (*x.1, x.0.clone())).map(|x| (x.0.clone(), *x.1)).unwrap();+        if c as f64 >= 0.7 * total as f64 { i.net = Some(n); } else { i.mixed = true; mixed.push(json!({"side": faces[i.face].side, "island": i.id, "votes": i.votes})); }+    }+    // vias: seen on both faces, or on one with the other hidden by a part+    let (ft, fb) = (faces.iter().position(|f| f.side == "top"), faces.iter().position(|f| f.side == "bottom"));+    let tops: Vec<(f64, f64)> = domes.iter().filter(|d| Some(d.2) == ft).map(|d| (d.0, d.1)).collect();+    let bots: Vec<(f64, f64)> = domes.iter().filter(|d| Some(d.2) == fb).map(|d| (d.0, d.1)).collect();+    // Vias tent over on the top of this board, so a bottom dome stands on its own: grids of them line up under the+    // parts and pours they stitch. A top dome counts only where the bottom is hidden from view, or it pairs up.+    let mut vias: Vec<(f64, f64, &'static str)> = vec![];+    let mut used_top = vec![false; tops.len()];+    let near = 0.30;+    for b in &bots {+        let m = tops.iter().enumerate().filter(|(i, t)| !used_top[*i] && crate::geom::dist(*b, **t) < near).min_by(|x, y| crate::geom::dist(*b, *x.1).partial_cmp(&crate::geom::dist(*b, *y.1)).unwrap()).map(|x| x.0);+        if let Some(i) = m { used_top[i] = true; vias.push((b.0, b.1, "both-faces")); }+        else if ft.map(|f| covered(&faces[f].t, b.0, b.1)).unwrap_or(false) { vias.push((b.0, b.1, "bottom, top hidden by a part")); }+        else { vias.push((b.0, b.1, "bottom")); }+    }+    for (i, t) in tops.iter().enumerate() { if !used_top[i] && fb.map(|f| covered(&faces[f].t, t.0, t.1)).unwrap_or(false) { vias.push((t.0, t.1, "top, bottom hidden")); } }+    // a via must not sit on a pad: dimples of soldered pads and pin bodies look like domes+    // nor overlap another via: real ones keep a hole's clearance+    let on_pad = |v: &(f64, f64, &str)| pads.iter().any(|p| { let (dx, dy) = (v.0 - p.x, v.1 - p.y); let (c, s) = (p.rot.to_radians().cos(), p.rot.to_radians().sin()); let (lx, ly) = (dx * c - dy * s, dx * s + dy * c); lx.abs() < p.w / 2.0 + opt.via.0 / 2.0 + opt.clearance && ly.abs() < p.h / 2.0 + opt.via.0 / 2.0 + opt.clearance });+    let mut uniq: Vec<(f64, f64, &'static str)> = vec![];+    for v in vias.into_iter().filter(|v| !on_pad(v)) { if v.0 - opt.edge.0 < opt.via.0 / 2.0 + opt.edge_clear || opt.edge.2 - v.0 < opt.via.0 / 2.0 + opt.edge_clear || v.1 - opt.edge.1 < opt.via.0 / 2.0 + opt.edge_clear || opt.edge.3 - v.1 < opt.via.0 / 2.0 + opt.edge_clear { continue; } if !uniq.iter().any(|u| crate::geom::dist((u.0, u.1), (v.0, v.1)) < opt.via.1 + opt.hole_hole) { uniq.push(v); } }+    let vias = uniq;+    let dome_total = (tops.len(), bots.len());+    // via islands, then nets across vias until nothing changes+    let mut via_isl: Vec<Vec<usize>> = vec![];+    for v in &vias { let mut here = vec![]; for (fi, f) in faces.iter().enumerate() { if let Some(l) = island_at(&f.t, v.0, v.1, 0.2) { here.push(index[&(fi, l)]); } } via_isl.push(here); }+    // Two islands are linked by the vias between them. One via is enough for a track, whose only way off a face+    // is a via; two large pours need at least two, so a stray dome on a boundary cannot carry a net across.+    let mut links: HashMap<(usize, usize), u32> = HashMap::new();+    for here in &via_isl { for a in 0..here.len() { for b in a + 1..here.len() { let k = (here[a].min(here[b]), here[a].max(here[b])); if k.0 != k.1 { *links.entry(k).or_default() += 1; } } } }+    let small_area = (3.0 * MM2) as usize;+    let strong: Vec<(usize, usize)> = links.iter().filter(|(k, c)| **c >= 2 || isl[k.0].area < small_area || isl[k.1].area < small_area).map(|(k, _)| *k).collect();+    for _ in 0..12 {+        let mut any = false;+        for (a, b) in &strong {+            match (isl[*a].net.clone(), isl[*b].net.clone()) {+                (Some(n), None) if !isl[*b].mixed => { isl[*b].net = Some(n); any = true; }+                (None, Some(n)) if !isl[*a].mixed => { isl[*a].net = Some(n); any = true; }+                _ => {}+            }+        }+        if !any { break; }+    }+    let mut clashes: BTreeSet<String> = BTreeSet::new();+    for (a, b) in &strong { if let (Some(x), Some(y)) = (&isl[*a].net, &isl[*b].net) { if x != y { let (p, q) = if x < y { (x, y) } else { (y, x) }; clashes.insert(format!("{p} + {q}")); } } }+    let net_of = |k: usize, isl: &[Isl], faces: &[Face]| -> String { isl[k].net.clone().unwrap_or_else(|| format!("UNK_{}_{}", faces[isl[k].face].side, isl[k].id)) };+    // pins the islands add or dispute+    let (mut pin_adds, mut pin_clash) = (vec![], vec![]);+    for (pi, p) in pads.iter().enumerate() {+        let pin = format!("{}.{}", p.part, p.num);+        for k in &pad_on[pi] { if let Some(n) = &isl[*k].net {+            match pin_net.get(&pin) {+                None if !p.num.is_empty() => pin_adds.push(json!({"pin": pin, "net": n, "side": faces[isl[*k].face].side, "island": isl[*k].id})),+                Some(o) if o != n => pin_clash.push(json!({"pin": pin, "inferred": o, "copper": n, "side": faces[isl[*k].face].side, "island": isl[*k].id})),+                _ => {}+            } } }+    }+    // board objects+    let (mut zones, mut tracks, mut small): (Vec<Value>, Vec<Value>, usize) = (vec![], vec![], 0);+    let mut sizes: Vec<(usize, u32, usize)> = vec![];+    for (fi, f) in faces.iter().enumerate() { for r in &f.t.regions { sizes.push((fi, r.id, r.area)); } }+    let mut zone_of: HashMap<usize, String> = HashMap::new(); let mut track_of: HashMap<usize, Vec<String>> = HashMap::new();+    for (fi, f) in faces.iter().enumerate() {+        let t = &f.t; let (w, h) = (t.w, t.h);+        // boxes+        let by_id: HashMap<u32, &crate::trace::Region> = t.regions.iter().map(|r| (r.id, r)).collect();+        let mut px: HashMap<u32, Vec<usize>> = HashMap::new();+        for i in 0..w * h { let l = t.labels[i]; if l != 0 { px.entry(l).or_default().push(i); } }+        for (id, pix) in &px {+            let r = by_id[id]; let k = index[&(fi, *id)];+            let area = r.area as f64 / MM2; let half = (r.half_width as f64 + 1.5) / board::RES;+            let net = net_of(k, &isl, faces);+            let unknown = isl[k].net.is_none();+            let pour = half >= 0.40 && area >= 0.8;+            if pour {+                // the outline includes the pads and vias this island joins+                let (x0, y0, x1, y1) = (r.x0.saturating_sub(60), r.y0.saturating_sub(60), (r.x1 + 60).min(w - 1), (r.y1 + 60).min(h - 1));+                let (sw, sh) = (x1 - x0 + 3, y1 - y0 + 3);+                let mut sub = Mask::new(sw, sh);+                for &i in pix { sub.set(i % w - x0 + 1, i / w - y0 + 1, true); }+                let mark = |sub: &mut Mask, cx: f64, cy: f64, hw: f64, hh: f64, c: f64, s: f64, round: bool| {+                    let (u0, v0) = board::to_px(cx, cy); let e = ((hw.max(hh) * std::f64::consts::SQRT_2 + 0.1) * board::RES) as i64;+                    for dv in -e..=e { for du in -e..=e { let (mx, my) = (du as f64 / board::RES, dv as f64 / board::RES); let (lx, ly) = (mx * c - my * s, mx * s + my * c);+                        let hit = if round { lx * lx + ly * ly <= hw * hw } else { lx.abs() <= hw && ly.abs() <= hh }; if !hit { continue; }+                        let (a, b) = (u0 as i64 + du - x0 as i64 + 1, v0 as i64 + dv - y0 as i64 + 1); if a >= 0 && b >= 0 && a < sw as i64 && b < sh as i64 { sub.set(a as usize, b as usize, true); } } } };+                for &pi in &isl[k].pins { let p = &pads[pi]; mark(&mut sub, p.x, p.y, p.w / 2.0 + 0.32, p.h / 2.0 + 0.32, p.rot.to_radians().cos(), p.rot.to_radians().sin(), p.round); }+                for (vi, here) in via_isl.iter().enumerate() { if here.contains(&k) { mark(&mut sub, vias[vi].0, vias[vi].1, 0.45, 0.45, 1.0, 0.0, true); } }+                // pinholes in a pour are dimples; larger holes are what the photo shows around other copper+                let filled = sub.fill_holes();+                let holes = filled.andnot(&sub);+                let (hl, hc) = raster::components(&holes);+                let mut tiny = vec![false; hc.len() + 1];+                for c in &hc { tiny[c.label as usize] = (c.area as f64) < 0.25 * MM2; }+                for i in 0..sw * sh { if hl[i] != 0 && tiny[hl[i] as usize] { sub.d[i] = true; } }+                // the photo's grain on an edge is not copper+                let sub = sub.close(2.0).open(2.0);+                let (lab, comps) = raster::components(&sub);+                let Some(main) = comps.iter().max_by_key(|c| c.area) else { continue };+                let outer = crate::geom::simplify(&raster::trace_boundary(&lab, sw, sh, main), 1.2);+                if outer.len() < 3 { continue; }+                // the exact copper, as runs on the board's 20 px/mm grid: an outline cannot say what a pour's holes are+                let (gx0, gy0) = (x0.wrapping_sub(1), y0.wrapping_sub(1));+                let mut rle: Vec<Value> = vec![];+                let mut gv = (y0 / 2).max(1);+                while gv * 2 < gy0.wrapping_add(sh) {+                    let mut row: Vec<usize> = vec![gv]; let mut start: Option<usize> = None;+                    let gu0 = (x0 / 2).max(1);+                    let mut gu = gu0;+                    while gu * 2 < gx0.wrapping_add(sw) {+                        let (a, b) = ((gu * 2).wrapping_sub(gx0), (gv * 2).wrapping_sub(gy0));+                        let on = a < sw && b < sh && sub.d[b * sw + a];+                        match (on, start) { (true, None) => start = Some(gu), (false, Some(s0)) => { row.push(s0); row.push(gu); start = None; } _ => {} }+                        gu += 1;+                    }+                    if let Some(s0) = start { row.push(s0); row.push(gu); }+                    if row.len() > 1 { rle.push(json!(row)); }+                    gv += 1;+                }+                let oz = poly_mm(&outer, x0 as f64 - 1.0, y0 as f64 - 1.0);+                let zid = format!("isl-{}-{id}", f.side);+                zone_of.insert(k, zid.clone());+                let rank = sizes.iter().filter(|s| s.2 > r.area).count() as u64;+                zones.push(json!({"id": zid, "layer": f.layer, "net": net, "from": "islands", "polygon_mm": oz.iter().map(|p| json!([p.0, p.1])).collect::<Vec<_>>(), "rle": rle, "areaMm2": (area * 100.0).round() / 100.0,+                    "connect": "solid", "priority": rank, "min_width_mm": 0.2, "tag": "MEASURED", "fieldTags": if unknown { json!({"net": "UNKNOWN"}) } else { json!({"net": "INFERRED"}) }, "tests": ["T-pour-nets"],+                    "evidence": [format!("evidence/copper-islands-{}.jpg", f.side)]}));+            } else if area >= 0.25 {+                // a track: its centre line, at the width measured along it+                let (x0, y0, x1, y1) = (r.x0, r.y0, r.x1, r.y1);+                let (sw, sh) = (x1 - x0 + 3, y1 - y0 + 3);+                let mut sk = vec![false; sw * sh];+                for &i in pix { sk[(i / w - y0 + 1) * sw + i % w - x0 + 1] = true; }+                crate::trace::thin(&mut sk, sw, sh);+                let cs = chains(&sk, sw, sh);+                let mut widths: Vec<f32> = vec![];+                for c in &cs { for (cx, cy) in c { widths.push(half_at(t, cx + x0 - 1, cy + y0 - 1, *id)); } }+                widths.sort_by(|a, b| a.partial_cmp(b).unwrap());+                let wd = widths.get(widths.len() / 2).map(|v| (*v as f64 + 1.0) * 2.0 / board::RES).unwrap_or(opt.track).clamp(opt.track, 1.2);+                for (ci, c) in cs.iter().enumerate() {+                    if c.len() < 14 { continue; }+                    let pts: Vec<(f64, f64)> = c.iter().map(|(cx, cy)| board::to_mm((cx + x0 - 1) as f64, (cy + y0 - 1) as f64)).collect();+                    let s = rdp(&pts, 0.03);+                    let tid = format!("isl-{}-{id}-{ci}", f.side);+                    track_of.entry(k).or_default().push(tid.clone());+                    tracks.push(json!({"id": tid, "layer": f.layer, "net": net, "from": "islands", "width_mm": r3(wd), "path_mm": s.iter().map(|p| json!([r3(p.0), r3(p.1)])).collect::<Vec<_>>(), "tag": "MEASURED", "fieldTags": if unknown { json!({"net": "UNKNOWN"}) } else { json!({"net": "INFERRED"}) }, "tests": ["T-pour-nets"], "evidence": [format!("evidence/copper-islands-{}.jpg", f.side)]}));+                }+                // the track reaches the pads and vias its island joins+                let mut ends: Vec<(f64, f64)> = isl[k].pins.iter().map(|pi| (pads[*pi].x, pads[*pi].y)).collect();+                for (vi, here) in via_isl.iter().enumerate() { if here.contains(&k) { ends.push((vias[vi].0, vias[vi].1)); } }+                let line: Vec<(f64, f64)> = pix.iter().step_by(4).map(|i| board::to_mm((i % w) as f64, (i / w) as f64)).collect();+                for (ei, e) in ends.iter().enumerate() {+                    if let Some(q) = line.iter().min_by(|a, b| crate::geom::dist(**a, *e).partial_cmp(&crate::geom::dist(**b, *e)).unwrap()) {+                        if crate::geom::dist(*q, *e) < 1.2 { let tid = format!("isl-{}-{id}-stub{ei}", f.side); track_of.entry(k).or_default().push(tid.clone());+                            tracks.push(json!({"id": tid, "layer": f.layer, "net": net, "from": "islands", "width_mm": r3(opt.track), "path_mm": [[r3(q.0), r3(q.1)], [r3(e.0), r3(e.1)]], "tag": "MEASURED", "fieldTags": {"net": "INFERRED"}, "tests": ["T-pour-nets"], "evidence": [format!("evidence/copper-islands-{}.jpg", f.side)]})); }+                    }+                }+            } else { small += 1; }+        }+    }+    // Fragments are not tracks. What is left must also make physical sense: a piece of track that runs into a pad+    // of another net, or lies on a longer track of another net, is a misreading of the photo. The longest go first.+    let path_of = |t: &Value| -> Vec<(f64, f64)> { t["path_mm"].as_array().map(|a| a.iter().map(|q| (q[0].as_f64().unwrap_or(0.0), q[1].as_f64().unwrap_or(0.0))).collect()).unwrap_or_default() };+    let length = |p: &[(f64, f64)]| p.windows(2).map(|q| crate::geom::dist(q[0], q[1])).sum::<f64>();+    let seg_d = |p: (f64, f64), a: (f64, f64), b: (f64, f64)| { let (dx, dy) = (b.0 - a.0, b.1 - a.1); let l2 = dx * dx + dy * dy; let t = if l2 > 0.0 { (((p.0 - a.0) * dx + (p.1 - a.1) * dy) / l2).clamp(0.0, 1.0) } else { 0.0 }; ((p.0 - a.0 - t * dx).powi(2) + (p.1 - a.1 - t * dy).powi(2)).sqrt() };+    let tracks_in = tracks.len();+    tracks.sort_by(|a, b| length(&path_of(b)).partial_cmp(&length(&path_of(a))).unwrap());+    let mut kept: Vec<Value> = vec![];+    for t in tracks {+        let (net, layer, wd) = (t["net"].as_str().unwrap_or("").to_string(), t["layer"].as_str().unwrap_or("").to_string(), t["width_mm"].as_f64().unwrap_or(0.2));+        let path = path_of(&t); let unknown = net.starts_with("UNK"); let stub = t["id"].as_str().unwrap_or("").contains("stub");+        if unknown && length(&path) < 0.8 { continue; }+        // copper at the very edge of the photo is the photo's edge: real copper keeps its distance+        let at_edge = |q: &(f64, f64)| q.0 - opt.edge.0 < opt.edge_clear + wd / 2.0 || opt.edge.2 - q.0 < opt.edge_clear + wd / 2.0 || q.1 - opt.edge.1 < opt.edge_clear + wd / 2.0 || opt.edge.3 - q.1 < opt.edge_clear + wd / 2.0;+        if path.iter().any(at_edge) { continue; }+        // a track of unknown net has to be clear of everything by the rule, or it is more likely a misreading than copper+        let margin = if unknown { opt.clearance } else { 0.05 };+        // into a pad of another net+        let hits_pad = pads.iter().any(|p| p.on(&layer) && p.net != net && path.windows(2).any(|q| seg_d((p.x, p.y), q[0], q[1]) < p.w.min(p.h) / 2.0 + wd / 2.0 + 0.05 || (!stub && seg_d((p.x, p.y), q[0], q[1]) < p.w.max(p.h) / 2.0 + wd / 2.0)));+        if hits_pad { continue; }+        // onto a longer track of another net+        let hits_track = kept.iter().any(|o| o["layer"].as_str() == Some(&layer) && o["net"].as_str() != Some(&net) && { let op = path_of(o); let ow = o["width_mm"].as_f64().unwrap_or(0.2); path.iter().any(|a| op.windows(2).any(|q| seg_d(*a, q[0], q[1]) < wd / 2.0 + ow / 2.0 + margin)) || op.iter().any(|a| path.windows(2).any(|q| seg_d(*a, q[0], q[1]) < wd / 2.0 + ow / 2.0 + margin)) });+        if hits_track { continue; }+        // a via of another net+        if unknown && vias.iter().any(|v| path.windows(2).any(|q| seg_d((v.0, v.1), q[0], q[1]) < wd / 2.0 + opt.via.0 / 2.0 + margin)) { continue; }+        kept.push(t);+    }+    let tracks = kept;+    let tracks_dropped = tracks_in - tracks.len();+    // via objects, and inner-layer evidence: a small isolated ring on both faces, joined to no track or pour+    let mut via_out = vec![]; let mut inner = 0; let mut named = 0;+    for (vi, v) in vias.iter().enumerate() {+        let here = &via_isl[vi];+        // a via takes its net from named copper actually around it: a pour or track on either face, the bottom first+        let around: Vec<usize> = here.iter().rev().copied().filter(|k| zone_of.contains_key(k) || track_of.contains_key(k)).collect();+        let known: BTreeSet<String> = around.iter().find_map(|k| isl[*k].net.clone()).into_iter().collect();+        let net = if let Some(n) = known.iter().next() { named += 1; n.clone() } else if let Some(k) = around.first() { net_of(*k, &isl, faces) } else { String::new() };+        let isolated = here.len() >= 2 && here.iter().all(|k| !zone_of.contains_key(k) && !track_of.contains_key(k));+        let kind = if isolated { inner += 1; "inner" } else if net.is_empty() { "unknown" } else if net.starts_with("UNK") { "seen" } else { "seen-net" };+        via_out.push(json!({"id": format!("v{vi}"), "pos_mm": [r3(v.0), r3(v.1)], "diameter_mm": opt.via.0, "drill_mm": opt.via.1, "net": net, "kind": kind, "seen": v.2, "tag": "MEASURED", "fieldTags": if known.len() == 1 { json!({"net": "INFERRED"}) } else { json!({"net": "UNKNOWN"}) }, "tests": ["T-via-nets"]}));+    }+    // connected groups per net, for the router: what the photo already joins+    let mut parent: Vec<usize> = (0..isl.len()).collect();+    fn find(p: &mut Vec<usize>, x: usize) -> usize { let mut r = x; while p[r] != r { r = p[r]; } let mut y = x; while p[y] != r { let n = p[y]; p[y] = r; y = n; } r }+    for (vi, here) in via_isl.iter().enumerate() { let _ = vi; for w2 in here.windows(2) { if isl[w2[0]].net.is_some() && isl[w2[0]].net == isl[w2[1]].net { let (a, b) = (find(&mut parent, w2[0]), find(&mut parent, w2[1])); parent[a] = b; } } }+    for pi in 0..pads.len() { for w2 in pad_on[pi].windows(2) { if isl[w2[0]].net.is_some() && isl[w2[0]].net == isl[w2[1]].net { let (a, b) = (find(&mut parent, w2[0]), find(&mut parent, w2[1])); parent[a] = b; } } }+    let mut grp: HashMap<usize, (String, BTreeSet<String>, Vec<String>, Vec<String>, Vec<String>)> = HashMap::new();+    for k in 0..isl.len() {+        let Some(net) = isl[k].net.clone() else { continue };+        let rt = find(&mut parent, k);+        let e = grp.entry(rt).or_insert_with(|| (net.clone(), BTreeSet::new(), vec![], vec![], vec![]));+        for pi in &isl[k].pins { let pin = format!("{}.{}", pads[*pi].part, pads[*pi].num); if pin_net.get(&pin) == Some(&net) || !pin_net.contains_key(&pin) { e.1.insert(pin); } }+        if let Some(z) = zone_of.get(&k) { e.2.push(z.clone()); }+        if let Some(t) = track_of.get(&k) { e.3.extend(t.iter().cloned()); }+    }+    for (vi, here) in via_isl.iter().enumerate() { if let Some(k) = here.iter().find(|k| isl[**k].net.is_some()) { let rt = find(&mut parent, *k); if let Some(e) = grp.get_mut(&rt) { if here.iter().all(|q| isl[*q].net.is_none() || isl[*q].net == isl[*k].net) { e.4.push(format!("v{vi}")); } } } }+    let groups: Vec<Value> = grp.into_values().filter(|g| !g.1.is_empty() || !g.2.is_empty() || !g.3.is_empty()).map(|g| json!({"net": g.0, "pins": g.1, "zones": g.2, "traces": g.3, "vias": g.4})).collect();+    let both = via_out.iter().filter(|v| v["seen"] == json!("both-faces")).count();+    let report = json!({"islands": isl.len(), "absorbedDimples": absorbed, "tooSmallToDraw": small, "pours": zones.len(), "tracks": tracks.len(), "fragmentsDropped": tracks_dropped, "domes": {"top": dome_total.0, "bottom": dome_total.1}, "vias": {"total": via_out.len(), "bothFaces": both, "named": named, "inner": inner},+        "islandsWithNet": isl.iter().filter(|i| i.net.is_some()).count(), "pinsJoined": joins.len(), "pinAdds": pin_adds.len(), "pinClashes": pin_clash.len(), "viaClashes": clashes.len(), "mixedIslands": mixed.len()});+    json!({"zones": zones, "traces": tracks, "vias": via_out, "groups": groups, "report": report, "pinAdds": pin_adds, "pinClashes": pin_clash, "viaClashes": clashes, "mixed": mixed, "joins": joins, "profile": profile})+}++fn half_at(t: &Traced, x: usize, y: usize, id: u32) -> f32 {+    // distance to the island's edge, along the four axes and diagonals+    let mut best = f32::MAX;+    for (dx, dy, st) in [(1i64, 0i64, 1.0f32), (0, 1, 1.0), (1, 1, 1.414), (1, -1, 1.414)] { let mut c = 0f32;+        for sg in [1i64, -1] { let mut k = 1i64; while k < 60 { let (a, b) = (x as i64 + dx * sg * k, y as i64 + dy * sg * k); if a < 0 || b < 0 || a >= t.w as i64 || b >= t.h as i64 || t.labels[b as usize * t.w + a as usize] != id { break; } c += st; k += 1; } }+        best = best.min(c / 2.0); }+    best+}++#[cfg(test)]+mod tests {+    use super::*;+    #[test]+    fn an_island_at_the_photos_edge_keeps_sane_coordinates() {+        let p = poly_mm(&[(0.0, 0.0), (41.0, 1.0)], -1.0, -1.0);+        assert_eq!(p[0], (99.975, 99.975));+        assert_eq!(p[1], (101.0, 100.0));+    }+    #[test]+    fn a_line_with_a_kink_simplifies_to_its_corner() {+        let p: Vec<(f64, f64)> = (0..10).map(|i| (i as f64, 0.0)).chain((1..10).map(|i| (9.0, i as f64))).collect();+        let s = rdp(&p, 0.05);+        assert_eq!(s.len(), 3, "{s:?}");+    }+    #[test]+    fn chains_split_at_a_junction() {+        let (w, h) = (9, 9); let mut m = vec![false; w * h];+        for x in 0..9 { m[4 * w + x] = true; }+        for y in 0..4 { m[y * w + 4] = true; }+        let c = chains(&m, w, h);+        assert!(c.len() >= 3, "{}", c.len());+    }+}
src/items.rsadded+158
@@ -0,0 +1,158 @@+//! Every claim the flow makes is an item: a part, a block, a zone, a trace, a via, a net, a plane, the+//! outline, the layer proposal. Each item carries an evidence tag saying how it is known, the files+//! that show it, and (when it is not seen) the tests that would settle it. The checks and the report+//! read items the same way whichever artifact they come from.+use serde_json::Value;+use std::path::{Path, PathBuf};++/// How an item is known, strongest first.+pub const TAGS: [&str; 5] = ["SEEN", "MEASURED", "DATASHEET", "INFERRED", "UNKNOWN"];++/// (artifact file, array key, id key, id prefix): where items live.+pub const KINDS: &[(&str, &str, &str, &str)] = &[+    ("parts.json", "parts", "ref", ""),+    ("blocks.json", "blocks", "id", "block:"),+    ("copper.json", "zones", "id", "zone:"),+    ("copper.json", "traces", "id", "trace:"),+    ("copper.json", "vias", "id", "via:"),+    ("nets.json", "nets", "name", "net:"),+    ("layers.json", "planes", "layer", "plane:"),+];++/// The artifacts that hold items, in flow order.+pub const ARTIFACTS: [&str; 5] = ["parts.json", "blocks.json", "copper.json", "nets.json", "layers.json"];++#[derive(Clone, Debug)]+pub struct Item {+    pub id: String,+    pub artifact: String,+    pub kind: String,+    pub tag: String,+    /// Per-field tags (`fieldTags: {"value": "INFERRED"}`), for an item seen in part and inferred in part.+    pub field_tags: Vec<(String, String)>,+    pub tests: Vec<String>,+    pub evidence: Vec<String>,+    pub source: String,+}++pub fn is_open(tag: &str) -> bool { tag == "INFERRED" || tag == "UNKNOWN" }++impl Item {+    /// The item is not fully known: its own tag or one of its field tags is INFERRED or UNKNOWN.+    pub fn open(&self) -> bool { is_open(&self.tag) || self.field_tags.iter().any(|(_, t)| is_open(t)) }+    /// The tags of the item and its fields that are open, for messages.+    pub fn open_what(&self) -> Vec<String> {+        let mut v = vec![];+        if is_open(&self.tag) { v.push(self.tag.clone()); }+        for (f, t) in &self.field_tags { if is_open(t) { v.push(format!("{f} {t}")); } }+        v+    }+}++fn strs(v: &Value) -> Vec<String> {+    match v {+        Value::String(s) if !s.is_empty() => vec![s.clone()],+        Value::Array(a) => a.iter().filter_map(|x| x.as_str().map(str::to_string)).filter(|s| !s.is_empty()).collect(),+        _ => vec![],+    }+}++fn item(id: String, artifact: &str, kind: &str, v: &Value, default_evidence: &[String]) -> Item {+    let mut evidence = strs(&v["evidence"]);+    if evidence.is_empty() { evidence = default_evidence.to_vec(); }+    let field_tags = v["fieldTags"].as_object().map(|o| o.iter().map(|(k, t)| (k.clone(), t.as_str().unwrap_or("").to_string())).collect()).unwrap_or_default();+    Item {+        id, artifact: artifact.into(), kind: kind.into(),+        tag: v["tag"].as_str().unwrap_or("").to_string(),+        field_tags, tests: strs(&v["tests"]), evidence,+        source: v["source"].as_str().unwrap_or("").to_string(),+    }+}++/// The items in one artifact. Arrays per KINDS, plus the singletons: copper.json `outline` and the+/// layer proposal (layers.json's own tag).+pub fn items_of(artifact: &str, doc: &Value) -> Vec<Item> {+    let dflt = strs(&doc["defaultEvidence"]);+    let mut out = vec![];+    for (file, key, idk, pre) in KINDS {+        if *file != artifact { continue; }+        for v in doc[*key].as_array().into_iter().flatten() {+            let id = v[*idk].as_str().unwrap_or("").to_string();+            out.push(item(format!("{pre}{id}"), artifact, key, v, &dflt));+        }+    }+    if artifact == "copper.json" && doc["outline"].is_object() { out.push(item("outline".into(), artifact, "outline", &doc["outline"], &dflt)); }+    if artifact == "layers.json" && doc.get("proposed").is_some() { out.push(item("layers".into(), artifact, "layers", doc, &dflt)); }+    out+}++pub fn read_json(p: &Path) -> Result<Value, String> {+    let t = std::fs::read_to_string(p).map_err(|e| format!("{}: {e}", p.display()))?;+    serde_json::from_str(&t).map_err(|e| format!("{}: not valid JSON: {e}", p.display()))+}++/// Every item in every artifact that exists in the flow folder.+pub fn all(flow_dir: &Path) -> Vec<Item> {+    ARTIFACTS.iter().filter_map(|a| read_json(&flow_dir.join(a)).ok().map(|d| items_of(a, &d))).flatten().collect()+}++/// An evidence path: absolute, or relative to the flow folder, or to the project folder above it.+pub fn resolve(flow_dir: &Path, rel: &str) -> Option<PathBuf> {+    let p = Path::new(rel);+    if p.is_absolute() { return p.exists().then(|| p.to_path_buf()); }+    let a = flow_dir.join(p);+    if a.exists() { return Some(a); }+    let b = flow_dir.parent().map(|d| d.join(p))?;+    b.exists().then_some(b)+}++/// The rules every item meets, whatever artifact it is in. Problems are pushed to `errs`.+pub fn validate(flow_dir: &Path, it: &Item, errs: &mut Vec<String>) {+    let id = if it.id.is_empty() || it.id.ends_with(':') { format!("an item in {} with no id", it.artifact) } else { it.id.clone() };+    if !TAGS.contains(&it.tag.as_str()) {+        errs.push(format!("{id}: tag {:?} is not one of {}", it.tag, TAGS.join(", ")));+    }+    for (f, t) in &it.field_tags {+        if !TAGS.contains(&t.as_str()) { errs.push(format!("{id}: fieldTags.{f} {t:?} is not one of {}", TAGS.join(", "))); }+    }+    if matches!(it.tag.as_str(), "SEEN" | "MEASURED") {+        if it.evidence.is_empty() { errs.push(format!("{id}: tagged {} but names no evidence file (a crop that shows it)", it.tag)); }+    }+    for e in &it.evidence {+        if resolve(flow_dir, e).is_none() { errs.push(format!("{id}: evidence file {e} is not there")); }+    }+    let ds = it.tag == "DATASHEET" || it.field_tags.iter().any(|(_, t)| t == "DATASHEET");+    if ds && it.source.is_empty() { errs.push(format!("{id}: tagged DATASHEET but has no source (document, revision, page or table)")); }+    if it.open() && it.tests.is_empty() {+        errs.push(format!("{id}: {} but names no test that would settle it (tests: [\"T..\"])", it.open_what().join(", ")));+    }+}++#[cfg(test)]+mod tests {+    use super::*;+    use serde_json::json;++    #[test]+    fn items_and_rules() {+        let d = std::env::temp_dir().join(format!("items-test-{}", std::process::id()));+        std::fs::create_dir_all(d.join("evidence")).unwrap();+        std::fs::write(d.join("evidence/u1.jpg"), "x").unwrap();+        let doc = json!({"parts": [+            {"ref": "U1", "tag": "SEEN", "evidence": ["evidence/u1.jpg"], "fieldTags": {"mpn": "INFERRED"}, "tests": ["T1"]},+            {"ref": "R1", "tag": "INFERRED"},+            {"ref": "C1", "tag": "DATASHEET"},+            {"ref": "Q1", "tag": "SEEN", "evidence": ["evidence/missing.jpg"]}+        ]});+        let items = items_of("parts.json", &doc);+        assert_eq!(items.len(), 4);+        let mut e = vec![];+        for it in &items { validate(&d, it, &mut e); }+        assert!(e.iter().any(|m| m.starts_with("R1:") && m.contains("no test")), "{e:?}");+        assert!(e.iter().any(|m| m.starts_with("C1:") && m.contains("no source")), "{e:?}");+        assert!(e.iter().any(|m| m.starts_with("Q1:") && m.contains("not there")), "{e:?}");+        assert!(!e.iter().any(|m| m.starts_with("U1:")), "{e:?}");+        assert!(items[0].open());+        std::fs::remove_dir_all(&d).ok();+    }+}
src/kicad_in.rsadded+96
@@ -0,0 +1,96 @@+//! Just enough of a `.kicad_pcb` to compare the copied board with the photos: each footprint's+//! reference, side, position and rotation, and the copper layer count. A small S-expression scanner;+//! nothing here writes a board.++#[derive(Debug, Clone)]+pub struct Footprint { pub reference: String, pub layer: String, pub x: f64, pub y: f64, pub rot: f64 }++/// The child lists of the list that opens at `open` (byte index of its '('), as (start, end) spans.+fn children(t: &[u8], open: usize) -> Vec<(usize, usize)> {+    let mut out = vec![];+    let (mut i, mut depth, mut in_str, mut start) = (open + 1, 1usize, false, 0usize);+    while i < t.len() && depth > 0 {+        let c = t[i];+        if in_str {+            if c == b'\\' { i += 1; } else if c == b'"' { in_str = false; }+        } else if c == b'"' { in_str = true; }+        else if c == b'(' { if depth == 1 { start = i; } depth += 1; }+        else if c == b')' { depth -= 1; if depth == 1 { out.push((start, i + 1)); } }+        i += 1;+    }+    out+}++fn head(s: &str) -> &str { s[1..].split(|c: char| c.is_whitespace() || c == ')').next().unwrap_or("") }++fn quoted(s: &str, n: usize) -> Option<String> {+    let parts: Vec<&str> = s.split('"').collect();+    parts.get(2 * n + 1).map(|x| x.to_string())+}++fn nums(s: &str) -> Vec<f64> {+    s.trim_start_matches('(').trim_end_matches(')').split_whitespace().skip(1).filter_map(|x| x.parse().ok()).collect()+}++pub fn footprints(text: &str) -> Vec<Footprint> {+    let t = text.as_bytes();+    let Some(root) = text.find('(') else { return vec![] };+    let mut out = vec![];+    for (s, e) in children(t, root) {+        let fp = &text[s..e];+        if head(fp) != "footprint" && head(fp) != "module" { continue; }+        let (mut reference, mut layer, mut at) = (String::new(), String::new(), vec![]);+        for (cs, ce) in children(t, s) {+            let c = &text[cs..ce];+            match head(c) {+                "layer" if layer.is_empty() => layer = quoted(c, 0).unwrap_or_default(),+                "at" if at.is_empty() => at = nums(c),+                "property" if reference.is_empty() && quoted(c, 0).as_deref() == Some("Reference") => reference = quoted(c, 1).unwrap_or_default(),+                "fp_text" if reference.is_empty() && c.starts_with("(fp_text reference") => reference = quoted(c, 0).unwrap_or_default(),+                _ => {}+            }+        }+        out.push(Footprint { reference, layer, x: *at.first().unwrap_or(&0.0), y: *at.get(1).unwrap_or(&0.0), rot: *at.get(2).unwrap_or(&0.0) });+    }+    out+}++/// Copper layers declared in the board's `(layers ...)` section.+pub fn copper_layers(text: &str) -> usize {+    let t = text.as_bytes();+    let Some(root) = text.find('(') else { return 0 };+    for (s, _) in children(t, root) {+        if head(&text[s..]) != "layers" { continue; }+        return children(t, s).iter().filter(|(a, b)| quoted(&text[*a..*b], 0).map(|n| n.ends_with(".Cu")).unwrap_or(false)).count();+    }+    0+}++#[cfg(test)]+mod tests {+    use super::*;+    const PCB: &str = r#"(kicad_pcb (version 20250114) (generator "pcbnew")+	(layers (0 "F.Cu" signal) (4 "In1.Cu" power) (6 "In2.Cu" power) (2 "B.Cu" signal) (9 "F.Adhes" user "F.Adhesive"))+	(footprint "Resistor_SMD:R_0603" (layer "F.Cu") (uuid "a")+		(at 10.5 20.25 90)+		(property "Reference" "R1" (at 0 -1.4 90) (layer "F.SilkS"))+		(pad "1" smd rect (at -0.8 0) (size 0.9 0.9) (layers "F.Cu"))+	)+	(footprint "Package_DFN_QFN:QFN-48" (layer "B.Cu") (uuid "b")+		(at 30 40 225)+		(property "Reference" "U1 (\"x\")" (at 0 0) (layer "B.SilkS"))+	)+	(gr_text "R9" (at 1 1))+)"#;++    #[test]+    fn reads_footprints_and_layers() {+        let f = footprints(PCB);+        assert_eq!(f.len(), 2);+        assert_eq!(f[0].reference, "R1");+        assert_eq!((f[0].x, f[0].y, f[0].rot), (10.5, 20.25, 90.0));+        assert_eq!(f[1].layer, "B.Cu");+        assert_eq!(f[1].rot, 225.0);+        assert_eq!(copper_layers(PCB), 4);+    }+}
src/kicad_out.rsadded+401
@@ -0,0 +1,401 @@+//! The KiCad copy. Footprints come from KiCad's own library (service-kicad, cached under lib/), lugs+//! measured on the photo get a generated footprint, pads carry their nets, zones take the fitted+//! outlines and their settings, and every zone is filled here by a raster fill (zone minus other-net+//! copper grown by the clearance, islands without same-net copper removed, holes bridged into one+//! outline as KiCad stores fills), so the board shows its pours the moment it opens.+use crate::raster::{self, Mask};+use crate::sexp::{self, a, f, l, q, N};+use serde_json::Value;+use std::collections::HashMap;+use std::path::{Path, PathBuf};++pub fn uuid(seed: &str) -> String {+    let mut h1: u64 = 0xcbf29ce484222325; let mut h2: u64 = 0x84222325cbf29ce4;+    for b in seed.bytes() { h1 = (h1 ^ b as u64).wrapping_mul(0x100000001b3); h2 = (h2 ^ b as u64).wrapping_mul(0x1000193).rotate_left(7); }+    let s = format!("{h1:016x}{h2:016x}");+    format!("{}-{}-4{}-a{}-{}", &s[0..8], &s[8..12], &s[13..16], &s[17..20], &s[20..32])+}++fn fv(v: &Value, k: &str) -> f64 { v[k].as_f64().unwrap_or(0.0) }+fn pt(v: &Value) -> (f64, f64) { (v[0].as_f64().unwrap_or(0.0), v[1].as_f64().unwrap_or(0.0)) }++/// A lug footprint from measured sizes: one square plated pad with its hole.+pub fn lug_footprint(pad: f64, drill: f64) -> String {+    format!("(footprint \"Lug_{pad:.1}mm_Drill{drill:.2}mm\" (layer \"F.Cu\") (attr through_hole)\n (property \"Reference\" \"REF**\" (at 0 {:.2}) (layer \"F.SilkS\") (effects (font (size 1 1) (thickness 0.15))))\n (property \"Value\" \"Lug\" (at 0 {:.2}) (layer \"F.Fab\") (effects (font (size 1 1) (thickness 0.15))))\n (fp_rect (start {:.3} {:.3}) (end {:.3} {:.3}) (stroke (width 0.05) (type solid)) (fill no) (layer \"F.CrtYd\"))\n (pad \"1\" thru_hole roundrect (at 0 0) (size {pad:.3} {pad:.3}) (drill {drill:.3}) (layers \"*.Cu\" \"*.Mask\") (roundrect_rratio 0.15)))\n",+        -pad / 2.0 - 1.2, pad / 2.0 + 1.2, -pad / 2.0 - 0.25, -pad / 2.0 - 0.25, pad / 2.0 + 0.25, pad / 2.0 + 0.25)+}++/// Fetch a library footprint (cached). `id` is "Library:Name".+pub fn footprint_text(cache: &Path, id: &str, part: &Value) -> Result<String, String> {+    if id == "generated:lug" { return Ok(lug_footprint(fv(&part["lug"], "pad_mm").max(1.0), fv(&part["lug"], "drill_mm").max(0.3))); }+    if id == "generated:hole" {+        let (pad, drill) = (fv(&part["hole"], "pad_mm"), fv(&part["hole"], "drill_mm").max(0.3));+        if pad <= drill { return Ok(format!("(footprint \"Hole_NPTH_{drill:.2}mm\" (layer \"F.Cu\") (attr exclude_from_pos_files exclude_from_bom)\n (property \"Reference\" \"REF**\" (at 0 {:.2}) (layer \"F.SilkS\") (effects (font (size 1 1) (thickness 0.15))))\n (property \"Value\" \"Hole\" (at 0 {:.2}) (layer \"F.Fab\") (effects (font (size 1 1) (thickness 0.15))))\n (pad \"\" np_thru_hole circle (at 0 0) (size {drill:.3} {drill:.3}) (drill {drill:.3}) (layers \"*.Cu\" \"*.Mask\")))\n", -drill - 1.0, drill + 1.0)); }+        return Ok(format!("(footprint \"Hole_Plated_{pad:.2}mm_Drill{drill:.2}mm\" (layer \"F.Cu\") (attr through_hole)\n (property \"Reference\" \"REF**\" (at 0 {:.2}) (layer \"F.SilkS\") (effects (font (size 1 1) (thickness 0.15))))\n (property \"Value\" \"Hole\" (at 0 {:.2}) (layer \"F.Fab\") (effects (font (size 1 1) (thickness 0.15))))\n (pad \"1\" thru_hole circle (at 0 0) (size {pad:.3} {pad:.3}) (drill {drill:.3}) (layers \"*.Cu\" \"*.Mask\")))\n", -pad - 1.0, pad + 1.0));+    }+    let (lib, name) = id.split_once(':').ok_or(format!("footprint {id:?} is not Library:Name"))?;+    let p = cache.join(lib).join(format!("{name}.kicad_mod"));+    if !p.is_file() {+        std::fs::create_dir_all(p.parent().unwrap()).ok();+        let o = std::process::Command::new("service-kicad").args(["fp", "fetch", lib, name, "--out"]).arg(&p).output().map_err(|e| format!("service-kicad not runnable: {e}"))?;+        if !p.is_file() || std::fs::metadata(&p).map(|m| m.len() < 50).unwrap_or(true) {+            let _ = std::fs::remove_file(&p);+            return Err(format!("footprint {id} not in KiCad's library ({})", String::from_utf8_lossy(&o.stderr).trim().chars().take(160).collect::<String>()));+        }+    }+    std::fs::read_to_string(&p).map_err(|e| e.to_string())+}++#[derive(Clone, Debug)]+pub struct Pad { pub part: String, pub num: String, pub x: f64, pub y: f64, pub w: f64, pub h: f64, pub rot: f64, pub round: bool, pub drill: f64, pub layers: Vec<String>, pub net: String }++impl Pad {+    pub fn on(&self, layer: &str) -> bool { self.layers.iter().any(|l| l == layer || l == "*.Cu" || (l == "F&B.Cu" && (layer == "F.Cu" || layer == "B.Cu"))) || (self.drill > 0.0 && self.layers.iter().any(|l| l == "*.Cu")) }+}++/// KiCad places a footprint child at fp + R(rot) * local, rotation counter-clockwise on screen.+pub fn place(x: f64, y: f64, rot: f64, lx: f64, ly: f64) -> (f64, f64) {+    let (c, s) = (rot.to_radians().cos(), rot.to_radians().sin());+    (x + lx * c + ly * s, y - lx * s + ly * c)+}++/// One placed footprint node and its pads in board coordinates.+pub fn placed(text: &str, id: &str, part: &Value, nets: &HashMap<String, String>, net_idx: &HashMap<String, usize>) -> Result<(N, Vec<Pad>), String> {+    let mut n = sexp::parse(text)?;+    let r = part["ref"].as_str().unwrap_or("?").to_string();+    let (x, y) = pt(&part["pos_mm"]); let rot = fv(part, "rot");+    let side = part["side"].as_str().unwrap_or("top");+    if side == "bottom" { return Err(format!("{r}: bottom-side parts are not written yet in 0.2; place it on the top or add the flip")); }+    let items = n.items_mut();+    items[1] = q(id);+    items.retain(|c| !matches!(c.head(), "version" | "generator" | "generator_version" | "embedded_fonts" | "uuid" | "at" | "tedit"));+    let li = items.iter().position(|c| c.head() == "layer").map(|i| i + 1).unwrap_or(2);+    items.insert(li, l(vec![a("at"), f(x), f(y), f(rot)]));+    items.insert(li + 1, l(vec![a("uuid"), q(&uuid(&format!("fp/{r}")))]));+    let mut pads = vec![];+    for c in items.iter_mut() {+        match c.head() {+            "property" => {+                let key = c.arg(0).unwrap_or_default();+                if key == "Reference" { c.items_mut()[2] = q(&r); }+                if key == "Value" { c.items_mut()[2] = q(part["value"].as_str().unwrap_or("")); }+                if let Some(at) = c.child_mut("at") { let ang = at.num(2).unwrap_or(0.0) + rot; let v = at.items_mut(); v.truncate(3); v.push(f(ang)); }+            }+            "fp_text" => { if let Some(at) = c.child_mut("at") { let ang = at.num(2).unwrap_or(0.0) + rot; let v = at.items_mut(); v.truncate(3); v.push(f(ang)); } }+            "pad" => {+                let num = c.arg(0).unwrap_or_default();+                let shape = c.arg(2).unwrap_or_default();+                let at = c.child("at").cloned().unwrap_or(l(vec![a("at"), a("0"), a("0")]));+                let (lx, ly, lr) = (at.num(0).unwrap_or(0.0), at.num(1).unwrap_or(0.0), at.num(2).unwrap_or(0.0));+                if let Some(atn) = c.child_mut("at") { let v = atn.items_mut(); v.truncate(3); v.push(f(lr + rot)); }+                let size = c.child("size"); let (w, h) = (size.and_then(|s| s.num(0)).unwrap_or(0.0), size.and_then(|s| s.num(1)).unwrap_or(0.0));+                let drill = c.child("drill").and_then(|d| d.num(0).or_else(|| d.num(1))).unwrap_or(0.0);+                let layers: Vec<String> = c.child("layers").map(|ls| ls.items()[1..].iter().map(|x| sexp::unq(&match x { N::A(s) => s.clone(), _ => String::new() })).collect()).unwrap_or_default();+                let net = nets.get(&format!("{r}.{num}")).cloned().unwrap_or_default();+                c.items_mut().retain(|k| k.head() != "net" && k.head() != "uuid");+                if !net.is_empty() { c.items_mut().push(l(vec![a("net"), a(net_idx[&net]), q(&net)])); }+                c.items_mut().push(l(vec![a("uuid"), q(&uuid(&format!("pad/{r}/{num}/{lx}/{ly}")))]));+                let (px, py) = place(x, y, rot, lx, ly);+                if !num.is_empty() || shape != "" { pads.push(Pad { part: r.clone(), num, x: px, y: py, w, h, rot: lr + rot, round: shape == "circle" || (shape == "oval" && (w - h).abs() < 1e-6), drill, layers, net }); }+            }+            _ => {}+        }+    }+    Ok((n, pads))+}++/// Raster of board copper at `res` px/mm over the board box.+pub struct Grid { pub x0: f64, pub y0: f64, pub res: f64, pub w: usize, pub h: usize }+impl Grid {+    pub fn px(&self, x: f64, y: f64) -> (f64, f64) { ((x - self.x0) * self.res, (y - self.y0) * self.res) }+    pub fn mm(&self, u: f64, v: f64) -> (f64, f64) { (self.x0 + u / self.res, self.y0 + v / self.res) }+    pub fn empty(&self) -> Mask { Mask::new(self.w, self.h) }+    pub fn poly(&self, m: &mut Mask, poly: &[(f64, f64)]) {+        let p: Vec<(f64, f64)> = poly.iter().map(|q| self.px(q.0, q.1)).collect();+        for v in 0..self.h { let yc = v as f64 + 0.5; let mut xs = vec![];+            for i in 0..p.len() { let (a, b) = (p[i], p[(i + 1) % p.len()]); if (a.1 <= yc && b.1 > yc) || (b.1 <= yc && a.1 > yc) { xs.push(a.0 + (yc - a.1) / (b.1 - a.1) * (b.0 - a.0)); } }+            xs.sort_by(|a, b| a.partial_cmp(b).unwrap());+            for c in xs.chunks(2) { if c.len() == 2 { for u in (c[0].max(0.0).round() as usize)..(c[1].min(self.w as f64).round() as usize) { m.set(u, v, true); } } } }+    }+    pub fn pad(&self, m: &mut Mask, p: &Pad, grow: f64) {+        let (cx, cy) = self.px(p.x, p.y);+        let (hw, hh) = ((p.w / 2.0 + grow) * self.res, (p.h / 2.0 + grow) * self.res);+        let (c, s) = (p.rot.to_radians().cos(), p.rot.to_radians().sin());+        let r = hw.max(hh).ceil() as i64 + 1;+        for dv in -r..=r { for du in -r..=r {+            let (u, v) = (cx as i64 + du, cy as i64 + dv); if u < 0 || v < 0 || u >= self.w as i64 || v >= self.h as i64 { continue; }+            let (dx, dy) = (du as f64 + 0.5 - (cx - cx.floor()), dv as f64 + 0.5 - (cy - cy.floor()));+            let (lx, ly) = (dx * c - dy * s, dx * s + dy * c);+            let inside = if p.round { (lx / hw).powi(2) + (ly / hh).powi(2) <= 1.0 } else { lx.abs() <= hw && ly.abs() <= hh };+            if inside { m.set(u as usize, v as usize, true); }+        } }+    }+    pub fn disk(&self, m: &mut Mask, x: f64, y: f64, rad: f64) {+        let (cx, cy) = self.px(x, y); let r = rad * self.res; let ri = r.ceil() as i64;+        for dv in -ri..=ri { for du in -ri..=ri { if (du * du + dv * dv) as f64 > r * r { continue; }+            let (u, v) = (cx as i64 + du, cy as i64 + dv); if u >= 0 && v >= 0 && u < self.w as i64 && v < self.h as i64 { m.set(u as usize, v as usize, true); } } }+    }+    pub fn seg(&self, m: &mut Mask, a: (f64, f64), b: (f64, f64), wd: f64) {+        let n = ((crate::geom::dist(a, b) * self.res) as usize).max(1);+        for i in 0..=n { let t = i as f64 / n as f64; self.disk(m, a.0 + (b.0 - a.0) * t, a.1 + (b.1 - a.1) * t, wd / 2.0); }+    }+}++/// Join holes into the outer ring with slits that never cross: each hole, left to right, joins from its leftmost+/// vertex straight left to the first edge of what has been built so far. Done in any other order, or to the nearest+/// vertex, slits cross on a pour with hundreds of holes and KiCad drops the polygon.+pub fn keyhole(outer: Vec<(f64, f64)>, mut holes: Vec<Vec<(f64, f64)>>) -> Vec<(f64, f64)> {+    // a hole winds against its outline: KiCad's triangulation reads a hole wound the same way as copper+    let area = |r: &[(f64, f64)]| (0..r.len()).map(|i| { let (a, b) = (r[i], r[(i + 1) % r.len()]); a.0 * b.1 - b.0 * a.1 }).sum::<f64>();+    let outer_sign = area(&outer).signum();+    for h in holes.iter_mut() { if area(h).signum() == outer_sign { h.reverse(); } }+    let left = |h: &Vec<(f64, f64)>| (0..h.len()).min_by(|a, b| h[*a].0.partial_cmp(&h[*b].0).unwrap().then(h[*a].1.partial_cmp(&h[*b].1).unwrap())).unwrap();+    holes.sort_by(|a, b| a[left(a)].0.partial_cmp(&b[left(b)].0).unwrap());+    let mut ring = outer;+    for h in holes {+        let k = left(&h); let p = h[k];+        // the first edge straight left of p+        let mut best: Option<(f64, usize)> = None;+        for i in 0..ring.len() {+            let (a, b) = (ring[i], ring[(i + 1) % ring.len()]);+            if (a.1 <= p.1) == (b.1 <= p.1) { continue; }+            let x = a.0 + (p.1 - a.1) / (b.1 - a.1) * (b.0 - a.0);+            if x <= p.0 && best.map(|q| x > q.0).unwrap_or(true) { best = Some((x, i)); }+        }+        let Some((x, i)) = best else { continue };+        let at = (x, p.1);+        let mut ins = vec![at];+        for j in 0..=h.len() { ins.push(h[(k + j) % h.len()]); }+        ins.push(at);+        ring.splice(i + 1..i + 1, ins);+    }+    ring+}++/// The filled region of a zone as KiCad polygon rings in mm (each a fractured ring: holes bridged in).+pub fn vectorize(g: &Grid, fill: &Mask) -> Vec<Vec<(f64, f64)>> {+    let (lab, cs) = raster::components(fill);+    let mut rings = vec![];+    for c in &cs {+        if c.area < 6 { continue; }+        let outer: Vec<(f64, f64)> = crate::geom::simplify(&raster::trace_boundary(&lab, fill.w, fill.h, c), 0.8);+        if outer.len() < 3 { continue; }+        // holes: background components enclosed by this component+        let (x0, y0, x1, y1) = (c.x0, c.y0, c.x1, c.y1);+        let mut sub = Mask::new(x1 - x0 + 3, y1 - y0 + 3);+        for y in y0..=y1 { for x in x0..=x1 { if lab[y * fill.w + x] == c.label { sub.set(x - x0 + 1, y - y0 + 1, true); } } }+        let holes = sub.fill_holes().andnot(&sub);+        let (hl, hc) = raster::components(&holes);+        let mut holes_px: Vec<Vec<(f64, f64)>> = vec![];+        for hcomp in hc.iter().filter(|h| h.area >= 4) {+            let hr: Vec<(f64, f64)> = crate::geom::simplify(&raster::trace_boundary(&hl, sub.w, sub.h, hcomp), 0.8).iter().map(|p| (p.0 + x0 as f64 - 1.0, p.1 + y0 as f64 - 1.0)).collect();+            if hr.len() >= 3 { holes_px.push(hr); }+        }+        let ring = keyhole(outer, holes_px);+        rings.push(ring.iter().map(|p| { let m = g.mm(p.0, p.1); ((m.0 * 1000.0).round() / 1000.0, (m.1 * 1000.0).round() / 1000.0) }).collect());+    }+    rings+}++/// A mask as the runs of pixels it sets: small pours subtract in the time their size takes, not the board's.+struct Sparse(Vec<usize>);+impl Sparse {+    fn of(m: &Mask) -> Sparse { Sparse((0..m.d.len()).filter(|i| m.d[*i]).collect()) }+    fn subtract_from(&self, m: &mut Mask) { for i in &self.0 { m.d[*i] = false; } }+}++pub struct Board { pub text: String, pub pads: Vec<Pad>, pub fills: Vec<(String, String, Mask)>, pub grid: Grid, pub warnings: Vec<String> }++/// Write the board. `layers` is the copper layer count (2, 4, 6).+pub fn board(cache: &Path, parts: &[Value], copper: &Value, nets_doc: &Value, layers: usize, title: &str) -> Result<Board, String> { board_opt(cache, parts, copper, nets_doc, layers, title, true) }++/// `filled`: write each zone's computed fill. A board for the router goes unfilled, so the router's gate+/// fills around the copper it adds instead of checking it against a fill drawn before it existed.+pub fn board_opt(cache: &Path, parts: &[Value], copper: &Value, nets_doc: &Value, layers: usize, title: &str, filled: bool) -> Result<Board, String> {+    let mut warnings = vec![];+    // nets: from nets.json pins, plus every zone, trace and via net+    let mut pin_net: HashMap<String, String> = HashMap::new();+    let mut names: Vec<String> = vec![];+    let mut add = |n: &str, names: &mut Vec<String>| { if !n.is_empty() && !names.iter().any(|x| x == n) { names.push(n.to_string()); } };+    for n in nets_doc["nets"].as_array().into_iter().flatten() {+        let name = n["name"].as_str().unwrap_or(""); add(name, &mut names);+        for p in n["pins"].as_array().into_iter().flatten().filter_map(|p| p.as_str()) { pin_net.insert(p.to_string(), name.to_string()); }+    }+    for k in ["zones", "traces", "vias"] { for z in copper[k].as_array().into_iter().flatten() { add(z["net"].as_str().unwrap_or(""), &mut names); } }+    let net_idx: HashMap<String, usize> = names.iter().enumerate().map(|(i, n)| (n.clone(), i + 1)).collect();+    // footprints+    let mut fps = vec![]; let mut pads = vec![];+    for p in parts {+        if p["onBoard"] == Value::Bool(false) { continue; }+        let id = p["footprint"].as_str().unwrap_or("");+        if id.is_empty() { warnings.push(format!("{}: no footprint", p["ref"].as_str().unwrap_or("?"))); continue; }+        match footprint_text(cache, id, p).and_then(|t| placed(&t, match id { "generated:lug" => "Generated:Lug", "generated:hole" => "Generated:Hole", _ => id }, p, &pin_net, &net_idx)) {+            Ok((n, ps)) => { fps.push(n); pads.extend(ps); }+            Err(e) => warnings.push(e),+        }+    }+    // outline+    let outline: Vec<(f64, f64)> = copper["outline"]["points_mm"].as_array().ok_or("copper.json has no outline.points_mm")?.iter().map(pt).collect();+    let (bx0, by0) = outline.iter().fold((f64::MAX, f64::MAX), |a, p| (a.0.min(p.0), a.1.min(p.1)));+    let (bx1, by1) = outline.iter().fold((f64::MIN, f64::MIN), |a, p| (a.0.max(p.0), a.1.max(p.1)));+    let radius = copper["outline"]["corner_mm"].as_f64().unwrap_or(0.0);+    let grid = Grid { x0: bx0, y0: by0, res: 20.0, w: ((bx1 - bx0) * 20.0).ceil() as usize + 1, h: ((by1 - by0) * 20.0).ceil() as usize + 1 };+    let cu_layers: Vec<String> = { let mut v = vec!["F.Cu".to_string()]; for i in 1..layers.saturating_sub(1) { v.push(format!("In{i}.Cu")); } v.push("B.Cu".into()); v };+    // board area inset by the edge clearance+    let inside = board_inside(&grid, &outline, radius);+    // zones stay the edge clearance inside the board, plus one raster cell+    let edge_ok = inside.erode(((copper["edge_clearance_mm"].as_f64().unwrap_or(0.5) + 1.0 / 20.0) * grid.res) as f32);+    // fills, highest priority first+    let mut zones: Vec<Value> = copper["zones"].as_array().cloned().unwrap_or_default();+    zones.sort_by(|a, b| fv(b, "priority").partial_cmp(&fv(a, "priority")).unwrap());+    let mut fills: Vec<(String, String, Mask)> = vec![];+    let mut dilated: Vec<(String, String, Sparse)> = vec![];+    let mut zone_nodes = vec![];+    for z in &zones {+        let layer = z["layer"].as_str().unwrap_or("F.Cu").to_string();+        if !cu_layers.contains(&layer) { warnings.push(format!("zone {} on {layer}: not a layer of a {layers}-layer board", z["id"])); continue; }+        let net = z["net"].as_str().unwrap_or("").to_string();+        let poly: Vec<(f64, f64)> = z["polygon_mm"].as_array().map(|a| a.iter().map(pt).collect()).unwrap_or_else(|| outline.clone());+        // one raster cell of margin, so a fill drawn at 20 px/mm never comes closer than the clearance+        let clr_rule = z["clearance_mm"].as_f64().unwrap_or(0.2);+        let clr = clr_rule + 2.0 / 20.0;+        let minw = z["min_width_mm"].as_f64().unwrap_or(0.2);+        let zm = zone_mask(&grid, z, &poly);+        let mut fill = zm.and(&edge_ok);+        let mut other = grid.empty(); let mut same = grid.empty();+        // only what is near this zone matters+        let zb = { let (mut a, mut b, mut c, mut d) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN); for q in &poly { a = a.min(q.0); b = b.min(q.1); c = c.max(q.0); d = d.max(q.1); } (a - 2.0, b - 2.0, c + 2.0, d + 2.0) };+        let nearby = |x: f64, y: f64| x >= zb.0 && x <= zb.2 && y >= zb.1 && y <= zb.3;+        for p in pads.iter().filter(|p| nearby(p.x, p.y)) { if p.on(&layer) { if p.net == net && !net.is_empty() { grid.pad(&mut same, p, 0.0); } else { grid.pad(&mut other, p, clr); } } }+        for t in copper["traces"].as_array().into_iter().flatten().filter(|t| t["layer"] == Value::String(layer.clone())) {+            let path: Vec<(f64, f64)> = t["path_mm"].as_array().map(|a| a.iter().map(pt).collect()).unwrap_or_default();+            let w = fv(t, "width_mm").max(0.1);+            for s in path.windows(2) { if !nearby(s[0].0, s[0].1) && !nearby(s[1].0, s[1].1) && crate::geom::dist(s[0], s[1]) < 4.0 { continue; } if t["net"].as_str() == Some(&net) { grid.seg(&mut same, s[0], s[1], w); } else { grid.seg(&mut other, s[0], s[1], w + 2.0 * clr); } }+        }+        for v in copper["vias"].as_array().into_iter().flatten().filter(|v| !v["net"].as_str().unwrap_or("").is_empty()) {+            let (x, y) = pt(&v["pos_mm"]); let d = v["diameter_mm"].as_f64().unwrap_or(0.6);+            if !nearby(x, y) { continue; }+            if v["net"].as_str() == Some(&net) { grid.disk(&mut same, x, y, d / 2.0); } else { grid.disk(&mut other, x, y, d / 2.0 + clr); }+        }+        for (_, fl, fm) in dilated.iter().filter(|(fl2, _, _)| *fl2 == layer) { if *fl != net { fm.subtract_from(&mut fill); } }+        fill = fill.andnot(&other).open((minw / 2.0 * grid.res) as f32);+        // islands: keep the pieces that touch same-net copper (all, if the layer has none of that net)+        let (lab, cs) = raster::components(&fill);+        let touch: std::collections::HashSet<u32> = (0..lab.len()).filter(|i| same.d[*i] && lab[*i] != 0).map(|i| lab[i]).collect();+        if !touch.is_empty() { for i in 0..lab.len() { if lab[i] != 0 && !touch.contains(&lab[i]) { fill.d[i] = false; } } }+        else if cs.len() > 1 { warnings.push(format!("zone {} ({net} on {layer}): no {net} copper inside it on this layer, so every island is kept", z["id"])); }+        let rings = vectorize(&grid, &fill);+        let mut zn = vec![a("zone"), l(vec![a("net"), a(net_idx.get(&net).copied().unwrap_or(0))]), l(vec![a("net_name"), q(&net)]), l(vec![a("layer"), q(&layer)]),+            l(vec![a("uuid"), q(&uuid(&format!("zone/{}", z["id"])))]), l(vec![a("name"), q(z["id"].as_str().unwrap_or(""))]), l(vec![a("hatch"), a("edge"), a("0.5")])];+        if let Some(p) = z["priority"].as_u64() { zn.push(l(vec![a("priority"), a(p)])); }+        let connect = z["connect"].as_str().unwrap_or("thermal");+        let mut cp = vec![a("connect_pads")]; if connect == "solid" { cp.push(a("yes")); } cp.push(l(vec![a("clearance"), f(clr_rule)])); zn.push(l(cp));+        zn.push(l(vec![a("min_thickness"), f(minw)])); zn.push(l(vec![a("filled_areas_thickness"), a("no")]));+        zn.push(l(vec![a("fill"), a("yes"), l(vec![a("thermal_gap"), f(z["thermal_gap_mm"].as_f64().unwrap_or(0.3))]), l(vec![a("thermal_bridge_width"), f(z["spoke_mm"].as_f64().unwrap_or(0.3))]), l(vec![a("island_removal_mode"), a("0")])]));+        zn.push(l(vec![a("polygon"), l(std::iter::once(a("pts")).chain(poly.iter().map(|p| l(vec![a("xy"), f(p.0), f(p.1)]))).collect())]));+        for r in rings.into_iter().filter(|_| filled) { zn.push(l(vec![a("filled_polygon"), l(vec![a("layer"), q(&layer)]), l(std::iter::once(a("pts")).chain(r.iter().map(|p| l(vec![a("xy"), f(p.0), f(p.1)]))).collect())])); }+        zone_nodes.push(l(zn));+        dilated.push((layer.clone(), net.clone(), Sparse::of(&fill.dilate((clr * grid.res) as f32))));+        fills.push((layer, net, fill));+    }+    // the file+    let mut out = String::from("(kicad_pcb\n\t(version 20241229)\n\t(generator \"adom-aiflow-reverse\")\n\t(generator_version \"9.0\")\n\t(general (thickness 1.6) (legacy_teardrops no))\n\t(paper \"A4\")\n");+    out += &format!("\t(title_block (title \"{}\") (comment 1 \"Generated by adom-aiflow-reverse from photos: see reverse-flow/ for the evidence of every item\"))\n", title.replace('"', "'"));+    out += "\t(layers\n\t\t(0 \"F.Cu\" signal)\n";+    for i in 1..layers.saturating_sub(1) { out += &format!("\t\t({} \"In{i}.Cu\" signal)\n", 2 + 2 * i); }+    out += "\t\t(2 \"B.Cu\" signal)\n\t\t(9 \"F.Adhes\" user \"F.Adhesive\")\n\t\t(11 \"B.Adhes\" user \"B.Adhesive\")\n\t\t(13 \"F.Paste\" user)\n\t\t(15 \"B.Paste\" user)\n\t\t(5 \"F.SilkS\" user \"F.Silkscreen\")\n\t\t(7 \"B.SilkS\" user \"B.Silkscreen\")\n\t\t(1 \"F.Mask\" user)\n\t\t(3 \"B.Mask\" user)\n\t\t(17 \"Dwgs.User\" user \"User.Drawings\")\n\t\t(19 \"Cmts.User\" user \"User.Comments\")\n\t\t(25 \"Edge.Cuts\" user)\n\t\t(27 \"Margin\" user)\n\t\t(31 \"F.CrtYd\" user \"F.Courtyard\")\n\t\t(29 \"B.CrtYd\" user \"B.Courtyard\")\n\t\t(35 \"F.Fab\" user)\n\t\t(33 \"B.Fab\" user))\n";+    out += "\t(setup (pad_to_mask_clearance 0) (allow_soldermask_bridges_in_footprints no))\n\t(net 0 \"\")\n";+    for (i, n) in names.iter().enumerate() { out += &format!("\t(net {} \"{}\")\n", i + 1, n.replace('"', "'")); }+    for n in &fps { out += "\t"; out += &n.to_string(); out += "\n"; }+    // Edge.Cuts: the outline, with arcs where a corner radius was measured+    let edge = |s: (f64, f64), e: (f64, f64), k: usize| format!("\t(gr_line (start {:.4} {:.4}) (end {:.4} {:.4}) (stroke (width 0.1) (type default)) (layer \"Edge.Cuts\") (uuid \"{}\"))\n", s.0, s.1, e.0, e.1, uuid(&format!("edge/{k}")));+    if radius > 0.0 && outline.len() == 4 {+        let r = radius; let (x0, y0, x1, y1) = (bx0, by0, bx1, by1); let c45 = r * (1.0 - std::f64::consts::FRAC_1_SQRT_2);+        out += &edge((x0 + r, y0), (x1 - r, y0), 0); out += &edge((x1, y0 + r), (x1, y1 - r), 1); out += &edge((x1 - r, y1), (x0 + r, y1), 2); out += &edge((x0, y1 - r), (x0, y0 + r), 3);+        for (k, (s, m, e)) in [((x0, y0 + r), (x0 + c45, y0 + c45), (x0 + r, y0)), ((x1 - r, y0), (x1 - c45, y0 + c45), (x1, y0 + r)), ((x1, y1 - r), (x1 - c45, y1 - c45), (x1 - r, y1)), ((x0 + r, y1), (x0 + c45, y1 - c45), (x0, y1 - r))].iter().enumerate() {+            out += &format!("\t(gr_arc (start {:.4} {:.4}) (mid {:.4} {:.4}) (end {:.4} {:.4}) (stroke (width 0.1) (type default)) (layer \"Edge.Cuts\") (uuid \"{}\"))\n", s.0, s.1, m.0, m.1, e.0, e.1, uuid(&format!("arc/{k}")));+        }+    } else { for i in 0..outline.len() { out += &edge(outline[i], outline[(i + 1) % outline.len()], i); } }+    for (k, t) in copper["traces"].as_array().into_iter().flatten().enumerate() {+        let path: Vec<(f64, f64)> = t["path_mm"].as_array().map(|a| a.iter().map(pt).collect()).unwrap_or_default();+        let net = net_idx.get(t["net"].as_str().unwrap_or("")).copied().unwrap_or(0);+        for (j, s) in path.windows(2).enumerate() {+            out += &format!("\t(segment (start {:.4} {:.4}) (end {:.4} {:.4}) (width {:.3}) (layer \"{}\") (net {net}) (uuid \"{}\"))\n", s[0].0, s[0].1, s[1].0, s[1].1, fv(t, "width_mm").max(0.1), t["layer"].as_str().unwrap_or("F.Cu"), uuid(&format!("seg/{k}/{j}")));+        }+    }+    for (k, v) in copper["vias"].as_array().into_iter().flatten().enumerate() {+        // a via whose net is not known yet is evidence only: it is not written into the board+        if v["net"].as_str().unwrap_or("").is_empty() { continue; }+        let (x, y) = pt(&v["pos_mm"]); let net = net_idx.get(v["net"].as_str().unwrap_or("")).copied().unwrap_or(0);+        out += &format!("\t(via (at {x:.4} {y:.4}) (size {:.3}) (drill {:.3}) (layers \"F.Cu\" \"B.Cu\") (net {net}) (uuid \"{}\"))\n", v["diameter_mm"].as_f64().unwrap_or(0.6), v["drill_mm"].as_f64().unwrap_or(0.3), uuid(&format!("via/{k}")));+    }+    for z in &zone_nodes { out += "\t"; out += &z.to_string(); out += "\n"; }+    out += ")\n";+    Ok(Board { text: out, pads, fills, grid, warnings })+}++/// A zone's copper: its exact runs when it was traced from a photo, otherwise everything inside its outline.+pub fn zone_mask(g: &Grid, z: &Value, poly: &[(f64, f64)]) -> Mask {+    let mut m = g.empty();+    if let Some(rows) = z["rle"].as_array() {+        for r in rows { let Some(a) = r.as_array() else { continue }; let v = a[0].as_u64().unwrap_or(0) as usize; if v >= g.h { continue; }+            for c in a[1..].chunks(2) { if c.len() < 2 { continue; } let (u0, u1) = (c[0].as_u64().unwrap_or(0) as usize, (c[1].as_u64().unwrap_or(0) as usize).min(g.w)); for u in u0..u1 { m.d[v * g.w + u] = true; } } }+    } else { g.poly(&mut m, poly); }+    m+}++/// The board's area as a mask, rounded corners included.+pub fn board_inside(g: &Grid, outline: &[(f64, f64)], radius: f64) -> Mask {+    let mut m = g.empty();+    if radius > 0.0 && outline.len() == 4 {+        let (x0, y0) = outline.iter().fold((f64::MAX, f64::MAX), |a, p| (a.0.min(p.0), a.1.min(p.1)));+        let (x1, y1) = outline.iter().fold((f64::MIN, f64::MIN), |a, p| (a.0.max(p.0), a.1.max(p.1)));+        let mut poly = vec![];+        for (cx, cy, a0) in [(x1 - radius, y0 + radius, -90.0f64), (x1 - radius, y1 - radius, 0.0), (x0 + radius, y1 - radius, 90.0), (x0 + radius, y0 + radius, 180.0)] {+            for k in 0..=12 { let t = (a0 + k as f64 * 7.5).to_radians(); poly.push((cx + radius * t.cos(), cy + radius * t.sin())); }+        }+        g.poly(&mut m, &poly);+    } else { g.poly(&mut m, outline); }+    m+}++pub fn cache_dir(flow: &Path) -> PathBuf { flow.join("lib").join("fp") }++#[cfg(test)]+mod tests {+    use super::*;+    #[test]+    fn placement_matches_kicad() {+        // a pad at local (1, 0) of a footprint at (10, 10) turned 90 degrees lands above it (y smaller)+        let p = place(10.0, 10.0, 90.0, 1.0, 0.0);+        assert!((p.0 - 10.0).abs() < 1e-9 && (p.1 - 9.0).abs() < 1e-9, "{p:?}");+        assert_eq!(uuid("a").len(), 36);+    }+}++#[cfg(test)]+mod keyhole_tests {+    use super::*;+    fn crosses(r: &[(f64, f64)]) -> bool {+        let n = r.len();+        let side = |a: (f64, f64), b: (f64, f64), c: (f64, f64)| (b.0 - a.0) * (c.1 - a.1) - (b.1 - a.1) * (c.0 - a.0);+        for i in 0..n { for j in i + 2..n { if i == 0 && j == n - 1 { continue; }+            let (a, b, c, d) = (r[i], r[(i + 1) % n], r[j], r[(j + 1) % n]);+            let (s1, s2, s3, s4) = (side(a, b, c), side(a, b, d), side(c, d, a), side(c, d, b));+            if s1 * s2 < -1e-9 && s3 * s4 < -1e-9 { return true; }+        } }+        false+    }+    #[test]+    fn many_holes_never_cross_their_slits() {+        let outer = vec![(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)];+        let mut holes = vec![];+        for j in 0..7 { for i in 0..7 { let (x, y) = (8.0 + i as f64 * 13.0 + (j % 2) as f64 * 3.0, 8.0 + j as f64 * 13.0 + (i % 3) as f64); holes.push(vec![(x, y), (x + 3.0, y), (x + 3.0, y + 3.0), (x, y + 3.0)]); } }+        let r = keyhole(outer.clone(), holes);+        assert_eq!(r.len(), 4 + 49 * 7);+        assert!(!crosses(&r));+        // holes wind against the outline: the total area is the outline minus the holes+        let area = |r: &[(f64, f64)]| (0..r.len()).map(|i| { let (a, b) = (r[i], r[(i + 1) % r.len()]); a.0 * b.1 - b.0 * a.1 }).sum::<f64>().abs() / 2.0;+        assert!((area(&r) - (area(&outer) - 49.0 * 9.0)).abs() < 1e-6, "{}", area(&r));+    }+}
src/ledger.rsadded+50
@@ -0,0 +1,50 @@+//! The project's flow folder (`<project>/<flow>/`): state.json plus run.jsonl, one line per command+//! with the time, the calling AI thread and what it did. Never rewrite earlier lines.+use serde_json::{json, Value};+use std::io::Write;+use std::path::{Path, PathBuf};++pub fn now() -> String {+    let out = std::process::Command::new("date").args(["-u", "+%Y-%m-%dT%H:%M:%SZ"]).output();+    out.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()).unwrap_or_default()+}++#[allow(dead_code)]+pub struct Project { pub dir: PathBuf, pub flow_dir: PathBuf, pub state: Value }++impl Project {+    /// The nearest folder at or above `start` that holds `<flow>/state.json`.+    pub fn find(start: &Path, flow: &str) -> Option<Project> {+        let mut d = Some(start.to_path_buf());+        while let Some(p) = d {+            let s = p.join(flow).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: p, state });+            }+            d = p.parent().map(Path::to_path_buf);+        }+        None+    }++    pub fn create(dir: &Path, flow: &str, state: Value) -> std::io::Result<Project> {+        let flow_dir = dir.join(flow);+        std::fs::create_dir_all(&flow_dir)?;+        let p = Project { dir: dir.to_path_buf(), flow_dir, state };+        p.save()?;+        Ok(p)+    }++    pub fn save(&self) -> std::io::Result<()> {+        std::fs::write(self.flow_dir.join("state.json"), serde_json::to_string_pretty(&self.state)? + "\n")+    }++    pub fn path(&self, rel: &str) -> PathBuf { self.flow_dir.join(rel) }++    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}");+        }+    }+}
src/main.rsadded+1279
@@ -0,0 +1,1279 @@+//! adom-aiflow-reverse 0.2: reverse engineer a PCB from photos. The program measures: board outline+//! and straightening, scale from hole-row pitches and part bodies, part bodies and packages, hole rows,+//! every island of copper on both faces, vias, and what each pad and via joins. It generates the KiCad copy+//! with the photographed pours and tracks, routes what cannot be seen on its own N-layer router, and jumps+//! the layer count until every connection closes and the DRC passes. The AI names (identities, markings,+//! nets, functions). Every stage records its interactive PCB or schematic in the flow window.+mod board;+mod bridge;+mod checks;+mod connect;+mod route;+mod trace;+mod islands;+mod copper;+mod draw;+mod findings;+mod flow;+mod geom;+mod items;+mod kicad_in;+mod kicad_out;+mod ledger;+mod out;+mod parts;+mod prep;+mod raster;+mod report;+mod rules;+mod server;+mod sch;+mod sexp;+mod snap;+mod timeline;+mod views;++use clap::{Parser, Subcommand};+use out::{err, ok};+use serde_json::{json, Value};+use std::collections::{BTreeMap, HashMap, HashSet};+use std::io::Write;+use std::path::{Path, PathBuf};++const FLOW_DIR: &str = "reverse-flow";++#[derive(Parser)]+#[command(name = "adom-aiflow-reverse", version, about = "Reverse engineer a PCB from photos: the program measures the board, its parts, copper and connectivity, generates the KiCad copy and routes what cannot be seen; the AI names")]+struct Cli {+    /// The name of the AI conversation calling (required for commands that change state)+    #[arg(long, global = true)]+    ai_thread: Option<String>,+    /// Project folder (default: the nearest one above the current folder that has reverse-flow/)+    #[arg(long, global = true)]+    project: Option<String>,+    #[command(subcommand)]+    cmd: Cmd,+}++#[derive(Subcommand)]+enum Cmd {+    /// Start: `init --top <photo...> --bottom <photo...>` (photos are copied into reverse-flow/photos)+    /// `--rules kicad-default|jlc-4l|<file.json>`: the design rules every later step uses (default kicad-default)+    Init { #[arg(long, num_args = 1..)] top: Vec<String>, #[arg(long, num_args = 0..)] bottom: Vec<String>, #[arg(long)] target: Option<String>, #[arg(long, default_value = "kicad-default")] rules: String },+    /// Show or set the design rules: `rules` (show) or `rules --set <profile|file>` (only before the first build)+    Rules { #[arg(long)] set: Option<String> },+    /// What to do now+    Next,+    /// Every step with its state+    Steps,+    /// The run ledger+    Ledger,+    /// Measure every photo: outline, straightened image, sharpness, holes, hole rows, part bodies, bottom-to-top mapping+    Prep,+    /// Pixels per mm from hole pitches, confirmed by a datasheet body: `scale --body <id>=<L>x<W> --source "..."`; then the board frame+    Scale { #[arg(long)] body: Option<String>, #[arg(long)] source: Option<String> },+    /// `detect parts [--bottom]`: part candidates with package matches, crops and contact sheets (--bottom: the bottom face has parts too)+    Detect { what: String, #[arg(long)] bottom: bool },+    /// `parts init [--force]` (parts.json from the candidates), `parts snap` (measured position and rotation from each footprint's pads), `parts reconcile` (dismiss candidates that are fragments of placed parts)+    Parts { what: String, #[arg(long)] force: bool },+    /// Read a resistor or inductor marking code: `decode 59C`+    Decode { code: String },+    /// `blocks propose`: groups of parts that sit together, for naming+    Blocks { what: String },+    /// `copper prep` (gap maps, grids, via candidates), `copper trace` (every island of copper from the photo) or `copper fit` (sketched pours onto the photo's gap lines)+    Copper { what: String },+    /// Write the KiCad project (footprints, outline, fitted and filled pours, traces, vias): `build [--layers N]`+    Build { #[arg(long)] layers: Option<usize> },+    /// Check a step: prep | parts | blocks | copper | mimic --pcb <board> | nets | layers [--pcb <board>] | review | tests+    Check { what: String, #[arg(long)] pcb: Option<String> },+    /// Save the KiCad project as it stands: `snapshot --name mimic --dir <project folder>`+    Snapshot { #[arg(long)] name: String, #[arg(long)] dir: String },+    /// `connect extract` (connectivity measured on the photos) or `connect route` (the router on each stackup, jumping layers until every connection closes and the DRC passes; `--layers 4,6` to choose, `--seconds` per stackup)+    Connect { what: String, #[arg(long)] layers: Option<String>, #[arg(long, default_value_t = 480.0)] seconds: f64, #[arg(long, default_value_t = 3)] stall: usize, #[arg(long, default_value_t = 8)] max_layers: usize },+    /// `review measure` (re-run every measurement) or `review record --changes <n> --confidence confidence.json --note "..."`+    Review { what: String, #[arg(long)] changes: Option<u64>, #[arg(long)] confidence: Option<String>, #[arg(long)] note: Option<String> },+    /// Evidence tag counts per artifact+    Confidence,+    /// `tests generate`: a test for every INFERRED or UNKNOWN item+    Tests { what: String },+    /// Generated tables for the report+    Report,+    /// Register the KiCad project the flow window shows: `kicad --pcb <board> [--sch <root sheet>]`+    Kicad { #[arg(long)] pcb: String, #[arg(long)] sch: Option<String> },+    /// Record the current stage's view again after an update+    View { #[arg(long)] step: Option<String>, #[arg(long)] reason: Option<String> },+    /// The flow window+    Serve { #[arg(long, default_value_t = 8890)] port: u16 },+    /// Record one desktop window for the video: `clip start --name <step> --hwnd <window>` / `clip stop`+    Clip { what: String, #[arg(long)] name: Option<String>, #[arg(long)] hwnd: Option<i64> },+}++fn thread(cli: &Cli) -> String { cli.ai_thread.clone().unwrap_or_else(|| err("this command changes state: pass --ai-thread \"<your thread name>\"", &[])) }++fn project(cli: &Cli) -> ledger::Project {+    let start = cli.project.as_ref().map(PathBuf::from).unwrap_or_else(|| std::env::current_dir().unwrap());+    ledger::Project::find(&start, FLOW_DIR).unwrap_or_else(|| err(&format!("no {FLOW_DIR}/ project here"), &["Start one: adom-aiflow-reverse init --top <photo...> --bottom <photo...> --ai-thread <you>".into()]))+}++pub fn sha256(p: &Path) -> String {+    let o = std::process::Command::new("sha256sum").arg(p).output();+    o.ok().and_then(|o| String::from_utf8_lossy(&o.stdout).split_whitespace().next().map(str::to_string)).unwrap_or_default()+}++++fn abs(p: &Path) -> String { std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf()).display().to_string() }+fn rd(p: &Path) -> Value { items::read_json(p).unwrap_or_else(|e| err(&e, &[])) }+fn wr(p: &Path, v: &Value) { if let Some(d) = p.parent() { std::fs::create_dir_all(d).ok(); } std::fs::write(p, serde_json::to_string_pretty(v).unwrap() + "\n").unwrap_or_else(|e| err(&format!("{}: {e}", p.display()), &[])); }+fn fv(v: &Value, k: &str) -> f64 { v[k].as_f64().unwrap_or(0.0) }+fn img(p: &Path) -> image::RgbImage { image::open(p).unwrap_or_else(|e| err(&format!("{}: {e}", p.display()), &["Run the step that writes it first (`scale` writes board/).".into()])).to_rgb8() }+fn save(i: &image::RgbImage, p: &Path) { if let Some(d) = p.parent() { std::fs::create_dir_all(d).ok(); } i.save(p).unwrap_or_else(|e| err(&format!("{}: {e}", p.display()), &[])); }++fn step_hint(fl: &flow::Flow, dir: &Path) -> Vec<String> {+    match flow::current(fl, dir) {+        None => vec!["every step is done: the flow is finished".into()],+        Some(s) => {+            let mut h = vec![];+            for d in &s.done { let st = flow::stale(dir, d); if !st.is_empty() { h.push(format!("{d} is stale: {} changed after the check passed; run it again", st.join(", "))); } }+            h.push(format!("next step {}: {}", s.name, s.todo));+            h+        }+    }+}++/// Furthest step reached, and loop backs when an earlier step is re-done because its files changed.+fn note_progress(p: &mut ledger::Project, fl: &flow::Flow, idx: usize, was_stale: &[String]) {+    let reached = p.state["reached"].as_u64().map(|x| x as usize);+    if let Some(r) = reached {+        if idx < r && !was_stale.is_empty() && !p.state["rework"].is_object() {+            let lp = json!({"from": fl.steps[r].name, "to": fl.steps[idx].name, "why": format!("{} changed", was_stale.join(", ")), "at": ledger::now()});+            if !p.state["loops"].is_array() { p.state["loops"] = json!([]); }+            p.state["loops"].as_array_mut().unwrap().push(lp.clone());+            p.state["rework"] = lp;+        }+        if idx >= r { p.state["rework"] = Value::Null; }+    }+    p.state["reached"] = json!(reached.unwrap_or(0).max(idx));+}+fn reach(p: &mut ledger::Project, fl: &flow::Flow, step: &str) { if let Some(i) = fl.steps.iter().position(|s| s.name == step) { note_progress(p, fl, i, &[]); p.save().ok(); } }++fn copy_tree(src: &Path, dst: &Path, rel: &Path, files: &mut Vec<(String, String)>) -> std::io::Result<()> {+    for e in std::fs::read_dir(src.join(rel))? {+        let e = e?; let name = e.file_name().to_string_lossy().to_string();+        if name.ends_with("-backups") || name.ends_with(".lck") || name.ends_with('~') || name == "fp-info-cache" || name == ".history" { continue; }+        let r = rel.join(&name);+        if e.file_type()?.is_dir() { copy_tree(src, dst, &r, files)?; continue; }+        std::fs::create_dir_all(dst.join(&r).parent().unwrap())?;+        std::fs::copy(src.join(&r), dst.join(&r))?;+        files.push((r.display().to_string(), sha256(&dst.join(&r))));+    }+    Ok(())+}++/// The primary photo of a side: the sharpest one.+fn primary<'a>(prep: &'a Value, side: &str) -> Option<&'a Value> {+    prep["photos"].as_array()?.iter().filter(|p| p["side"] == json!(side)).max_by(|a, b| fv(a, "sharpness").partial_cmp(&fv(b, "sharpness")).unwrap())+}++/// Corner radius of the board outline: how far along each corner's diagonal the board starts.+fn corner_radius(top: &image::RgbImage) -> f64 {+    let (w, h) = (top.width() as i64, top.height() as i64);+    let mut ds = vec![];+    for (cx, cy, sx, sy) in [(0i64, 0i64, 1i64, 1i64), (w - 1, 0, -1, 1), (w - 1, h - 1, -1, -1), (0, h - 1, 1, -1)] {+        let mut d = 0; while d < 200 { let p = top.get_pixel((cx + sx * d) as u32, (cy + sy * d) as u32); let (hh, s, v) = raster::hsv(p); let wood = (0.0..45.0).contains(&hh) && s > 0.3 && v < 0.75 && !prep::is_mask_green(p); if !wood { break; } d += 1; }+        ds.push(d as f64 * std::f64::consts::SQRT_2 / board::RES);+    }+    ds.sort_by(|a, b| a.partial_cmp(b).unwrap());+    let med = (ds[1] + ds[2]) / 2.0;+    ((med / (std::f64::consts::SQRT_2 - 1.0)) * 10.0).round() / 10.0+}++fn holes_mm(i: &image::RgbImage) -> Vec<Value> {+    prep::holes(i, 0.45 * board::RES, 4.5 * board::RES).into_iter().map(|h| {+        let (x, y) = board::to_mm(fv(&h, "x"), fv(&h, "y"));+        json!({"x": (x * 1000.0).round() / 1000.0, "y": (y * 1000.0).round() / 1000.0, "hole_mm": (fv(&h, "hole") / board::RES * 100.0).round() / 100.0, "outer_mm": (fv(&h, "outer") / board::RES * 100.0).round() / 100.0, "square": h["square"]})+    }).collect()+}++fn gap_for(flow: &Path, layer: &str) -> (raster::Plane, f32) {+    let f = if layer == "F.Cu" { "board/top.jpg" } else { "board/bottom.jpg" };+    let g = copper::gap_map(&img(&flow.join(f)));+    let s = copper::edge_strength(&g);+    (g, s)+}++/// The run's rules (runs started before rules existed get the default profile).+fn rules_of(state: &Value) -> Value { if state["rules"].is_object() { state["rules"].clone() } else { rules::profile("kicad-default").unwrap() } }++/// The placed pads (2-layer build), for checks that need to know where pads are.+fn build_board_pads(flow: &Path) -> Vec<kicad_out::Pad> { build_board(flow, 2).pads }++fn build_board(flow: &Path, layers: usize) -> kicad_out::Board { build_board_for(flow, layers, false) }++/// The copper of one stackup: what was seen, plus the planes, each zone at the run's rule.+fn copper_for(flow: &Path, layers: usize, planes: &[(String, String)]) -> Value {+    let mut copper = rd(&flow.join("copper.json"));+    let st = items::read_json(&flow.join("state.json")).unwrap_or(json!({}));+    let rl = rules_of(&st);+    for z in copper["zones"].as_array_mut().into_iter().flatten() { if z["clearance_mm"].is_null() { z["clearance_mm"] = rl["clearance"].clone(); } }+    copper["edge_clearance_mm"] = rl["edgeClearance"].clone();+    copper["clearance_rule_mm"] = rl["clearance"].clone();+    if layers > 2 {+        let outline = copper["outline"]["points_mm"].clone();+        for (layer, net) in planes {+            let idx: usize = layer.trim_start_matches("In").trim_end_matches(".Cu").parse().unwrap_or(99);+            if idx < layers - 1 { if let Some(z) = copper["zones"].as_array_mut() { z.push(json!({"id": format!("plane-{layer}"), "net": net, "layer": layer, "polygon_mm": outline, "clearance_mm": rl["clearance"], "connect": "solid", "tag": "INFERRED"})); } }+        }+    }+    copper+}++fn board_title(flow: &Path) -> String { format!("{} (reverse engineered from photos)", flow.parent().and_then(|p| p.file_name()).map(|f| f.to_string_lossy().to_string()).unwrap_or_default()) }++fn build_board_for(flow: &Path, layers: usize, _routing: bool) -> kicad_out::Board {+    let parts = rd(&flow.join("parts.json"));+    let planes: Vec<(String, String)> = items::read_json(&flow.join("layers.json")).ok().map(|l| l["planes"].as_array().into_iter().flatten().map(|p| (p["layer"].as_str().unwrap_or("").to_string(), p["net"].as_str().unwrap_or("").to_string())).collect()).unwrap_or_default();+    let mut copper = copper_for(flow, layers, &planes);+    // the routed copper of this stackup, when it has been routed, is part of the copy+    if let Ok(rt) = items::read_json(&flow.join(format!("routes/L{layers}/routed.json"))) { merge_routed(&mut copper, &rt); }+    let nets = items::read_json(&flow.join("nets.json")).unwrap_or(json!({"nets": []}));+    kicad_out::board_opt(&kicad_out::cache_dir(flow), parts["parts"].as_array().map(|v| v.as_slice()).unwrap_or(&[]), &copper, &nets, layers, &board_title(flow), true).unwrap_or_else(|e| err(&e, &[]))+}++/// Seen tracks that routed copper of another net would touch give way: the router moved through them because+/// it could not do otherwise, and the count says so. Pours clear by themselves when they fill.+fn displace_seen(copper: &mut Value, rt: &Value, clr: f64) -> usize {+    let pt = |v: &Value| (v[0].as_f64().unwrap_or(0.0), v[1].as_f64().unwrap_or(0.0));+    let seg_d = |p: (f64, f64), a: (f64, f64), b: (f64, f64)| { let (dx, dy) = (b.0 - a.0, b.1 - a.1); let l2 = dx * dx + dy * dy; let t = if l2 > 0.0 { (((p.0 - a.0) * dx + (p.1 - a.1) * dy) / l2).clamp(0.0, 1.0) } else { 0.0 }; ((p.0 - a.0 - t * dx).powi(2) + (p.1 - a.1 - t * dy).powi(2)).sqrt() };+    let seg_seg = |a: (f64, f64), b: (f64, f64), c: (f64, f64), d: (f64, f64)| { let mut m = seg_d(a, c, d).min(seg_d(b, c, d)).min(seg_d(c, a, b)).min(seg_d(d, a, b));+        let side = |p: (f64, f64), q: (f64, f64), r: (f64, f64)| (q.0 - p.0) * (r.1 - p.1) - (q.1 - p.1) * (r.0 - p.0);+        if side(a, b, c) * side(a, b, d) < 0.0 && side(c, d, a) * side(c, d, b) < 0.0 { m = 0.0; } m };+    let routed: Vec<(&str, &str, f64, (f64, f64), (f64, f64))> = rt["traces"].as_array().into_iter().flatten().filter_map(|t| { let p = t["path_mm"].as_array()?; Some((t["net"].as_str()?, t["layer"].as_str()?, t["width_mm"].as_f64()?, pt(&p[0]), pt(&p[1]))) }).collect();+    let vias: Vec<(&str, f64, (f64, f64))> = rt["routedVias"].as_array().into_iter().flatten().filter_map(|v| Some((v["net"].as_str()?, v["diameter_mm"].as_f64()?, pt(&v["pos_mm"])))).collect();+    // a via of unknown net gives way the same way+    let mut gone = 0;+    if let Some(sv) = copper["vias"].as_array_mut() {+        let n0 = sv.len();+        sv.retain(|v| { let net = v["net"].as_str().unwrap_or(""); if !(net.is_empty() || net.starts_with("UNK")) || v["source"] == json!("router") { return true; }+            let (p, d) = (pt(&v["pos_mm"]), v["diameter_mm"].as_f64().unwrap_or(0.6));+            !(routed.iter().any(|r| seg_d(p, r.3, r.4) < d / 2.0 + r.2 / 2.0 + clr + 0.01) || vias.iter().any(|o| crate::geom::dist(p, o.2) < d / 2.0 + o.1 / 2.0 + clr + 0.01)) });+        gone = n0 - sv.len();+    }+    let Some(t) = copper["traces"].as_array_mut() else { return gone };+    let before = t.len() + gone;+    t.retain(|s| {+        let (net, layer, w) = (s["net"].as_str().unwrap_or(""), s["layer"].as_str().unwrap_or(""), s["width_mm"].as_f64().unwrap_or(0.2));+        let path: Vec<(f64, f64)> = s["path_mm"].as_array().map(|a| a.iter().map(pt).collect()).unwrap_or_default();+        !path.windows(2).any(|q| routed.iter().any(|r| r.0 != net && r.1 == layer && seg_seg(q[0], q[1], r.3, r.4) < w / 2.0 + r.2 / 2.0 + clr + 0.01) || vias.iter().any(|v| v.0 != net && seg_d(v.2, q[0], q[1]) < w / 2.0 + v.1 / 2.0 + clr + 0.01))+    });+    before - t.len()+}++fn merge_routed(copper: &mut Value, rt: &Value) {+    let clr = copper["clearance_rule_mm"].as_f64().unwrap_or(0.2);+    displace_seen(copper, rt, clr);+    if let Some(t) = copper["traces"].as_array_mut() { t.extend(rt["traces"].as_array().cloned().unwrap_or_default()); }+    if let Some(v) = copper["vias"].as_array_mut() { v.extend(rt["routedVias"].as_array().cloned().unwrap_or_default()); }+}++/// Which inner layer carries what on an n-layer stackup: the first plane net next to the top, the last next+/// to the bottom, signal layers between them. Two inner layers are both planes; a third inner layer onward is routable.+fn plane_map(n: usize, nets: &[String]) -> Vec<(String, String)> {+    if n < 4 { return vec![]; }+    let inner = n - 2;+    let mut v = vec![];+    if let Some(a) = nets.first() { v.push(("In1.Cu".to_string(), a.clone())); }+    if let Some(b) = nets.get(1) { if inner >= 2 { v.push((format!("In{inner}.Cu"), b.clone())); } }+    v+}++fn drc_of(pcb: &Path, out: &Path) -> Value {+    let o = std::process::Command::new("service-kicad").args(["pcb", "drc"]).arg(pcb).args(["--format", "json", "--out"]).arg(out).output();+    match o { Ok(o) if o.status.success() => items::read_json(out).unwrap_or(json!({})), Ok(o) => json!({"error": String::from_utf8_lossy(&o.stderr).to_string()}), Err(e) => json!({"error": e.to_string()}) }+}++/// The proposal: the smallest stackup that closes; if none does, the smallest beyond which more layers stop+/// helping. The same stackup routed again varies by about a quarter in open connections (6 layers on the Driver+/// board: 25 to 34), so a smaller difference is not a reason for more layers.+fn write_proposal(l: &mut Value, cands: &[Value], closed_at: Option<usize>) {+    let best_open = cands.iter().filter_map(|c| c["open"].as_u64()).min().unwrap_or(0) as f64;+    let best_cross = cands.iter().filter_map(|c| c["seenCrossingMm"].as_f64()).fold(f64::MAX, f64::min);+    let levelled = cands.iter().find(|c| c["open"].as_f64().unwrap_or(f64::MAX) <= best_open * 1.25 + 2.0 && c["seenCrossingMm"].as_f64().unwrap_or(f64::MAX) <= best_cross * 1.5 + 10.0).and_then(|c| c["layers"].as_u64()).map(|n| n as usize);+    let pick = closed_at.or(levelled);+    let trail: Vec<String> = cands.iter().map(|c| format!("{} layers: {} of {} connections open, {} new DRC errors, {} vias, {:.0} mm of track, {:.0} mm crossing seen copper", c["layers"], c["open"], c["connections"], c["drcErrors"], c["vias"], c["trackMm"].as_f64().unwrap_or(0.0), c["seenCrossingMm"].as_f64().unwrap_or(0.0))).collect();+    if let Some(n) = pick {+        let ch = cands.iter().find(|c| c["layers"].as_u64() == Some(n as u64)).cloned().unwrap_or(json!({}));+        l["proposed"] = json!(n);+        l["planes"] = ch["planes"].clone();+        l["tag"] = json!("INFERRED"); l["tests"] = json!(["T-layers"]);+        l["why"] = json!(match closed_at { Some(_) => format!("{n} layers is the smallest stackup where the router closes every connection and the DRC finds nothing new. {}", trail.join("; ")), None => format!("no stackup closed every connection. {n} layers is the smallest beyond which more layers stop helping. {}", trail.join("; ")) });+        if ch["open"].as_u64().unwrap_or(0) > 0 { l["openNote"] = json!(format!("{} connection(s) stay open at {n} layers: {}", ch["open"], ch["failed"].as_array().into_iter().flatten().map(|x| format!("{} ({})", x["net"].as_str().unwrap_or(""), x["pins"].as_array().map(|p| p.iter().filter_map(|q| q.as_str()).collect::<Vec<_>>().join(" ")).unwrap_or_default())).collect::<Vec<_>>().join("; "))); } else if let Some(o) = l.as_object_mut() { o.remove("openNote"); }+    }+    l["trail"] = json!(trail);+    l["candidates"] = json!(cands.iter().map(|c| { let mut x = c.clone(); if let Some(o) = x.as_object_mut() { o.remove("failed"); } x }).collect::<Vec<_>>());+}++/// A violation's identity for comparing two DRC reports: its type and its items, without measured lengths.+fn drc_keys(d: &Value) -> Vec<(String, String, Value)> {+    d["violations"].as_array().into_iter().flatten().filter(|v| v["severity"] == json!("error")).map(|v| {+        let mut it: Vec<String> = v["items"].as_array().into_iter().flatten().map(|i| { let s = i["description"].as_str().unwrap_or(""); s.split(", length").next().unwrap_or(s).to_string() }).collect();+        it.sort();+        (v["type"].as_str().unwrap_or("").to_string(), it.join(" | "), v.clone())+    }).collect()+}++/// Route one stackup, write the copy with zones filled around the new copper, and ask KiCad what it thinks.+fn route_candidate(flow: &Path, n: usize, plane_nets: &[String], rl: &Value, seconds: f64, stall: usize, log: &mut dyn FnMut(String)) -> Value {+    let dir = flow.join(format!("routes/L{n}"));+    let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).ok();+    let planes = plane_map(n, plane_nets);+    let parts = rd(&flow.join("parts.json"));+    let nets_doc = items::read_json(&flow.join("nets.json")).unwrap_or(json!({"nets": []}));+    let plist = parts["parts"].as_array().map(|v| v.as_slice()).unwrap_or(&[]);+    let cache = kicad_out::cache_dir(flow);+    let title = board_title(flow);+    let mut copper = copper_for(flow, n, &planes);+    // the board as it stands before routing: its DRC findings are not the router's+    let b0 = kicad_out::board_opt(&cache, plist, &copper, &nets_doc, n, &title, true).unwrap_or_else(|e| err(&e, &[]));+    std::fs::write(dir.join("unrouted.kicad_pcb"), &b0.text).ok();+    let base = drc_of(&dir.join("unrouted.kicad_pcb"), &dir.join("drc-unrouted.json"));+    let mut layers = vec!["F.Cu".to_string()]; for i in 1..n.saturating_sub(1) { layers.push(format!("In{i}.Cu")); } layers.push("B.Cu".into());+    let lidx = |nm: &str| layers.iter().position(|x| x == nm);+    let pt = |v: &Value| (v[0].as_f64().unwrap_or(0.0), v[1].as_f64().unwrap_or(0.0));+    let plane_by_net: HashMap<String, usize> = planes.iter().filter_map(|(l, nt)| lidx(l).map(|i| (nt.clone(), i))).collect();+    let mut zones = vec![];+    for z in copper["zones"].as_array().into_iter().flatten() {+        if z["id"].as_str().unwrap_or("").starts_with("plane-") { continue; }+        if let Some(l) = lidx(z["layer"].as_str().unwrap_or("")) {+            let poly: Vec<(f64, f64)> = z["polygon_mm"].as_array().map(|a| a.iter().map(pt).collect()).unwrap_or_default();+            let m = kicad_out::zone_mask(&b0.grid, z, &poly);+            zones.push(route::ZoneIn { id: z["id"].as_str().unwrap_or("").to_string(), net: z["net"].as_str().unwrap_or("").to_string(), layer: l, cells: (0..m.d.len()).filter(|i| m.d[*i]).collect() });+        }+    }+    // every via seen is a hole through every layer, whatever its net+    let vias: Vec<route::ViaIn> = copper["vias"].as_array().into_iter().flatten().map(|v| { let (x, y) = pt(&v["pos_mm"]); route::ViaIn { id: v["id"].as_str().unwrap_or("").to_string(), net: v["net"].as_str().unwrap_or("").to_string(), x, y, d: v["diameter_mm"].as_f64().unwrap_or(0.6), h: v["drill_mm"].as_f64().unwrap_or(0.3) } }).collect();+    let tracks: Vec<route::TrackIn> = copper["traces"].as_array().into_iter().flatten().filter_map(|t| Some(route::TrackIn { id: t["id"].as_str()?.to_string(), net: t["net"].as_str().unwrap_or("").to_string(), layer: lidx(t["layer"].as_str()?)?, path: t["path_mm"].as_array()?.iter().map(pt).collect(), w: t["width_mm"].as_f64().unwrap_or(0.2) })).collect();+    let str_list = |v: &Value| -> Vec<String> { v.as_array().into_iter().flatten().filter_map(|x| x.as_str().map(str::to_string)).collect() };+    let groups: Vec<route::GroupIn> = items::read_json(&flow.join("detect/copper-measured.json")).ok().map(|d| d["groups"].as_array().into_iter().flatten().map(|g| route::GroupIn { net: g["net"].as_str().unwrap_or("").to_string(), pins: str_list(&g["pins"]), zones: str_list(&g["zones"]), traces: str_list(&g["traces"]), vias: str_list(&g["vias"]) }).collect()).unwrap_or_default();+    let power: HashSet<String> = nets_doc["nets"].as_array().into_iter().flatten().filter_map(|x| x["name"].as_str()).filter(|nm| *nm == "GND" || nm.starts_with('+') || nm.starts_with("VIN") || nm.contains("VCC") || nm.contains("VDD") || nm.starts_with("PHASE") || nm.contains("VBAT")).map(str::to_string).collect();+    let (vs, vh) = rules::via(rl, "viaSignal"); let (ps, ph) = rules::via(rl, "viaPower");+    let rr_clr = rules::num(rl, "clearance", 0.2);+    let rr = route::Rules { clr: rules::num(rl, "clearance", 0.2), track: rules::num(rl, "track", 0.2), power_track: rules::num(rl, "powerTrack", 0.5), via: (vs, vh), power_via: (ps, ph), edge: rules::num(rl, "edgeClearance", 0.5), hole_clr: rules::num(rl, "holeClearance", 0.25), hole_hole: rules::num(rl, "holeToHole", 0.25) };+    let outline: Vec<(f64, f64)> = copper["outline"]["points_mm"].as_array().map(|a| a.iter().map(pt).collect()).unwrap_or_default();+    let inside = kicad_out::board_inside(&b0.grid, &outline, copper["outline"]["corner_mm"].as_f64().unwrap_or(0.0));+    let inp = route::Input { pads: &b0.pads, layers: layers.clone(), planes: plane_by_net, inside: &inside, grid: &b0.grid, zones, vias, tracks, groups, rules: rr, power, seconds, stall };+    let mut lines: Vec<String> = vec![];+    let res = { let mut sink = |m: String| { log(m.clone()); lines.push(m); }; route::run(&inp, &mut sink) };+    wr(&dir.join("routed.json"), &res);+    // the routed copy: zones fill around everything the router placed+    let displaced = displace_seen(&mut copper.clone(), &res, rr_clr);+    merge_routed(&mut copper, &res);+    let b1 = kicad_out::board_opt(&cache, plist, &copper, &nets_doc, n, &title, true).unwrap_or_else(|e| err(&e, &[]));+    let pcb = dir.join("board.kicad_pcb"); std::fs::write(&pcb, &b1.text).ok();+    let d = drc_of(&pcb, &dir.join("drc.json"));+    let known: HashSet<(String, String)> = drc_keys(&base).into_iter().map(|(a, b, _)| (a, b)).collect();+    let fresh: Vec<Value> = drc_keys(&d).into_iter().filter(|(a, b, _)| !known.contains(&(a.clone(), b.clone()))).map(|x| x.2).collect();+    let mut types: BTreeMap<String, u64> = BTreeMap::new();+    for v in &fresh { *types.entry(v["type"].as_str().unwrap_or("").to_string()).or_default() += 1; }+    let kicad_open = d["unconnected_items"].as_array().map(|a| a.len()).unwrap_or(0);+    wr(&dir.join("gate.json"), &json!({"newErrors": fresh.len(), "newErrorTypes": types, "inherited": known.len(), "kicadOpen": kicad_open, "new": fresh, "unconnected": d["unconnected_items"], "drcError": d["error"]}));+    std::fs::write(dir.join("route.log"), lines.join("\n") + "\n").ok();+    let mut c = json!({"layers": n, "open": res["open"], "connections": res["connections"], "drcErrors": fresh.len(), "drcTypes": types, "inheritedErrors": known.len(), "kicadOpen": kicad_open,+        "trackMm": res["trackMm"], "vias": res["vias"], "seenCrossingMm": res["seenCrossingMm"], "seenTracksDisplaced": displaced, "layerUse": res["layerUse"], "passes": res["passes"], "ripups": res["ripups"], "seconds": res["seconds"], "stop": res["stop"], "narrowedPower": res["narrowedPower"],+        "planes": planes.iter().map(|(l, nt)| json!({"layer": l, "net": nt, "tag": "INFERRED", "tests": ["T-layers"], "why": "chosen for this stackup: the largest power nets on the layers next to the outer faces"})).collect::<Vec<_>>(),+        "run": format!("reverse-flow/routes/L{n}"), "log": format!("reverse-flow/routes/L{n}/route.log"), "rules": rl["name"], "failed": res["failed"]});+    if !b1.warnings.is_empty() { c["warnings"] = json!(b1.warnings.len()); }+    c+}++fn main() {+    let cli = Cli::parse();+    let fl = flow::load();+    match &cli.cmd {+        Cmd::Init { top, bottom, target, rules: rules_arg } => {+            let th = thread(&cli);+            let rules_v = rules::load(rules_arg).unwrap_or_else(|e| err(&e, &[]));+            let dir = cli.project.as_ref().map(PathBuf::from).unwrap_or_else(|| std::env::current_dir().unwrap());+            if dir.join(FLOW_DIR).join("state.json").is_file() { err(&format!("{FLOW_DIR}/ already exists in {}", dir.display()), &["Continue it with `next`, or start in a new folder.".into()]); }+            if top.is_empty() { err("init needs at least one --top photo", &[]); }+            let fdir = dir.join(FLOW_DIR);+            for d in ["photos", "evidence", "board", "detect"] { std::fs::create_dir_all(fdir.join(d)).ok(); }+            let mut photos = vec![];+            for (side, list) in [("top", top), ("bottom", bottom)] {+                for p in list {+                    let src = Path::new(p); if !src.is_file() { err(&format!("{p}: not a file"), &[]); }+                    let name = src.file_name().unwrap().to_string_lossy().to_string();+                    let dst = fdir.join("photos").join(&name);+                    std::fs::copy(src, &dst).unwrap_or_else(|e| err(&format!("{p}: {e}"), &[]));+                    photos.push(json!({"file": name, "side": side, "source": src.display().to_string(), "sha256": sha256(&dst)}));+                }+            }+            let p = ledger::Project::create(&dir, FLOW_DIR, json!({"flow": fl.name, "photos": photos, "target": target, "started": ledger::now(), "rules": rules_v})).unwrap_or_else(|e| err(&e.to_string(), &[]));+            p.log(&th, "init", json!({"photos": p.state["photos"]}));+            let mut h = vec![]; if bottom.is_empty() { h.push("No bottom photo: bottom copper, via rings and layer evidence will be unknown. Ask for one.".into()); }+            h.push("Next: `prep`. Start the flow window too: nohup adom-aiflow-reverse serve --port 8890 > reverse-flow/serve.log 2>&1 &".into());+            ok(&format!("reverse flow started in {} with {} photo(s)", p.flow_dir.display(), photos.len()), &h);+        }+        Cmd::Rules { set } => {+            let mut p = project(&cli);+            if let Some(s) = set {+                let th = thread(&cli);+                if p.state["kicad"]["pcb"].is_string() { err("rules are set before the first build: every check and route since used the current ones", &["Start a new run with `init --rules ...`, or say why in the review and rebuild every step.".into()]); }+                p.state["rules"] = rules::load(s).unwrap_or_else(|e| err(&e, &[])); p.save().ok(); p.log(&th, "rules", p.state["rules"].clone());+            }+            let r = rules_of(&p.state);+            ok(&format!("rules {}: clearance {} mm, track {} mm, signal via {}/{} mm, edge {} mm", r["name"].as_str().unwrap_or("custom"), r["clearance"], r["track"], r["viaSignal"][0], r["viaSignal"][1], r["edgeClearance"]), &[r["why"].as_str().unwrap_or("").to_string()]);+        }+        Cmd::Next => { let p = project(&cli); ok(&format!("step {}", flow::current(&fl, &p.flow_dir).map(|s| s.name.clone()).unwrap_or("none".into())), &step_hint(&fl, &p.flow_dir)); }+        Cmd::Steps => {+            let p = project(&cli); let cur = flow::current(&fl, &p.flow_dir).map(|s| s.name.clone());+            for s in &fl.steps {+                let done = s.done.iter().all(|x| flow::exists(&p.flow_dir, x)); let stale = s.done.iter().any(|x| !flow::stale(&p.flow_dir, x).is_empty());+                let _ = writeln!(std::io::stdout(), "{} {}", if done { "done " } else if Some(&s.name) == cur.as_ref() { "NOW  " } else if stale { "stale" } else { "     " }, s.name);+            }+        }+        Cmd::Ledger => { let p = project(&cli); let _ = write!(std::io::stdout(), "{}", std::fs::read_to_string(p.path("run.jsonl")).unwrap_or_default()); }+        Cmd::Prep => {+            let th = thread(&cli); let mut p = project(&cli); let f = p.flow_dir.clone();+            let mut recs = vec![];+            for ph in p.state["photos"].as_array().cloned().unwrap_or_default() {+                let file = ph["file"].as_str().unwrap();+                let r = board::prep_photo(&f.join("photos").join(file), ph["side"].as_str().unwrap_or("top"), &f.join("evidence")).unwrap_or_else(|e| err(&format!("{file}: {e}"), &["The board must be fully in frame on a contrasting background.".into()]));+                recs.push(r);+            }+            let mut doc = json!({"photos": recs, "at": ledger::now()});+            let (t, b) = (primary(&doc, "top").cloned(), primary(&doc, "bottom").cloned());+            if let (Some(t), Some(b)) = (&t, &b) {+                let wh = |v: &Value| (v["rectPx"][0].as_f64().unwrap(), v["rectPx"][1].as_f64().unwrap());+                let (axis, m, o) = prep::flip_axis(t["holes"].as_array().unwrap(), wh(t), b["holes"].as_array().unwrap(), wh(b));+                doc["flip"] = json!({"axis": axis, "matches": m, "other": o});+            }+            doc["primary"] = json!({"top": t.as_ref().map(|v| v["file"].clone()), "bottom": b.as_ref().map(|v| v["file"].clone())});+            let cands = t.as_ref().map(|t| board::scale_candidates(t["rows"].as_array().unwrap())).unwrap_or_default();+            doc["scaleCandidates"] = json!(cands);+            wr(&f.join("prep.json"), &doc);+            p.log(&th, "prep", json!({"flip": doc["flip"], "photos": doc["photos"].as_array().map(|a| a.len())}));+            reach(&mut p, &fl, "intake");+            let mut h: Vec<String> = doc["photos"].as_array().unwrap().iter().map(|r| format!("{} ({}): sharpness {:.0}, {} holes, {} rows, {} bodies", r["file"].as_str().unwrap(), r["side"].as_str().unwrap(), fv(r, "sharpness"), r["holes"].as_array().unwrap().len(), r["rows"].as_array().unwrap().len(), r["bodies"].as_array().unwrap().len())).collect();+            if doc["flip"].is_object() { h.push(format!("bottom maps onto the top turned over {} ({} holes agree, {} the other way)", if doc["flip"]["axis"] == "y" { "top-to-bottom" } else { "left-to-right" }, doc["flip"]["matches"], doc["flip"]["other"])); }+            for c in cands.iter().take(3) { h.push(format!("scale candidate {} px/mm explains {} holes in {} rows", c["pxPerMm"], c["score"], c["rows"].as_array().unwrap().len())); }+            h.push("Run `check prep`, then `scale --body <id>=<L>x<W> --source \"...\"` with a body from evidence/prep-<top photo>.jpg whose size a datasheet gives.".into());+            h.extend(views::record(&f, &p.state, "intake", "photos measured", &th, None));+            ok("every photo measured: prep.json", &h);+        }+        Cmd::Scale { body, source } => {+            let th = thread(&cli); let mut p = project(&cli); let f = p.flow_dir.clone();+            let prep_doc = rd(&f.join("prep.json"));+            let top = primary(&prep_doc, "top").cloned().unwrap_or_else(|| err("no top photo measured", &[]));+            let cands: Vec<Value> = prep_doc["scaleCandidates"].as_array().cloned().unwrap_or_default();+            let Some(b) = body else {+                let mut h: Vec<String> = cands.iter().take(4).map(|c| format!("candidate {} px/mm: rows {}", c["pxPerMm"], c["rows"].as_array().unwrap().iter().map(|r| format!("{} mm", r["mm"])).collect::<Vec<_>>().join(", "))).collect();+                h.push("scale --body <id>=<L>x<W> --source \"<datasheet>\" (ids on evidence/prep-<top photo>.jpg)".into());+                err("scale needs a body whose size a datasheet gives, to confirm the pitch solution", &h)+            };+            if source.as_deref().unwrap_or("").is_empty() { err("--source: name the datasheet and package drawing that give the body size", &[]); }+            let (id, dims) = b.split_once('=').unwrap_or_else(|| err("--body <id>=<L>x<W>", &[]));+            let (l, w) = dims.split_once('x').map(|(a, c)| (a.parse::<f64>().unwrap_or(0.0), c.parse::<f64>().unwrap_or(0.0))).unwrap_or((0.0, 0.0));+            let bd = top["bodies"].as_array().and_then(|a| a.get(id.parse::<usize>().unwrap_or(usize::MAX))).cloned().unwrap_or_else(|| err(&format!("no body {id} on the top photo"), &[]));+            let (bl, bw) = (fv(&bd["body"], "long"), fv(&bd["body"], "short"));+            let (lm, wm) = if l >= w { (l, w) } else { (w, l) };+            let k_body = (bl / lm + bw / wm) / 2.0;+            // the pitches give the precise scale; the body only decides which standard the rows are (a body+            // measures to about +-15%, the candidates differ by factors like 2 or 1.27): exactly one candidate+            // may lie within 25% of it+            let near: Vec<&Value> = cands.iter().filter(|c| ((fv(c, "pxPerMm") - k_body) / k_body).abs() < 0.25).collect();+            if near.len() > 1 { err(&format!("the body ({k_body:.2} px/mm) cannot decide between {} candidates", near.len()), &near.iter().map(|c| format!("candidate {} px/mm", c["pxPerMm"])).collect::<Vec<_>>()); }+            let pick = near.first().copied();+            let k = match pick {+                Some(c) => fv(c, "pxPerMm"),+                None => { let mut h: Vec<String> = cands.iter().map(|c| format!("candidate {} px/mm", c["pxPerMm"])).collect(); h.push("Check the body id and its datasheet size, or measure another body.".into());+                          err(&format!("the body gives {k_body:.2} px/mm ({bl:.0} x {bw:.0} px for {lm} x {wm} mm) but no hole-row solution is within 25% of it"), &h) }+            };+            let (wmm, hmm) = (top["rectPx"][0].as_f64().unwrap() / k, top["rectPx"][1].as_f64().unwrap() / k);+            let axis = prep_doc["flip"]["axis"].as_str().unwrap_or("y").to_string();+            let corners = |v: &Value| -> [geom::P; 4] { let c = v["corners"].as_array().unwrap(); [0, 1, 2, 3].map(|i| (c[i][0].as_f64().unwrap(), c[i][1].as_f64().unwrap())) };+            let tdst = board::frame_corners(wmm, hmm, "top", &axis);+            let top_img = board::to_frame(&f.join("photos").join(top["file"].as_str().unwrap()), &corners(&top), &tdst, wmm, hmm, (0.0, 0.0)).unwrap_or_else(|e| err(&e, &[]));+            save(&top_img, &f.join("board/top.jpg"));+            let top_holes = holes_mm(&top_img);+            let mut align = json!(null);+            if let Some(bot) = primary(&prep_doc, "bottom") {+                let bdst = board::frame_corners(wmm, hmm, "bottom", &axis);+                let bpath = f.join("photos").join(bot["file"].as_str().unwrap());+                let first = board::to_frame(&bpath, &corners(bot), &bdst, wmm, hmm, (0.0, 0.0)).unwrap_or_else(|e| err(&e, &[]));+                let px = |v: &[Value]| v.iter().map(|h| { let q = board::to_px(fv(h, "x"), fv(h, "y")); json!({"x": q.0, "y": q.1}) }).collect::<Vec<_>>();+                let (shift, pairs, res) = board::align(&px(&top_holes), &px(&holes_mm(&first)));+                let bimg = if pairs >= 3 { board::to_frame(&bpath, &corners(bot), &bdst, wmm, hmm, shift).unwrap_or_else(|e| err(&e, &[])) } else { first };+                save(&bimg, &f.join("board/bottom.jpg"));+                save(&copper::grid_image(&bimg, 5.0), &f.join("board/bottom-grid.jpg"));+                align = json!({"shift_mm": [(shift.0 / board::RES * 1000.0).round() / 1000.0, (shift.1 / board::RES * 1000.0).round() / 1000.0], "pairs": pairs, "residual_mm": (res / board::RES * 1000.0).round() / 1000.0});+                wr(&f.join("detect/holes-bottom.json"), &json!({"holes": holes_mm(&bimg)}));+            }+            save(&copper::grid_image(&top_img, 5.0), &f.join("board/top-grid.jpg"));+            let rows: Vec<Value> = prep::hole_rows(&top_holes.iter().map(|h| json!({"x": fv(h, "x"), "y": fv(h, "y"), "outer": fv(h, "outer_mm")})).collect::<Vec<_>>())+                .into_iter().map(|mut r| { r["pitch_mm"] = json!((fv(&r, "pitch") * 1000.0).round() / 1000.0); r }).collect();+            wr(&f.join("detect/holes-top.json"), &json!({"holes": top_holes, "rows": rows}));+            let radius = corner_radius(&top_img);+            let doc = json!({"pxPerMm": k, "method": "hole-row pitches, confirmed by a datasheet body", "body": {"id": id, "mm": [lm, wm], "px": [bl, bw], "pxPerMm": k_body, "source": source}, "candidates": cands,+                             "board_mm": [(wmm * 100.0).round() / 100.0, (hmm * 100.0).round() / 100.0], "corner_mm": radius, "frame": {"origin": [board::ORIGIN, board::ORIGIN], "pxPerMm": board::RES, "bottomFlip": axis}, "alignment": align, "at": ledger::now()});+            wr(&f.join("scale.json"), &doc);+            p.log(&th, "scale", doc.clone());+            reach(&mut p, &fl, "scale");+            let mut h = vec![format!("{k} px/mm (body {id} says {k_body:.2}); board {:.2} x {:.2} mm, corner radius about {radius} mm", wmm, hmm)];+            if align.is_object() { h.push(format!("bottom aligned to top on {} shared holes, residual {} mm", align["pairs"], align["residual_mm"])); }+            h.extend(views::record(&f, &p.state, "scale", "board frame written", &th, None));+            h.extend(step_hint(&fl, &f));+            ok("scale.json and the board frame written (board/top.jpg, board/bottom.jpg, gridded copies)", &h);+        }+        Cmd::Detect { what, bottom } => {+            let th = thread(&cli); let p = project(&cli); let f = p.flow_dir.clone();+            if what != "parts" { err("detect parts", &[]); }+            let top = img(&f.join("board/top.jpg"));+            let mut cands = parts::candidates(&prep::bodies(&top, (0.1 * board::RES) as f32, 0.4 * board::RES), "top", 0);+            let bot = if f.join("board/bottom.jpg").is_file() { Some(img(&f.join("board/bottom.jpg"))) } else { None };+            if let Some(bi) = bot.as_ref().filter(|_| *bottom) {+                let n = cands.len();+                // bottom parts: only bodies that match a package (the bottom face is mostly mask)+                cands.extend(parts::candidates(&prep::bodies(bi, (0.1 * board::RES) as f32, 0.6 * board::RES), "bottom", n).into_iter().filter(|c| c["fits"] == json!(true)));+            }+            let ht = rd(&f.join("detect/holes-top.json"));+            let n = cands.len();+            cands.extend(parts::hole_parts(ht["holes"].as_array().unwrap(), ht["rows"].as_array().unwrap(), n));+            std::fs::create_dir_all(f.join("evidence/parts")).ok();+            let mut crops = vec![]; let mut map = top.clone();+            for c in &cands {+                let pos = (c["pos_mm"][0].as_f64().unwrap(), c["pos_mm"][1].as_f64().unwrap());+                let size = (c["size_mm"][0].as_f64().unwrap_or(3.0), c["size_mm"][1].as_f64().unwrap_or(3.0));+                let src = if c["side"] == json!("bottom") { bot.as_ref().unwrap_or(&top) } else { &top };+                let cr = parts::crop(src, pos, size, 3);+                let id = c["id"].as_str().unwrap().to_string();+                save(&cr, &f.join(format!("evidence/parts/{id}.jpg")));+                crops.push((id.clone(), cr));+                let q = board::to_px(pos.0, pos.1);+                draw::circle(&mut map, q, 6.0, if c["fits"] == json!(true) { [0, 255, 255] } else { [255, 80, 80] }, 2);+                draw::label(&mut map, (q.0 + 6.0, q.1 - 14.0), id.trim_start_matches('P'), [255, 255, 0], 3);+            }+            save(&map, &f.join("evidence/parts-map.jpg"));+            for (k, chunk) in crops.chunks(48).enumerate() { save(&parts::contact_sheet(chunk, 220, 8), &f.join(format!("evidence/parts-sheet-{}.jpg", k + 1))); }+            let fits = cands.iter().filter(|c| c["fits"] == json!(true)).count();+            wr(&f.join("detect/parts.json"), &json!({"candidates": cands, "at": ledger::now()}));+            p.log(&th, "detect", json!({"what": "parts", "candidates": cands.len(), "fits": fits}));+            let mut h = vec!["Next: `parts init`, then read every crop and complete parts.json.".to_string()];+            if !*bottom && bot.is_some() { h.push("The bottom face was not searched for parts: if board/bottom.jpg shows components, run `detect parts --bottom`.".into()); }+            ok(&format!("{} candidates ({fits} match a package by size); crops in evidence/parts/, contact sheets evidence/parts-sheet-*.jpg, map evidence/parts-map.jpg", cands.len()), &h);+        }+        Cmd::Parts { what, force } => {+            let th = thread(&cli); let p = project(&cli); let f = p.flow_dir.clone();+            if what == "snap" {+                let mut doc = rd(&f.join("parts.json"));+                let metal_top = snap::metal_plane(&img(&f.join("board/top.jpg")));+                let metal_bot = if f.join("board/bottom.jpg").is_file() { Some(snap::metal_plane(&img(&f.join("board/bottom.jpg")))) } else { None };+                let (mut lines, mut weak) = (vec![], vec![]);+                let cands_all: Vec<Value> = items::read_json(&f.join("detect/parts.json")).ok().and_then(|d| d["candidates"].as_array().cloned()).unwrap_or_default();+                for pt in doc["parts"].as_array_mut().into_iter().flatten() {+                    let id = pt["footprint"].as_str().unwrap_or("").to_string(); if id.is_empty() || pt["snap"]["lock"] == json!(true) { continue; }+                    let Ok(text) = kicad_out::footprint_text(&kicad_out::cache_dir(&f), &id, pt) else { continue };+                    let pads = snap::pads(&text); if pads.is_empty() { continue; }+                    let metal = if pt["side"] == json!("bottom") { match &metal_bot { Some(m) => m, None => continue } } else { &metal_top };+                    let (x, y, r) = (pt["pos_mm"][0].as_f64().unwrap_or(0.0), pt["pos_mm"][1].as_f64().unwrap_or(0.0), fv(pt, "rot"));+                    // never try a turn that maps the pads onto themselves: it is the same part, pin 1 stays the AI's+                    let sym = snap::symmetric_turns(&pads);+                    let turns: Vec<f64> = [0.0, 90.0, 180.0, 270.0].iter().filter(|d| **d == 0.0 || !sym.iter().any(|s| (s - **d).abs() < 1e-6)).map(|d| (r + d) % 360.0).collect();+                    let pc = snap::pad_centre(&pads);+                    let centre = if pt["anchor"] == json!("center") { Some(pc) } else { None };+                    // the AI's rotation unless another turn is clearly better (0.1 higher and at least 0.3)+                    let own = snap::best_pose_at(metal, &pads, x, y, &turns[..1], 1.0, centre);+                    let any = snap::best_pose_at(metal, &pads, x, y, &turns, 1.0, centre);+                    let (mut nx, mut ny, nr, sc) = if any.3 > own.3 + 0.1 && any.3 >= 0.3 { any } else { own };+                    let mut method = "pads";+                    if sc < 0.3 {+                        // pads hidden under the body (QFN, power packages) or a poor match: use the measured body+                        // centre of the nearest detected body, else keep the reading+                        let read_mid = if centre.is_some() { (x, y) } else { let (c, s) = (r.to_radians().cos(), r.to_radians().sin()); (x + pc.0 * c + pc.1 * s, y - pc.0 * s + pc.1 * c) };+                        let body = cands_all.iter().filter(|c| c["size_mm"].as_array().map(|a| a.iter().filter_map(|v| v.as_f64()).fold(0.0, f64::max) > 1.5).unwrap_or(false))+                            .map(|c| (c["pos_mm"][0].as_f64().unwrap_or(0.0), c["pos_mm"][1].as_f64().unwrap_or(0.0)))+                            .filter(|q| geom::dist(*q, read_mid) < 1.2).min_by(|a, b| geom::dist(*a, read_mid).partial_cmp(&geom::dist(*b, read_mid)).unwrap());+                        // a part placed by its pin 1 (from a measured hole) keeps that reading: its pads are not+                        // centred in its body, so a body centre would move it+                        let body = if centre.is_none() { None } else { body };+                        let m = body.unwrap_or(read_mid);+                        method = if body.is_some() { "body centre" } else { "reading" };+                        let (c, s) = (r.to_radians().cos(), r.to_radians().sin());+                        nx = m.0 - (pc.0 * c + pc.1 * s); ny = m.1 - (-pc.0 * s + pc.1 * c);+                    }+                    // a best pose at the edge of the search found nothing better inside it: keep the reading+                    let edge = { let (c, s) = (r.to_radians().cos(), r.to_radians().sin()); let rm = if centre.is_some() { (x, y) } else { (x + pc.0 * c + pc.1 * s, y - pc.0 * s + pc.1 * c) };+                                 let (c2, s2) = (nr.to_radians().cos(), nr.to_radians().sin()); geom::dist(rm, (nx + pc.0 * c2 + pc.1 * s2, ny - pc.0 * s2 + pc.1 * c2)) >= 0.95 };+                    if sc >= 0.3 && edge {+                        let (c, s) = (r.to_radians().cos(), r.to_radians().sin());+                        let m = if centre.is_some() { (x, y) } else { (x + pc.0 * c + pc.1 * s, y - pc.0 * s + pc.1 * c) };+                        nx = m.0 - (pc.0 * c + pc.1 * s); ny = m.1 - (-pc.0 * s + pc.1 * c); method = "reading (pad match hit the search edge)";+                    }+                    let nr = if sc < 0.3 || edge { r } else { nr };+                    pt["anchor"] = json!("origin");+                    // compare where the part's middle was read with where it is now+                    let mid = |ox: f64, oy: f64, rr: f64| { let (c, s) = (rr.to_radians().cos(), rr.to_radians().sin()); (ox + pc.0 * c + pc.1 * s, oy - pc.0 * s + pc.1 * c) };+                    let before = if centre.is_some() { (x, y) } else { mid(x, y, r) };+                    let moved = geom::dist(before, mid(nx, ny, nr));+                    let r_ = pt["ref"].as_str().unwrap_or("?").to_string();+                    pt["snap"] = json!({"score": (sc * 1000.0).round() / 1000.0, "moved_mm": (moved * 1000.0).round() / 1000.0, "from": [x, y, r], "method": method});+                    pt["pos_mm"] = json!([(nx * 1000.0).round() / 1000.0, (ny * 1000.0).round() / 1000.0]);+                    if (nr - r).rem_euclid(360.0) > 0.5 { lines.push(format!("{r_}: turned {r} -> {nr} by its pads (check pin 1 against the mark)")); pt["rot"] = json!(nr); }+                    if sc < 0.3 { weak.push(format!("{r_} ({id}): pad match {sc:.2} (pads hidden or wrong footprint): placed by {method}")); }+                }+                wr(&f.join("parts.json"), &doc);+                p.log(&th, "parts-snap", json!({"turned": lines.len(), "weak": weak.len()}));+                let mut h = lines; h.extend(weak);+                return ok("parts snapped to their pads in the photo (positions now measured; see each part's snap score)", &h);+            }+            if what == "reconcile" {+                let mut doc = rd(&f.join("parts.json"));+                let det = rd(&f.join("detect/parts.json"));+                let used: std::collections::HashSet<String> = doc["parts"].as_array().into_iter().flatten().filter_map(|p| p["candidate"].as_str().map(str::to_string)).collect();+                let mut dismissed: Vec<Value> = doc["dismissed"].as_array().cloned().unwrap_or_default();+                let already: std::collections::HashSet<String> = dismissed.iter().filter_map(|d| d["candidate"].as_str().map(str::to_string)).collect();+                // each part's extent from its footprint pads (rotated), plus 0.4 mm+                let mut extents: Vec<(String, f64, f64, f64, f64, f64)> = vec![];+                for pt in doc["parts"].as_array().into_iter().flatten() {+                    let id = pt["footprint"].as_str().unwrap_or("");+                    let pads = kicad_out::footprint_text(&kicad_out::cache_dir(&f), id, pt).map(|t| snap::pads(&t)).unwrap_or_default();+                    let (x0, y0, x1, y1) = pads.iter().fold((f64::MAX, f64::MAX, f64::MIN, f64::MIN), |a, q| (a.0.min(q.x - q.w / 2.0), a.1.min(q.y - q.h / 2.0), a.2.max(q.x + q.w / 2.0), a.3.max(q.y + q.h / 2.0)));+                    if x0 > x1 { continue; }+                    let body = pt["size_mm"].as_array().map(|a| a.iter().filter_map(|v| v.as_f64()).fold(0.0, f64::max)).unwrap_or(0.0);+                    let hx = ((x1 - x0) / 2.0).max(body / 2.0) + 0.4; let hy = ((y1 - y0) / 2.0).max(body / 2.0) + 0.4;+                    // the extent is centred on the pads, not on the footprint origin (pin 1 for a header)+                    let (pcx, pcy) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0); let rr = fv(pt, "rot"); let (co, si) = (rr.to_radians().cos(), rr.to_radians().sin());+                    let (ox, oy) = (pt["pos_mm"][0].as_f64().unwrap_or(0.0), pt["pos_mm"][1].as_f64().unwrap_or(0.0));+                    extents.push((pt["ref"].as_str().unwrap_or("?").to_string(), ox + pcx * co + pcy * si, oy - pcx * si + pcy * co, rr, hx, hy));+                }+                let mut rest = vec![]; let mut auto = 0;+                let board_box = items::read_json(&f.join("scale.json")).ok().and_then(|s| Some((board::ORIGIN, board::ORIGIN, board::ORIGIN + s["board_mm"][0].as_f64()?, board::ORIGIN + s["board_mm"][1].as_f64()?)));+                for c in det["candidates"].as_array().into_iter().flatten() {+                    let id = c["id"].as_str().unwrap_or("").to_string();+                    if used.contains(&id) || already.contains(&id) { continue; }+                    let (cx, cy) = (c["pos_mm"][0].as_f64().unwrap_or(0.0), c["pos_mm"][1].as_f64().unwrap_or(0.0));+                    let host = extents.iter().find(|(_, x, y, r, hx, hy)| { let (co, si) = (r.to_radians().cos(), r.to_radians().sin()); let (dx, dy) = (cx - x, cy - y); let (lx, ly) = (dx * co - dy * si, dx * si + dy * co); lx.abs() <= *hx && ly.abs() <= *hy });+                    let size = c["size_mm"].as_array().map(|a| a.iter().filter_map(|v| v.as_f64()).fold(0.0, f64::max)).unwrap_or(0.0);+                    if let Some(h) = host { dismissed.push(json!({"candidate": id, "why": format!("part of {}", h.0)})); auto += 1; }+                    else if size < 0.45 { dismissed.push(json!({"candidate": id, "why": "under 0.45 mm: smaller than any package here (texture, via, silkscreen dot)"})); auto += 1; }+                    else if board_box.map(|(x0, y0, x1, y1)| cx - x0 < 0.8 || x1 - cx < 0.8 || cy - y0 < 0.8 || y1 - cy < 0.8).unwrap_or(false) { dismissed.push(json!({"candidate": id, "why": "within 0.8 mm of the board edge: edge shadow or background"})); auto += 1; }+                    else if c["size_mm"][1].as_f64().unwrap_or(1.0) < 0.35 { dismissed.push(json!({"candidate": id, "why": "under 0.35 mm wide: a silkscreen stroke or trace edge, not a package"})); auto += 1; }+                    else { rest.push(json!({"candidate": id, "pos_mm": c["pos_mm"], "size_mm": c["size_mm"], "colour": c["colour"]})); }+                }+                doc["dismissed"] = json!(dismissed);+                doc["unassigned"] = json!(rest);+                wr(&f.join("parts.json"), &doc);+                p.log(&th, "parts-reconcile", json!({"dismissed": auto, "left": rest.len()}));+                return ok(&format!("{auto} candidates dismissed as fragments of placed parts or too small; {} left in parts.json unassigned for you to judge", rest.len()),+                          &rest.iter().take(25).map(|r| format!("{} at {} size {} ({})", r["candidate"].as_str().unwrap(), r["pos_mm"], r["size_mm"], r["colour"].as_str().unwrap_or(""))).collect::<Vec<_>>());+            }+            if what != "init" { err("parts init | parts snap | parts reconcile", &[]); }+            if f.join("parts.json").is_file() && !force { err("parts.json exists: it holds your edits. Use --force to overwrite it", &[]); }+            let det = rd(&f.join("detect/parts.json"));+            let mut cands: Vec<Value> = det["candidates"].as_array().cloned().unwrap_or_default();+            cands.sort_by(|a, b| (a["pos_mm"][1].as_f64().unwrap(), a["pos_mm"][0].as_f64().unwrap()).partial_cmp(&(b["pos_mm"][1].as_f64().unwrap(), b["pos_mm"][0].as_f64().unwrap())).unwrap());+            let mut count: HashMap<String, usize> = HashMap::new();+            let mut list = vec![]; let mut unassigned = vec![];+            for c in &cands {+                if c["fits"] != json!(true) { unassigned.push(json!({"candidate": c["id"], "size_mm": c["size_mm"], "colour": c["colour"], "best": c["matches"].get(0)})); continue; }+                let pre = c["prefix"].as_str().unwrap_or("X").to_string();+                let n = { let e = count.entry(pre.clone()).or_default(); *e += 1; *e };+                let r = format!("{pre}{n}");+                let one_way = !["C", "R", "TP", "J"].contains(&pre.as_str()) || c["package"].as_str().map(|s| s.contains("SOD") || s.starts_with("SM")).unwrap_or(false);+                let mut e = json!({"ref": r, "candidate": c["id"], "side": c["side"], "pos_mm": c["pos_mm"], "rot": c["rot"], "package": c["package"], "footprint": c["footprint"],+                                   "value": "", "marking": "", "function": "", "tag": "MEASURED", "evidence": [format!("evidence/parts/{}.jpg", c["id"].as_str().unwrap())],+                                   "fieldTags": {"identity": "UNKNOWN"}, "tests": [format!("T-id-{r}")]});+                if c["lug"].is_object() { e["lug"] = c["lug"].clone(); }+                if one_way { e["pin1"] = json!({"mark": "", "evidence": format!("evidence/parts/{}.jpg", c["id"].as_str().unwrap())}); }+                list.push(e);+            }+            wr(&f.join("parts.json"), &json!({"parts": list, "unassigned": unassigned, "dismissed": []}));+            p.log(&th, "parts-init", json!({"parts": list.len(), "unassigned": unassigned.len()}));+            ok(&format!("parts.json: {} parts from candidates, {} candidates unassigned (add each as a part or dismiss it with a reason)", list.len(), unassigned.len()),+               &["For every part: read its crop, set ref, value, marking, mpn and function, correct the footprint if the size match is wrong, set rot so pin 1 matches the photo, tag what you read SEEN (fieldTags) and remove its T-id test once the identity is seen. `decode <code>` reads resistor markings.".into()]);+        }+        Cmd::Decode { code } => match parts::decode(code) {+            Some((v, how)) => ok(&format!("{code} = {} ({how})", parts::si(v, "Ω")), &["Inductor codes read the same way in µH (101 = 100 µH).".into()]),+            None => err(&format!("{code}: not a resistor code this decoder knows (3-digit, 4-digit, R-notation, EIA-96)"), &["Look the marking up in the manufacturer's marking table instead.".into()]),+        },+        Cmd::Blocks { what } => {+            let th = thread(&cli); let p = project(&cli); let f = p.flow_dir.clone();+            if what != "propose" { err("blocks propose", &[]); }+            let list: Vec<Value> = rd(&f.join("parts.json"))["parts"].as_array().cloned().unwrap_or_default();+            let n = list.len(); let mut g: Vec<usize> = (0..n).collect();+            fn root(g: &mut [usize], x: usize) -> usize { let mut r = x; while g[r] != r { r = g[r]; } g[x] = r; r }+            for i in 0..n { for j in i + 1..n {+                let (a, b) = (&list[i]["pos_mm"], &list[j]["pos_mm"]);+                if geom::dist((a[0].as_f64().unwrap(), a[1].as_f64().unwrap()), (b[0].as_f64().unwrap(), b[1].as_f64().unwrap())) < 2.5 { let (ri, rj) = (root(&mut g, i), root(&mut g, j)); g[ri] = rj; }+            } }+            let mut groups: HashMap<usize, Vec<String>> = HashMap::new();+            for i in 0..n { let r = root(&mut g, i); groups.entry(r).or_default().push(list[i]["ref"].as_str().unwrap_or("").to_string()); }+            let mut v: Vec<Vec<String>> = groups.into_values().collect(); v.sort_by(|a, b| b.len().cmp(&a.len()));+            let prop: Vec<Value> = v.iter().enumerate().map(|(k, refs)| json!({"id": format!("group-{}", k + 1), "parts": refs})).collect();+            wr(&f.join("detect/blocks-proposal.json"), &json!({"groups": prop}));+            p.log(&th, "blocks-propose", json!({"groups": prop.len()}));+            ok(&format!("{} proximity groups in detect/blocks-proposal.json", prop.len()), &["Name each block, say what it does, merge or split groups by topology and silkscreen, and write blocks.json.".into()]);+        }+        Cmd::Copper { what } => {+            let th = thread(&cli); let p = project(&cli); let f = p.flow_dir.clone();+            match what.as_str() {+                "prep" => {+                    let mut top_v = vec![]; let mut bot_v = vec![];+                    for (side, file) in [("top", "board/top.jpg"), ("bottom", "board/bottom.jpg")] {+                        if !f.join(file).is_file() { continue; }+                        let i = img(&f.join(file));+                        save(&copper::gap_image(&copper::gap_map(&i)), &f.join(format!("evidence/gaps-{side}.jpg")));+                        let vs = copper::vias(&i, (0.3, 0.6));+                        if side == "top" { top_v = vs; } else { bot_v = vs; }+                    }+                    let mut out = vec![];+                    for (k, b) in bot_v.iter().enumerate() {+                        let q = (b["pos_mm"][0].as_f64().unwrap(), b["pos_mm"][1].as_f64().unwrap());+                        let t = top_v.iter().find(|t| geom::dist(q, (t["pos_mm"][0].as_f64().unwrap(), t["pos_mm"][1].as_f64().unwrap())) < 0.35);+                        let (rb, rt) = (fv(b, "ringContrast"), t.map(|t| fv(t, "ringContrast")).unwrap_or(f64::NAN));+                        let suggest = if t.is_none() { "unknown" } else if rb > 0.08 && rt > 0.08 { "inner" } else if rb > 0.08 || rt > 0.08 { "signal" } else { "stitch" };+                        out.push(json!({"id": format!("v{}", k + 1), "pos_mm": b["pos_mm"], "ringBottom": rb, "ringTop": if rt.is_nan() { Value::Null } else { json!(rt) }, "suggest": suggest}));+                    }+                    if f.join("board/bottom.jpg").is_file() {+                        let mut vi = img(&f.join("board/bottom.jpg"));+                        for v in &out { let q = board::to_px(v["pos_mm"][0].as_f64().unwrap(), v["pos_mm"][1].as_f64().unwrap());+                            draw::circle(&mut vi, q, 14.0, match v["suggest"].as_str().unwrap() { "inner" => [255, 0, 255], "signal" => [255, 200, 0], "stitch" => [0, 255, 120], _ => [160, 160, 160] }, 2); }+                        save(&vi, &f.join("evidence/vias-bottom.jpg"));+                    }+                    let tally = |k: &str| out.iter().filter(|v| v["suggest"] == json!(k)).count();+                    wr(&f.join("detect/vias.json"), &json!({"vias": out, "note": "suggest: ring on both faces = inner, one face = signal, none = stitch; check each on evidence/vias-bottom.jpg (magenta inner, yellow signal, green stitch)"}));+                    p.log(&th, "copper-prep", json!({"vias": out.len()}));+                    ok(&format!("gap maps evidence/gaps-*.jpg; {} via candidates ({} inner, {} signal, {} stitch, {} unknown) in detect/vias.json", out.len(), tally("inner"), tally("signal"), tally("stitch"), tally("unknown")),+                       &["Sketch each pour in copper.json (zones[].sketch_mm, layer, net, settings) from board/*-grid.jpg and evidence/gaps-*.jpg, then `copper fit`.".into()]);+                }+                "trace" => {+                    // every island of copper from the photos, turned into pours, tracks and vias, with what they connect+                    let mut faces: Vec<islands::Face> = vec![]; let mut h = vec![];+                    let mut imgs = vec![];+                    for (side, layer, file) in [("top", "F.Cu", "board/top.jpg"), ("bottom", "B.Cu", "board/bottom.jpg")] {+                        if !f.join(file).is_file() { continue; }+                        let im = img(&f.join(file));+                        let t = trace::trace(&im, &trace::Params { bodies: side == "top", dark: if side == "top" { 0.88 } else { 0.90 }, ..Default::default() });+                        imgs.push((side, im));+                        faces.push(islands::Face { side, layer, t });+                    }+                    let parts = rd(&f.join("parts.json"));+                    let nets_doc = items::read_json(&f.join("nets.json")).unwrap_or(json!({"nets": []}));+                    let mut pin_net: HashMap<String, String> = HashMap::new();+                    for n in nets_doc["nets"].as_array().into_iter().flatten() { for q in n["pins"].as_array().into_iter().flatten().filter_map(|x| x.as_str()) { pin_net.insert(q.to_string(), n["name"].as_str().unwrap_or("").to_string()); } }+                    let empty = json!({"outline": rd(&f.join("copper.json"))["outline"], "zones": [], "traces": [], "vias": []});+                    let pads = kicad_out::board_opt(&kicad_out::cache_dir(&f), parts["parts"].as_array().map(|v| v.as_slice()).unwrap_or(&[]), &empty, &nets_doc, 2, "pads", false).unwrap_or_else(|e| err(&e, &[])).pads;+                    let dd = rd(&f.join("detect/domes.json"));+                    let domes: Vec<(f64, f64, usize)> = dd["domes"].as_array().into_iter().flatten().filter_map(|x| { let fi = faces.iter().position(|q| Some(q.side) == x["side"].as_str())?; Some((x["pos_mm"][0].as_f64()?, x["pos_mm"][1].as_f64()?, fi)) }).collect();+                    let rl = rules_of(&p.state);+                    let m = islands::measure(&mut faces, &pads, &domes, &pin_net, &islands::Options { track: rules::num(&rl, "track", 0.2), via: rules::via(&rl, "viaSignal"), clearance: rules::num(&rl, "clearance", 0.2), hole_hole: rules::num(&rl, "holeToHole", 0.25), edge_clear: rules::num(&rl, "edgeClearance", 0.5), edge: { let o = rd(&f.join("copper.json")); let pts: Vec<(f64, f64)> = o["outline"]["points_mm"].as_array().map(|a| a.iter().map(|q| (q[0].as_f64().unwrap_or(0.0), q[1].as_f64().unwrap_or(0.0))).collect()).unwrap_or_default(); pts.iter().fold((f64::MAX, f64::MAX, f64::MIN, f64::MIN), |a, p| (a.0.min(p.0), a.1.min(p.1), a.2.max(p.0), a.3.max(p.1))) } });+                    for (fi, (side, im)) in imgs.iter().enumerate() { save(&trace::overlay(im, &faces[fi].t), &f.join(format!("evidence/copper-islands-{side}.jpg"))); }+                    // the seen copper replaces the sketched copper; the old file is kept beside it+                    let old = rd(&f.join("copper.json"));+                    if !f.join("copper.sketched.json").is_file() { wr(&f.join("copper.sketched.json"), &old); }+                    let mut c = old.clone();+                    c["zones"] = m["zones"].clone(); c["traces"] = m["traces"].clone(); c["vias"] = m["vias"].clone(); c["from"] = json!("islands");+                    wr(&f.join("copper.json"), &c);+                    wr(&f.join("detect/copper-measured.json"), &json!({"report": m["report"], "groups": m["groups"], "pinAdds": m["pinAdds"], "pinClashes": m["pinClashes"], "viaClashes": m["viaClashes"], "mixed": m["mixed"], "joins": m["joins"], "profile": m["profile"]}));+                    // pins with no net yet that the copper names: seen, so they join that net+                    let mut nd = nets_doc.clone(); let mut added = 0;+                    if let Some(list) = nd["nets"].as_array_mut() { for a in m["pinAdds"].as_array().into_iter().flatten() {+                        if let (Some(pin), Some(net)) = (a["pin"].as_str(), a["net"].as_str()) { if let Some(n) = list.iter_mut().find(|n| n["name"].as_str() == Some(net)) {+                            if !n["pins"].as_array().map(|p| p.iter().any(|x| x.as_str() == Some(pin))).unwrap_or(false) { if let Some(pl) = n["pins"].as_array_mut() { pl.push(json!(pin)); added += 1; } }+                            let e = n["seenPins"].as_array().cloned().unwrap_or_default(); let mut e2 = e; e2.push(json!(pin)); n["seenPins"] = json!(e2);+                        } }+                    } }+                    if added > 0 { wr(&f.join("nets.json"), &nd); }+                    p.log(&th, "copper-trace", m["report"].clone());+                    h.push(format!("{}", m["report"]));+                    h.push("Measured connectivity: detect/copper-measured.json (groups, pin clashes). Next: `check copper`, `build`, `check mimic`.".into());+                    ok("copper.json rebuilt from the photos, island by island", &h);+                }+                "regions" => {+                    let mut lines = vec![]; let mut all_domes: Vec<Value> = vec![];+                    for side in ["top", "bottom"] {+                        let file = f.join(format!("board/{side}.jpg")); if !file.is_file() { continue; }+                        let i = img(&file);+                        let (regs, _lab, _w, nd) = copper::regions(&i, side);+                        { let b = copper::line_barrier(&i); let mut bi = i.clone(); draw::tint(&mut bi, &b, [255, 0, 255], 0.8); save(&bi, &f.join(format!("evidence/copper-barrier-{side}.jpg"))); }+                        let ds = copper::domes(&i);+                        for (x, y) in &ds { all_domes.push(json!({"side": side, "pos_mm": [(x * 1000.0).round() / 1000.0, (y * 1000.0).round() / 1000.0]})); }+                        // evidence: region outlines (cyan likely copper: stitched; grey not), ids, domes+                        let mut ev = i.clone();+                        for (x, y) in &ds { draw::circle(&mut ev, board::to_px(*x, *y), 6.0, [255, 0, 255], 1); }+                        for r in &regs {+                            let poly: Vec<geom::P> = r["polygon_mm"].as_array().unwrap().iter().map(|q| board::to_px(q[0].as_f64().unwrap(), q[1].as_f64().unwrap())).collect();+                            let stitched = r["domesPerMm2"].as_f64().unwrap_or(0.0) >= 0.2;+                            draw::poly(&mut ev, &poly, if stitched { [0, 255, 255] } else { [150, 150, 150] }, 3);+                            let c = board::to_px(r["centre_mm"][0].as_f64().unwrap(), r["centre_mm"][1].as_f64().unwrap());+                            draw::label(&mut ev, (c.0 - 20.0, c.1 - 10.0), r["id"].as_str().unwrap().split(':').nth(1).unwrap_or(""), [255, 255, 0], 5);+                        }+                        save(&ev, &f.join(format!("evidence/copper-regions-{side}.jpg")));+                        let stitched = regs.iter().filter(|r| r["domesPerMm2"].as_f64().unwrap_or(0.0) >= 0.2).count();+                        lines.push(format!("{side}: {} regions ({stitched} stitched with vias, likely copper), {nd} vias found; evidence/copper-regions-{side}.jpg", regs.len()));+                        wr(&f.join(format!("detect/copper-regions-{side}.json")), &json!({"side": side, "regions": regs, "note": "regions enclosed by straight dark gap lines; domesPerMm2 >= 0.2 marks a stitched pour. Use them in copper.json zones as \"regions\": [\"<side>:<id>\", ...]"}));+                    }+                    // a dome seen at the same spot on both faces is a via through the board+                    let tops: Vec<(f64, f64)> = all_domes.iter().filter(|d| d["side"] == json!("top")).map(|d| (d["pos_mm"][0].as_f64().unwrap(), d["pos_mm"][1].as_f64().unwrap())).collect();+                    for d in all_domes.iter_mut().filter(|d| d["side"] == json!("bottom")) {+                        let q = (d["pos_mm"][0].as_f64().unwrap(), d["pos_mm"][1].as_f64().unwrap());+                        d["seenTop"] = json!(tops.iter().any(|t| geom::dist(*t, q) < 0.35));+                    }+                    wr(&f.join("detect/domes.json"), &json!({"domes": all_domes}));+                    p.log(&th, "copper-regions", json!({"domes": all_domes.len()}));+                    lines.push("In copper.json, give each pour \"regions\": [ids] (merge several where the barrier split one pour), its layer and net; `copper fit` builds the outline from them and assigns every via inside it.".into());+                    ok("candidate pours and vias measured on both faces", &lines);+                }+                "assign" => {+                    // pours from the measured regions: a region holding pads of one net takes that net; every+                    // region of a net on a face joins one pour. A stitched region with no pad is copper of an+                    // unknown net (INFERRED, with a test). Zones the AI wrote by hand (no "auto") are kept.+                    let mut doc = rd(&f.join("copper.json"));+                    let pads = build_board_pads(&f);+                    let mut keep: Vec<Value> = doc["zones"].as_array().into_iter().flatten().filter(|z| z["auto"] != json!(true)).cloned().collect();+                    let hand_layers_nets: Vec<(String, String)> = keep.iter().map(|z| (z["layer"].as_str().unwrap_or("").to_string(), z["net"].as_str().unwrap_or("").to_string())).collect();+                    let hand_regions: std::collections::HashSet<String> = keep.iter().flat_map(|z| z["regions"].as_array().cloned().unwrap_or_default()).filter_map(|v| v.as_str().map(str::to_string)).collect();+                    let mut groups: std::collections::BTreeMap<(String, String), Vec<String>> = Default::default();+                    let (mut named, mut unnamed, mut skipped) = (0, 0, 0);+                    for side in ["bottom", "top"] {+                        let Ok(rg) = items::read_json(&f.join(format!("detect/copper-regions-{side}.json"))) else { continue };+                        let layer = if side == "top" { "F.Cu" } else { "B.Cu" };+                        let min_dens = if side == "top" { 0.3 } else { 0.2 };+                        let (gap, strength) = gap_for(&f, layer);+                        for r in rg["regions"].as_array().into_iter().flatten() {+                            if hand_regions.contains(r["id"].as_str().unwrap_or("")) { continue; }+                            let poly: Vec<geom::P> = r["polygon_mm"].as_array().unwrap().iter().map(|q| (q[0].as_f64().unwrap(), q[1].as_f64().unwrap())).collect();+                            let mut votes: HashMap<String, usize> = HashMap::new();+                            // a pad votes only when its clearance ring is broken (thermal spokes or solid): an isolated pad+                            // inside a pour belongs to another net+                            for pd in pads.iter().filter(|pd| pd.drill > 0.0 && pd.on(layer) && !pd.net.is_empty() && pd.w * pd.h >= 9.0) {+                                if !connect::inside(&poly, (pd.x, pd.y)) { continue; }+                                let share = connect::ring_share(&gap, pd, 0.12, strength);+                                if connect::classify(share) != "isolated" { *votes.entry(pd.net.clone()).or_default() += 1; }+                            }+                            let stitched = r["domesPerMm2"].as_f64().unwrap_or(0.0) >= min_dens;+                            let area = r["area_mm2"].as_f64().unwrap_or(0.0);+                            // a small region around a plated pad is that pad's own copper, not a pour+                            let holds_pad = pads.iter().any(|pd| pd.drill > 0.0 && connect::inside(&poly, (pd.x, pd.y)));+                            if holds_pad && area < 8.0 { skipped += 1; continue; }+                            // a region mostly covered by one pad is that pad's copper (a lug, a large pad), not a pour+                            if pads.iter().any(|pd| pd.on(layer) && connect::inside(&poly, (pd.x, pd.y)) && pd.w * pd.h >= 0.4 * area) { skipped += 1; continue; }+                            // a region lying (mostly) within one pad's box is a piece of that pad+                            let in_pad = |pd: &kicad_out::Pad| { let m = pd.w.max(pd.h) / 2.0 + 0.4; poly.iter().filter(|q| (q.0 - pd.x).abs() <= m && (q.1 - pd.y).abs() <= m).count() as f64 >= 0.8 * poly.len() as f64 };+                            if pads.iter().any(|pd| pd.on(layer) && in_pad(pd)) { skipped += 1; continue; }+                            // pads of more than one net joined to one region: the region is not named by them+                            // a pour on this kind of board is stitched: an unstitched region (a silkscreen box round a+                            // header) is not named by the pins inside it+                            // plated pads of more than one net inside the region (a header): never named by them+                            let mixed = { let mut s: std::collections::HashSet<&str> = Default::default(); for pd in pads.iter().filter(|pd| pd.drill > 0.0 && pd.on(layer) && !pd.net.is_empty() && connect::inside(&poly, (pd.x, pd.y))) { s.insert(pd.net.as_str()); } s.len() > 1 };+                            let net = if votes.len() == 1 && !mixed && (stitched || side == "top") { votes.into_iter().next().map(|(n, _)| n) } else { None };+                            match net {+                                Some(n) => { named += 1; groups.entry((layer.to_string(), n)).or_default().push(r["id"].as_str().unwrap().to_string()); }+                                // on the top face parts cover the board: an unnamed region there is not taken as copper+                                None if stitched && side == "bottom" && r["area_mm2"].as_f64().unwrap_or(0.0) >= 3.0 => { unnamed += 1; groups.entry((layer.to_string(), format!("~{}", r["id"].as_str().unwrap()))).or_default().push(r["id"].as_str().unwrap().to_string()); }+                                None => { skipped += 1; }+                            }+                        }+                    }+                    let mut added = 0;+                    for ((layer, net), ids) in groups {+                        let unknown = net.starts_with('~');+                        if !unknown && hand_layers_nets.iter().any(|(l, n)| *l == layer && *n == net) { continue; }+                        let id = if unknown { format!("pour-{}", net.trim_start_matches('~').replace(':', "-")) } else { format!("pour-{}-{}", net.to_lowercase().replace(['+', '$'], ""), if layer == "F.Cu" { "top" } else { "bottom" }) };+                        // copper of unknown net still goes on the board, on a placeholder net of its own+                        let mut z = json!({"id": id, "layer": layer, "net": if unknown { format!("UNK_{}", net.trim_start_matches('~').replace(':', "_")) } else { net.clone() }, "regions": ids, "auto": true, "tag": "MEASURED", "evidence": [format!("evidence/copper-regions-{}.jpg", if layer == "F.Cu" { "top" } else { "bottom" })]});+                        if unknown { z["fieldTags"] = json!({"net": "UNKNOWN"}); z["tests"] = json!(["T-pour-nets"]); } else { z["fieldTags"] = json!({"net": "INFERRED"}); z["tests"] = json!(["T-pour-nets"]); }+                        keep.push(z); added += 1;+                    }+                    doc["zones"] = json!(keep);+                    wr(&f.join("copper.json"), &doc);+                    p.log(&th, "copper-assign", json!({"zones": added, "named": named, "unnamed": unnamed, "skipped": skipped}));+                    ok(&format!("{added} pours from measured regions ({named} regions named by their pads, {unnamed} stitched regions of unknown net, {skipped} not copper)"), &["Run `copper fit` to build their outlines and give their vias a net; review evidence/copper-fit-*.jpg; replace an auto pour with your own zone where the regions are wrong.".into()]);+                }+                "fit" => {+                    let mut doc = rd(&f.join("copper.json"));+                    // zones given as region ids: the union of those regions is the outline+                    let mut region_poly: HashMap<String, Vec<geom::P>> = HashMap::new();+                    for side in ["top", "bottom"] { if let Ok(r) = items::read_json(&f.join(format!("detect/copper-regions-{side}.json"))) {+                        for g in r["regions"].as_array().into_iter().flatten() { region_poly.insert(g["id"].as_str().unwrap_or("").to_string(), g["polygon_mm"].as_array().unwrap().iter().map(|q| (q[0].as_f64().unwrap(), q[1].as_f64().unwrap())).collect()); } } }+                    let mut missing = vec![];+                    for z in doc["zones"].as_array_mut().into_iter().flatten() {+                        let Some(ids) = z["regions"].as_array().cloned() else { continue };+                        let polys: Vec<Vec<geom::P>> = ids.iter().filter_map(|i| { let k = i.as_str().unwrap_or(""); let p = region_poly.get(k).cloned(); if p.is_none() { missing.push(k.to_string()); } p }).collect();+                        if polys.is_empty() { continue; }+                        let (x0, y0, x1, y1) = polys.iter().flatten().fold((f64::MAX, f64::MAX, f64::MIN, f64::MIN), |a, p| (a.0.min(p.0), a.1.min(p.1), a.2.max(p.0), a.3.max(p.1)));+                        let g = kicad_out::Grid { x0: x0 - 1.0, y0: y0 - 1.0, res: 20.0, w: ((x1 - x0 + 2.0) * 20.0) as usize + 1, h: ((y1 - y0 + 2.0) * 20.0) as usize + 1 };+                        let mut m = g.empty(); for p in &polys { g.poly(&mut m, p); }+                        let m = m.close(5.0).fill_holes();   // bridge the barrier seams between merged regions (0.25 mm)+                        let (lab, cs) = raster::components(&m);+                        let Some(c) = cs.iter().max_by_key(|c| c.area) else { continue };+                        let ring: Vec<geom::P> = geom::simplify(&raster::trace_boundary(&lab, m.w, m.h, c), 1.5).iter().map(|q| { let p = g.mm(q.0, q.1); ((p.0 * 1000.0).round() / 1000.0, (p.1 * 1000.0).round() / 1000.0) }).collect();+                        z["sketch_mm"] = json!(ring.iter().map(|q| json!([q.0, q.1])).collect::<Vec<_>>());+                        z["from"] = json!("regions");+                    }+                    if !missing.is_empty() { err(&format!("unknown region ids: {}", missing.join(", ")), &["Run `copper regions` and use ids from detect/copper-regions-<side>.json.".into()]); }+                    let mut gaps: HashMap<String, (raster::Plane, f32)> = HashMap::new();+                    let mut ov_top = img(&f.join("board/top.jpg"));+                    let mut ov_bot = if f.join("board/bottom.jpg").is_file() { img(&f.join("board/bottom.jpg")) } else { ov_top.clone() };+                    let mut lines = vec![];+                    let doc_ro = doc.clone();+                    for z in doc["zones"].as_array_mut().into_iter().flatten() {+                        let layer = z["layer"].as_str().unwrap_or("F.Cu").to_string();+                        if layer != "F.Cu" && layer != "B.Cu" { continue; }+                        let Some(sk) = z["sketch_mm"].as_array() else { continue };+                        let sketch: Vec<geom::P> = sk.iter().map(|q| (q[0].as_f64().unwrap_or(0.0), q[1].as_f64().unwrap_or(0.0))).collect();+                        if !gaps.contains_key(&layer) { gaps.insert(layer.clone(), gap_for(&f, &layer)); }+                        let (g, st) = &gaps[&layer];+                        let ol: Vec<geom::P> = doc_ro["outline"]["points_mm"].as_array().map(|a| a.iter().map(|q| (q[0].as_f64().unwrap_or(0.0), q[1].as_f64().unwrap_or(0.0))).collect()).unwrap_or_default();+                        let bbox = if ol.is_empty() { None } else { Some(ol.iter().fold((f64::MAX, f64::MAX, f64::MIN, f64::MIN), |a, p| (a.0.min(p.0), a.1.min(p.1), a.2.max(p.0), a.3.max(p.1)))) };+                        // an outline built from measured regions already sits on the boundary lines: settle it only+                        let reach = if z["from"] == json!("regions") { 0.25 } else { 1.0 };+                        let (fit, share, moved) = copper::snap_polygon_in(g, &sketch, reach, *st, bbox);+                        z["polygon_mm"] = json!(fit.iter().map(|q| json!([q.0, q.1])).collect::<Vec<_>>());+                        z["fit"] = json!({"edgeShare": (share * 1000.0).round() / 1000.0, "maxMove_mm": (moved * 1000.0).round() / 1000.0});+                        let ov = if layer == "F.Cu" { &mut ov_top } else { &mut ov_bot };+                        copper::draw_poly_mm(ov, &sketch, [255, 220, 0], 2); copper::draw_poly_mm(ov, &fit, [255, 0, 255], 3);+                        lines.push(format!("{} ({layer}): {:.0}% of the outline on a measured gap, corners moved up to {moved:.2} mm", z["id"].as_str().unwrap_or("?"), share * 100.0));+                    }+                    // vias: every dome inside a fitted pour with a named net becomes a stitching via of that net (written+                    // to the board); domes elsewhere stay as evidence. Domes on pads are not vias.+                    if let Ok(dd) = items::read_json(&f.join("detect/domes.json")) {+                        let pads = build_board_pads(&f);+                        let zones: Vec<(String, String, Vec<geom::P>)> = doc["zones"].as_array().into_iter().flatten().filter_map(|z| Some((z["layer"].as_str()?.to_string(), z["net"].as_str()?.to_string(), z["polygon_mm"].as_array()?.iter().map(|q| (q[0].as_f64().unwrap_or(0.0), q[1].as_f64().unwrap_or(0.0))).collect()))).collect();+                        let mut vias = vec![]; let (mut named, mut free) = (0, 0);+                        for (k, d) in dd["domes"].as_array().into_iter().flatten().enumerate() {+                            let side = d["side"].as_str().unwrap_or("bottom"); let layer = if side == "top" { "F.Cu" } else { "B.Cu" };+                            let q = (d["pos_mm"][0].as_f64().unwrap(), d["pos_mm"][1].as_f64().unwrap());+                            if pads.iter().any(|pd| pd.on(layer) && (pd.x - q.0).abs() <= pd.w.max(pd.h) / 2.0 + 0.2 && (pd.y - q.1).abs() <= pd.w.max(pd.h) / 2.0 + 0.2) { continue; }+                            let z = zones.iter().find(|(l, n, poly)| l == layer && !n.is_empty() && !n.starts_with("UNK") && connect::inside(poly, q));+                            let z = z.or_else(|| zones.iter().find(|(l, _, poly)| l == layer && connect::inside(poly, q)));+                            match z {+                                Some((_, n, _)) if !n.is_empty() => { named += 1; vias.push(json!({"id": format!("d{k}"), "pos_mm": [q.0, q.1], "kind": "stitch", "net": n, "side": side, "seenTop": d["seenTop"], "diameter_mm": rules::via(&rules_of(&p.state), "viaSignal").0, "drill_mm": rules::via(&rules_of(&p.state), "viaSignal").1, "tag": "MEASURED", "fieldTags": {"net": "INFERRED"}, "tests": ["T-vias"]})); }+                                _ => { free += 1; vias.push(json!({"id": format!("d{k}"), "pos_mm": [q.0, q.1], "kind": if d["seenTop"] == json!(true) { "signal" } else { "unknown" }, "side": side, "tag": "MEASURED"})); }+                            }+                        }+                        doc["vias"] = json!(vias);+                        lines.push(format!("vias: {named} inside named pours (stitching, on the board with the pour's net), {free} elsewhere (evidence only)"));+                    }+                    wr(&f.join("copper.json"), &doc);+                    save(&ov_top, &f.join("evidence/copper-fit-top.jpg")); save(&ov_bot, &f.join("evidence/copper-fit-bottom.jpg"));+                    p.log(&th, "copper-fit", json!({"zones": lines.len()}));+                    ok("pours fitted to the photo's gap lines (yellow sketch, magenta fit: evidence/copper-fit-*.jpg)", &lines);+                }+                _ => err("copper prep | copper fit", &[]),+            }+        }+        Cmd::Build { layers } => {+            let th = thread(&cli); let mut p = project(&cli); let f = p.flow_dir.clone();+            let n = layers.or_else(|| items::read_json(&f.join("layers.json")).ok().and_then(|l| l["proposed"].as_u64().map(|x| x as usize))).unwrap_or(2);+            let b = build_board(&f, n);+            let name = p.dir.file_name().map(|x| x.to_string_lossy().to_string()).unwrap_or("board".into());+            let kdir = p.dir.join("kicad"); std::fs::create_dir_all(&kdir).ok();+            let pcb = kdir.join(format!("{name}.kicad_pcb"));+            std::fs::write(&pcb, &b.text).unwrap_or_else(|e| err(&e.to_string(), &[]));+            let pro = kdir.join(format!("{name}.kicad_pro"));+            std::fs::write(&pro, rules::kicad_pro(&rules_of(&p.state), &name)).unwrap_or_else(|e| err(&e.to_string(), &[]));+            let mut faces = vec![];+            for (layer, file, side) in [("F.Cu", "board/top.jpg", "top"), ("B.Cu", "board/bottom.jpg", "bottom")] {+                if !f.join(file).is_file() { continue; }+                let mut o = img(&f.join(file));+                for (_, _, m) in b.fills.iter().filter(|(l, _, _)| l == layer) {+                    for v in 0..m.h { for u in 0..m.w { if m.at(u, v) { let (x, y) = b.grid.mm(u as f64 + 0.5, v as f64 + 0.5); let q = board::to_px(x, y); let (qx, qy) = (q.0 as i64, q.1 as i64);+                        for dy in 0..2 { for dx in 0..2 { if (qx + dx + qy + dy) % 3 == 0 { draw::px(&mut o, qx + dx, qy + dy, [255, 120, 40]); } } } } } }+                }+                for pd in b.pads.iter().filter(|pd| pd.on(layer)) {+                    let (hw, hh) = (pd.w / 2.0, pd.h / 2.0); let (c, s) = (pd.rot.to_radians().cos(), pd.rot.to_radians().sin());+                    let pts: Vec<geom::P> = [(-hw, -hh), (hw, -hh), (hw, hh), (-hw, hh)].iter().map(|(lx, ly)| board::to_px(pd.x + lx * c + ly * s, pd.y - lx * s + ly * c)).collect();+                    draw::poly(&mut o, &pts, [0, 255, 255], 2);+                }+                save(&o, &f.join(format!("evidence/overlay-{side}.jpg")));+                faces.push(side);+            }+            // the schematic from the same files: blocks (or one block of every part), footprint pads as pins, nets as labels+            let parts_doc = rd(&f.join("parts.json"));+            let blocks = items::read_json(&f.join("blocks.json")).unwrap_or_else(|_| json!({"blocks": [{"id": "all", "name": "All parts", "function": "", "parts": parts_doc["parts"].as_array().map(|a| a.iter().map(|p| p["ref"].clone()).collect::<Vec<_>>()).unwrap_or_default()}]}));+            let nets_doc = items::read_json(&f.join("nets.json")).unwrap_or(json!({"nets": []}));+            let schp = kdir.join(format!("{name}.kicad_sch"));+            std::fs::write(&schp, sch::schematic(&kicad_out::cache_dir(&f), parts_doc["parts"].as_array().map(|v| v.as_slice()).unwrap_or(&[]), &blocks, &nets_doc, &name)).unwrap_or_else(|e| err(&e.to_string(), &[]));+            p.state["kicad"]["pcb"] = json!(abs(&pcb)); p.state["kicad"]["sch"] = json!(abs(&schp)); p.save().ok();+            p.log(&th, "build", json!({"pcb": abs(&pcb), "layers": n, "warnings": b.warnings, "pads": b.pads.len(), "zones": b.fills.len()}));+            let cur = flow::current(&fl, &f).map(|s| s.name.clone()).unwrap_or("mimic".into());+            let mut h: Vec<String> = b.warnings.iter().map(|w| format!("warning: {w}")).collect();+            h.push(format!("overlays: {}", faces.iter().map(|s| format!("evidence/overlay-{s}.jpg")).collect::<Vec<_>>().join(", ")));+            h.extend(views::record(&f, &p.state, &cur, &format!("build ({n} layers)"), &th, None));+            ok(&format!("{} ({n} copper layers, {} pads, {} zones filled)", abs(&pcb), b.pads.len(), b.fills.len()), &h);+        }+        Cmd::Check { what, pcb } => {+            let th = thread(&cli); let mut p = project(&cli);+            let fdir = p.flow_dir.clone(); let f = &fdir;+            let pcbp = pcb.as_ref().map(PathBuf::from).or_else(|| p.state["kicad"]["pcb"].as_str().map(PathBuf::from));+            let step = match what.as_str() { "prep" => "intake", "nets" | "layers" => "connect", w => w };+            let idx = fl.steps.iter().position(|s| s.name == step).unwrap_or(0);+            if let Some(i) = fl.steps.iter().position(|s| s.name == step) {+                let behind: Vec<String> = fl.steps[..i].iter().filter(|s| !s.done.iter().all(|d| flow::exists(f, d))).map(|s| s.name.clone()).collect();+                if !behind.is_empty() { err(&format!("check {what}: earlier step(s) not done or stale: {}", behind.join(", ")), &step_hint(&fl, f)); }+            }+            let was_stale: Vec<String> = fl.steps.get(idx).map(|s| s.done.iter().flat_map(|d| flow::stale(f, d)).collect()).unwrap_or_default();+            let r = match what.as_str() {+                "prep" => checks::prep(f, &p.state),+                "parts" => checks::parts(f), "blocks" => checks::blocks(f), "copper" => checks::copper(f),+                "mimic" => { let Some(b) = &pcbp else { err("check mimic needs --pcb <board> (or run `build`)", &[]) }; checks::mimic(f, b, &p.state) }+                "nets" => checks::nets(f), "layers" => checks::layers(f, pcbp.as_deref()), "review" => checks::review(f), "tests" => checks::tests(f),+                _ => err(&format!("unknown check {what}"), &["prep | parts | blocks | copper | mimic | nets | layers | review | tests".into()]),+            };+            match r {+                Err(e) => { p.log(&th, "check", json!({"what": what, "ok": false, "errors": e})); err(&format!("check {what}: {} problem(s)", e.len()), &e) }+                Ok(pass) => {+                    checks::write_ok(f, what, &pass).unwrap_or_else(|e| err(&e.to_string(), &[]));+                    p.log(&th, "check", json!({"what": what, "ok": true, "summary": pass.summary}));+                    note_progress(&mut p, &fl, idx, &was_stale); p.save().ok();+                    let mut h = pass.hints.clone();+                    h.extend(views::record(f, &p.state, step, &format!("check {what} passed"), &th, None));+                    h.extend(step_hint(&fl, f));+                    ok(&format!("check {what} passed: {}", pass.summary), &h);+                }+            }+        }+        Cmd::Snapshot { name, dir } => {+            let th = thread(&cli); let mut p = project(&cli);+            let src = Path::new(dir); if !src.is_dir() { err(&format!("{dir}: not a folder"), &[]); }+            let base = p.path(&format!("snapshots/{name}"));+            if base.exists() { err(&format!("snapshot {name} exists; snapshots are never overwritten"), &[]); }+            let mut files = vec![]; copy_tree(src, &base.join("files"), Path::new(""), &mut files).unwrap_or_else(|e| err(&format!("copy failed: {e}"), &[]));+            files.sort();+            wr(&base.join("manifest.json"), &json!({"name": name, "source": src.display().to_string(), "at": ledger::now(), "files": files.iter().map(|(f, h)| (f.clone(), json!(h))).collect::<serde_json::Map<_, _>>()}));+            p.log(&th, "snapshot", json!({"name": name, "files": files.len()}));+            reach(&mut p, &fl, "snapshot");+            let sp = files.iter().find(|(f, _)| f.ends_with(".kicad_pcb") && !f.contains('/')).map(|(f, _)| base.join("files").join(f));+            let mut h = views::record(&p.flow_dir, &p.state, "snapshot", &format!("snapshot {name}"), &th, sp.as_deref()); h.extend(step_hint(&fl, &p.flow_dir));+            ok(&format!("snapshot {name}: {} file(s) saved with sha256", files.len()), &h);+        }+        Cmd::Connect { what, layers, seconds, stall, max_layers } => {+            let th = thread(&cli); let p = project(&cli); let f = p.flow_dir.clone();+            match what.as_str() {+                "extract" => {+                    let b = build_board(&f, 2);+                    let copper = rd(&f.join("copper.json"));+                    let mut gaps = HashMap::new();+                    for l in ["F.Cu", "B.Cu"] { if f.join(if l == "F.Cu" { "board/top.jpg" } else { "board/bottom.jpg" }).is_file() { gaps.insert(l.to_string(), gap_for(&f, l)); } }+                    let ex = connect::extract(&b.pads, &copper, &gaps, &b.grid);+                    // merge: measured nets replace earlier measured ones; the AI's nets stay; a pin claimed by both is reported+                    let old = items::read_json(&f.join("nets.json")).unwrap_or(json!({"nets": []}));+                    let mut keep: Vec<Value> = old["nets"].as_array().into_iter().flatten().filter(|n| n["source"] != json!("measured")).cloned().collect();+                    let mut measured: Vec<Value> = ex["nets"].as_array().cloned().unwrap_or_default();+                    for m in measured.iter_mut() { m["source"] = json!("measured"); }+                    let n_measured = measured.len();+                    let mpins: HashMap<String, String> = measured.iter().flat_map(|n| n["pins"].as_array().unwrap().iter().map(move |p| (p.as_str().unwrap().to_string(), n["name"].as_str().unwrap().to_string()))).collect();+                    let mut clashes = vec![];+                    for k in keep.iter_mut() {+                        let name = k["name"].as_str().unwrap_or("").to_string();+                        let mut rest = vec![];+                        for pn in k["pins"].as_array().cloned().unwrap_or_default() { let s = pn.as_str().unwrap_or("").to_string(); match mpins.get(&s) { Some(m) if *m != name => clashes.push(format!("{s}: your net {name}, measured {m}")), Some(_) => {}, None => rest.push(pn) } }+                        k["pins"] = json!(rest);+                    }+                    keep.retain(|k| !k["pins"].as_array().map(|a| a.is_empty()).unwrap_or(true));+                    // a net of yours with a measured net's name joins it: the measured pins stay listed as+                    // measuredPins, and the net takes your (weaker) tag and tests for the pins added+                    let mut rest = vec![];+                    for k in keep {+                        let name = k["name"].as_str().unwrap_or("").to_string();+                        if let Some(m) = measured.iter_mut().find(|m| m["name"].as_str() == Some(name.as_str())) {+                            if m["measuredPins"].is_null() { m["measuredPins"] = m["pins"].clone(); }+                            let mut pins = m["pins"].as_array().cloned().unwrap_or_default();+                            for pn in k["pins"].as_array().cloned().unwrap_or_default() { if !pins.contains(&pn) { pins.push(pn); } }+                            m["pins"] = json!(pins);+                            for key in ["tag", "tests", "source", "why"] { if !k[key].is_null() { m[key] = k[key].clone(); } }+                        } else { rest.push(k); }+                    }+                    measured.extend(rest);+                    let pin_net: HashMap<String, String> = measured.iter().flat_map(|n| n["pins"].as_array().unwrap().iter().map(move |p| (p.as_str().unwrap().to_string(), n["name"].as_str().unwrap().to_string()))).collect();+                    let planes = connect::plane_evidence(copper["vias"].as_array().map(|v| v.as_slice()).unwrap_or(&[]), &b.pads, &pin_net, 1.2);+                    wr(&f.join("nets.json"), &json!({"nets": measured}));+                    wr(&f.join("detect/connectivity.json"), &json!({"measurements": ex["measurements"], "conflicts": ex["conflicts"], "clashes": clashes, "planes": planes}));+                    if f.join("board/bottom.jpg").is_file() {+                        let mut o = img(&f.join("board/bottom.jpg"));+                        for m in ex["measurements"].as_array().into_iter().flatten().filter(|m| m["layer"] == json!("B.Cu")) {+                            let it = m["item"].as_str().unwrap_or("");+                            if let Some(pd) = b.pads.iter().find(|pd| format!("{}.{}", pd.part, pd.num) == it) {+                                draw::circle(&mut o, board::to_px(pd.x, pd.y), (pd.w.max(pd.h) / 2.0 + 0.4) * board::RES, match m["connection"].as_str().unwrap() { "isolated" => [255, 60, 60], "thermal" => [255, 200, 0], _ => [0, 255, 120] }, 3);+                            }+                        }+                        save(&o, &f.join("evidence/connectivity-bottom.jpg"));+                    }+                    p.log(&th, "connect-extract", json!({"nets": measured.len(), "measured": n_measured, "clashes": clashes.len()}));+                    let mut h = vec![format!("plane evidence: {}", planes["byNet"].as_array().unwrap().iter().map(|x| format!("{} inner vias beside {}", x["vias"], x["net"].as_str().unwrap_or(""))).collect::<Vec<_>>().join(", "))];+                    for c in clashes.iter().take(10) { h.push(format!("clash: {c}")); }+                    if !ex["conflicts"].as_array().unwrap().is_empty() { h.push(format!("{} measured group(s) carry two net names: see detect/connectivity.json conflicts", ex["conflicts"].as_array().unwrap().len())); }+                    h.push("Add the connections nobody can see (DATASHEET or INFERRED with a test), then `connect route --layers 2,4`.".into());+                    ok(&format!("nets.json: {} nets, {n_measured} measured from the photos; measurements in detect/connectivity.json, evidence/connectivity-bottom.jpg", measured.len()), &h);+                }+                "propose" => {+                    // the proposal again, from the candidates already routed+                    let mut l = rd(&f.join("layers.json"));+                    let mut cands: Vec<Value> = l["candidates"].as_array().cloned().unwrap_or_default();+                    for c in cands.iter_mut() { if let Ok(r) = items::read_json(&f.join(format!("routes/L{}/routed.json", c["layers"]))) { c["failed"] = r["failed"].clone(); } }+                    let closed_at = cands.iter().find(|c| c["open"] == json!(0) && c["kicadOpen"] == json!(0) && c["drcErrors"] == json!(0)).and_then(|c| c["layers"].as_u64()).map(|n| n as usize);+                    write_proposal(&mut l, &cands, closed_at);+                    wr(&f.join("layers.json"), &l);+                    p.log(&th, "connect-propose", json!({"proposed": l["proposed"]}));+                    ok(&format!("proposed {} layers", l["proposed"]), &[l["why"].as_str().unwrap_or("").to_string()]);+                }+                "route" => {+                    // layer counts: the floor the evidence allows, then two more at a time. A stackup that cannot+                    // close its connections, or that closes them and fails the DRC, is a reason to jump.+                    let rl = rules_of(&p.state);+                    let copper = rd(&f.join("copper.json"));+                    let floor = if checks::inner_vias(&copper) > 0 { 4 } else { 2 };+                    let counts: Vec<usize> = match layers.as_deref() { Some(l) => l.split(',').filter_map(|x| x.trim().parse().ok()).collect(), None => (floor..=*max_layers).step_by(2).collect() };+                    let mut named: Vec<(String, String)> = vec![];+                    if let Ok(l) = items::read_json(&f.join("layers.json")) { for pl in l["planes"].as_array().into_iter().flatten() { named.push((pl["layer"].as_str().unwrap_or("").to_string(), pl["net"].as_str().unwrap_or("").to_string())); } }+                    let plane_nets: Vec<String> = { let mut v: Vec<(String, String)> = named.clone(); v.sort(); v.dedup_by(|a, b| a.1 == b.1); v.into_iter().map(|x| x.1).collect() };+                    let mut cands: Vec<Value> = vec![];+                    let mut closed_at: Option<usize> = None;+                    let mut last_why = String::new();+                    for n in counts {+                        let t = std::time::Instant::now();+                        let c = route_candidate(&f, n, &plane_nets, &rl, *seconds, *stall, &mut |m| println!("  {m}"));+                        println!("  {} layers: {} open, {} new DRC errors, {} missing connections per KiCad, {:.0} mm of track, {} vias, {:.0} mm crossing seen copper, {} seen tracks displaced, {:.0} s", n, c["open"], c["drcErrors"], c["kicadOpen"], c["trackMm"].as_f64().unwrap_or(0.0), c["vias"], c["seenCrossingMm"].as_f64().unwrap_or(0.0), c["seenTracksDisplaced"], t.elapsed().as_secs_f64());+                        p.log(&th, "connect-route-layer", json!({"layers": n, "open": c["open"], "drcErrors": c["drcErrors"], "kicadOpen": c["kicadOpen"], "vias": c["vias"], "trackMm": c["trackMm"], "seenCrossingMm": c["seenCrossingMm"]}));+                        for h in views::record(&f, &p.state, "connect", &format!("{n}-layer route: {} open, {} new DRC errors", c["open"], c["drcErrors"]), &th, Some(&f.join(format!("routes/L{n}/board.kicad_pcb")))) { println!("  {h}"); }+                        let closes = c["open"] == json!(0) && c["kicadOpen"] == json!(0) && c["drcErrors"] == json!(0);+                        last_why = format!("{n} layers: {} open, {} new DRC errors, {} missing per KiCad ({})", c["open"], c["drcErrors"], c["kicadOpen"], c["stop"].as_str().unwrap_or(""));+                        cands.push(c);+                        if closes { closed_at = Some(n); break; }+                        println!("  jumping: {last_why}");+                    }+                    let mut l = items::read_json(&f.join("layers.json")).unwrap_or(json!({}));+                    write_proposal(&mut l, &cands, closed_at);+                    let pick = l["proposed"].as_u64();+                    let trail: Vec<String> = l["trail"].as_array().into_iter().flatten().filter_map(|x| x.as_str().map(str::to_string)).collect();+                    l["escalation"] = json!(match closed_at { Some(n) => format!("{n} layers closed every connection and passed the gate"), None => format!("not closed; last: {last_why}") });+                    wr(&f.join("layers.json"), &l);+                    p.log(&th, "connect-route", json!({"proposed": pick, "closed": closed_at, "trail": trail}));+                    let mut h = trail.clone();+                    h.push(match closed_at { Some(n) => format!("proposed {n} layers"), None => format!("nothing closed: proposed {:?} with the rest listed in openNote", pick) });+                    h.push("`build --layers <proposed>` writes the routed copy; then `check nets`, `check layers`.".into());+                    ok("routing results recorded in layers.json", &h);+                }+                _ => err("connect extract | connect route [--layers 4,6] [--seconds 480]", &[]),+            }+        }+        Cmd::Review { what, changes, confidence, note } => {+            let th = thread(&cli); let mut p = project(&cli); let f = p.flow_dir.clone();+            match what.as_str() {+                "measure" => {+                    let mut lines = vec![];+                    let parts_doc = rd(&f.join("parts.json")); let det = items::read_json(&f.join("detect/parts.json")).unwrap_or(json!({}));+                    let cands: HashMap<String, Value> = det["candidates"].as_array().into_iter().flatten().map(|c| (c["id"].as_str().unwrap_or("").to_string(), c.clone())).collect();+                    for pt in parts_doc["parts"].as_array().into_iter().flatten() {+                        if let Some(c) = pt["candidate"].as_str().and_then(|c| cands.get(c)) {+                            let d = geom::dist((pt["pos_mm"][0].as_f64().unwrap(), pt["pos_mm"][1].as_f64().unwrap()), (c["pos_mm"][0].as_f64().unwrap(), c["pos_mm"][1].as_f64().unwrap()));+                            if d > 0.5 { lines.push(format!("{} sits {d:.2} mm from its measured body {}", pt["ref"].as_str().unwrap_or("?"), c["id"].as_str().unwrap())); }+                        }+                    }+                    for z in rd(&f.join("copper.json"))["zones"].as_array().into_iter().flatten() {+                        let s = z["fit"]["edgeShare"].as_f64().unwrap_or(0.0); if z["sketch_mm"].is_array() && s < 0.6 { lines.push(format!("zone {}: {:.0}% on measured gaps", z["id"].as_str().unwrap_or("?"), s * 100.0)); }+                    }+                    if let Ok(c) = items::read_json(&f.join("detect/connectivity.json")) { for x in c["clashes"].as_array().into_iter().flatten() { lines.push(format!("net clash: {}", x.as_str().unwrap_or(""))); } }+                    p.log(&th, "review-measure", json!({"findings": lines.len()}));+                    if lines.is_empty() { ok("every measurement agrees with the files", &["Record the pass: `review record --changes 0 ...`".into()]) } else { ok(&format!("{} finding(s)", lines.len()), &lines) }+                }+                "record" => {+                    let (Some(n), Some(c)) = (changes, confidence) else { err("review record needs --changes and --confidence", &[]) };+                    let conf = rd(Path::new(c)); let ce = checks::confidence_errors(&conf); if !ce.is_empty() { err("confidence.json is incomplete", &ce); }+                    let path = f.join("review.json"); let mut doc = items::read_json(&path).unwrap_or(json!({"passes": []}));+                    let k = doc["passes"].as_array().map(|a| a.len()).unwrap_or(0) + 1;+                    doc["passes"].as_array_mut().unwrap().push(json!({"n": k, "at": ledger::now(), "aiThread": th, "changes": n, "note": note, "hashes": checks::current_hashes(&f), "confidence": conf}));+                    wr(&path, &doc);+                    p.log(&th, "review", json!({"pass": k, "changes": n, "note": note}));+                    reach(&mut p, &fl, "review");+                    let mut h = views::record(&f, &p.state, "review", &format!("review pass {k}, {n} change(s)"), &th, None);+                    h.push(if *n > 0 || k < 2 { "review again: the loop ends after a pass with no changes (at least two passes)".into() } else { "a pass with no changes: run `check review`".into() });+                    ok(&format!("review pass {k} recorded"), &h);+                }+                _ => err("review measure | review record ...", &[]),+            }+        }+        Cmd::Confidence => {+            let p = project(&cli); let (table, v) = report::stats(&p.flow_dir);+            wr(&p.path("confidence-stats.json"), &v); let _ = write!(std::io::stdout(), "{table}");+            ok("confidence-stats.json written", &["High: nearly all SEEN, MEASURED or DATASHEET. Medium: a real share INFERRED that does not change how the board works. Low: a pin map, inner layers or a controller INFERRED or UNKNOWN.".into()]);+        }+        Cmd::Tests { what } => {+            let th = thread(&cli); let p = project(&cli); let f = p.flow_dir.clone();+            if what != "generate" { err("tests generate", &[]); }+            let all = items::all(&f);+            let mut tests: Vec<Value> = items::read_json(&f.join("tests.json")).ok().and_then(|t| t["tests"].as_array().cloned()).unwrap_or_default();+            let mut added = 0;+            for it in all.iter().filter(|i| i.open() || !i.tests.is_empty()) {+                for tid in if it.tests.is_empty() { vec![format!("T-{}", it.id.replace(':', "-"))] } else { it.tests.clone() } {+                    if tests.iter().any(|t| t["id"] == json!(tid)) { continue; }+                    let (method, equip, expected, ifnot) = match it.kind.as_str() {+                        "parts" if !it.open() => ("Find pin 1 or the polarity: the cathode band or pin-1 mark under magnification, or a diode-mode reading across the part in circuit", "loupe or microscope, DMM in diode mode", "the orientation recorded in parts.json (pin1)", "turn the part in parts.json and rebuild; check every net on its pins"),+                        "parts" => ("Read the marking under raking light and magnification; if still unreadable, measure in circuit (resistance, diode test) or identify by pinout continuity", "lamp, loupe or microscope, DMM", "the marking or the measured value", "update the part's value, MPN and footprint in parts.json and rebuild"),+                        "nets" => ("Continuity from each pin of this net to the others, board unpowered", "DMM in continuity mode, fine probes", "every listed pin beeps to the others, and to no pin of another net", "move the pin to the net that beeps, update nets.json and rebuild"),+                        "planes" | "layers" => ("Count copper layers on the board edge under a microscope (or X-ray); resistance from a plane via to a pin of the named net", "microscope or X-ray, DMM", "the proposed copper layer count, and continuity to the plane's net", "re-propose the stackup and planes in layers.json"),+                        "vias" => ("Continuity from the via (probe its pad or scrape the tenting at its edge) to the pins of its net", "DMM, sharp probe", "continuity to the stated net", "correct the via's net or kind in copper.json"),+                        _ => ("Inspect against the photos and measure on the board", "DMM, microscope", "matches the file", "correct the item and rebuild"),+                    };+                    tests.push(json!({"id": tid, "settles": [it.id], "method": method, "equipment": equip, "expected": expected, "ifNot": ifnot, "generated": true})); added += 1;+                }+            }+            wr(&f.join("tests.json"), &json!({"tests": tests}));+            p.log(&th, "tests-generate", json!({"added": added}));+            ok(&format!("tests.json: {added} test(s) added, {} in all", tests.len()), &["Edit the expected results the generator could not know (rail voltages from the divider, marking candidates), then `check tests`.".into()]);+        }+        Cmd::Report => {+            let th = thread(&cli); let mut p = project(&cli);+            let md = report::tables(&p.flow_dir, &fl);+            std::fs::write(p.path("report-tables.md"), &md).unwrap_or_else(|e| err(&e.to_string(), &[]));+            let mut files: Vec<String> = checks::REVIEWED.iter().chain(["review.json", "tests.json"].iter()).filter(|f| p.path(f).is_file()).map(|f| f.to_string()).collect();+            files.push("report-tables.md".into());+            checks::write_ok(&p.flow_dir, "report", &checks::Pass { summary: json!({}), hints: vec![], files }).unwrap_or_else(|e| err(&e.to_string(), &[]));+            p.log(&th, "report", json!({})); reach(&mut p, &fl, "report");+            let mut h = views::record(&p.flow_dir, &p.state, "report", "report built", &th, None); h.extend(step_hint(&fl, &p.flow_dir));+            ok("report-tables.md written", &h);+        }+        Cmd::Kicad { pcb, sch } => {+            let th = thread(&cli); let mut p = project(&cli);+            if !Path::new(pcb).is_file() { err(&format!("{pcb}: not a file"), &[]); }+            p.state["kicad"] = json!({"pcb": abs(Path::new(pcb)), "sch": sch.as_ref().map(|s| abs(Path::new(s)))}); p.save().ok();+            p.log(&th, "kicad", p.state["kicad"].clone());+            let cur = flow::current(&fl, &p.flow_dir).map(|s| s.name.clone()).unwrap_or("report".into());+            ok("the flow window now shows this KiCad project", &views::record(&p.flow_dir, &p.state, &cur, "KiCad project registered", &th, None));+        }+        Cmd::View { step, reason } => {+            let th = thread(&cli); let p = project(&cli);+            let st = step.clone().or_else(|| flow::current(&fl, &p.flow_dir).map(|s| s.name.clone())).unwrap_or("report".into());+            let h = views::record(&p.flow_dir, &p.state, &st, reason.as_deref().unwrap_or("updated"), &th, None);+            p.log(&th, "view", json!({"step": st, "reason": reason}));+            ok(&format!("view recorded for {st}"), &h);+        }+        Cmd::Serve { port } => {+            let p = project(&cli); let local = format!("http://127.0.0.1:{port}/");+            ok(&format!("flow window on {local}"), &[format!("Open it in a Hydrogen webview: adom-cli hydrogen webview open-or-refresh --name \"Reverse Flow\" --url \"{}\"", std::env::var("VSCODE_PROXY_URI").map(|u| u.replace("{{port}}", &port.to_string())).unwrap_or(local.clone()))]);+            server::serve(p.flow_dir.clone(), fl, *port).unwrap_or_else(|e| err(&format!("cannot serve on port {port}: {e}"), &[]));+        }+        Cmd::Clip { what, name, hwnd } => {+            let th = thread(&cli); let mut p = project(&cli);+            let target = p.state["target"].as_str().map(str::to_string).unwrap_or_else(|| err("no desktop target: init with --target <box>", &[]));+            let br = bridge::Bridge { target, ai_thread: th.clone() };+            match what.as_str() {+                "start" => {+                    let (Some(n), Some(h)) = (name, hwnd) else { err("clip start needs --name <step> --hwnd <window>", &[]) };+                    let id = br.record_window_start(*h, &format!("{FLOW_DIR}: record step {n}")).unwrap_or_else(|r| err(&format!("recording did not start: {r}"), &[]));+                    p.state["clip"] = json!({"id": id, "name": n, "hwnd": h, "started": ledger::now()}); p.save().ok(); p.log(&th, "clip-start", p.state["clip"].clone());+                    ok(&format!("recording {n}"), &[]);+                }+                "stop" => {+                    let c = p.state["clip"].clone(); let id = c["id"].as_str().unwrap_or_else(|| err("no clip is recording", &[]));+                    let dir = p.path("clips"); std::fs::create_dir_all(&dir).ok(); let s = br.record_stop(id, &dir);+                    p.state["clip"] = Value::Null; p.save().ok(); p.log(&th, "clip-stop", json!({"name": c["name"], "pulled": s.pulled}));+                    if !s.pulled { err("the clip did not land locally", &[]); }+                    ok(&format!("clip saved to {}", s.local.unwrap().display()), &[]);+                }+                _ => err("clip start|stop", &[]),+            }+        }+    }+}
src/out.rsadded+17
@@ -0,0 +1,17 @@+//! Every command answers the AI the same way: `OK: <what happened>` or `ERROR: <what is wrong>`,+//! then `Hint:` lines that depend on this project and this run. Exit 1 on ERROR.++use std::io::Write;++pub fn ok(msg: &str, hints: &[String]) {+    let mut o = std::io::stdout();+    let _ = writeln!(o, "OK: {msg}");+    for h in hints.iter().filter(|h| !h.is_empty()) { let _ = writeln!(o, "Hint: {h}"); }+}++pub fn err(msg: &str, hints: &[String]) -> ! {+    let mut o = std::io::stdout();+    let _ = writeln!(o, "ERROR: {msg}");+    for h in hints.iter().filter(|h| !h.is_empty()) { let _ = writeln!(o, "Hint: {h}"); }+    std::process::exit(1)+}
src/parts.rsadded+201
@@ -0,0 +1,201 @@+//! Part candidates in board millimetres. Bodies found on the board images get a package match by+//! measured size (with and without their terminations), a provisional reference, an orientation in+//! KiCad degrees and a crop for reading the marking. Rows of plated holes become connector candidates+//! (pitch and count give the footprint), and large square pads with a hole become lug candidates.+use crate::board::{to_mm, RES};+use image::RgbImage;+use serde_json::{json, Value};++/// (package, KiCad footprint, length, width with terminations, mm). Sizes from the KiCad library's own+/// footprint names and JEDEC/EIA nominal bodies; a match within 12% on both axes counts.+pub const PACKAGES: &[(&str, &str, f64, f64)] = &[+    ("0402", "Resistor_SMD:R_0402_1005Metric", 1.0, 0.5),+    ("0603", "Resistor_SMD:R_0603_1608Metric", 1.6, 0.8),+    ("0805", "Resistor_SMD:R_0805_2012Metric", 2.0, 1.25),+    ("1206", "Resistor_SMD:R_1206_3216Metric", 3.2, 1.6),+    ("1210", "Resistor_SMD:R_1210_3225Metric", 3.2, 2.5),+    ("2010", "Resistor_SMD:R_2010_5025Metric", 5.0, 2.5),+    ("2512", "Resistor_SMD:R_2512_6332Metric", 6.3, 3.2),+    ("SOD-123", "Diode_SMD:D_SOD-123", 3.7, 1.6),+    ("SMA", "Diode_SMD:D_SMA", 5.2, 2.6),+    ("SMB", "Diode_SMD:D_SMB", 5.4, 3.6),+    ("SOT-23", "Package_TO_SOT_SMD:SOT-23", 2.9, 2.4),+    ("SOT-23-8", "Package_TO_SOT_SMD:SOT-23-8", 2.9, 2.8),+    ("SOT-223", "Package_TO_SOT_SMD:SOT-223-3_TabPin2", 7.0, 6.5),+    ("SOIC-8", "Package_SO:SOIC-8_3.9x4.9mm_P1.27mm", 6.0, 4.9),+    ("PDFN-8 5x6", "Package_TO_SOT_SMD:TDSON-8-1", 6.15, 5.15),+    ("QFN 3x3", "Package_DFN_QFN:QFN-16-1EP_3x3mm_P0.5mm_EP1.7x1.7mm", 3.0, 3.0),+    ("QFN 4x4", "Package_DFN_QFN:QFN-24-1EP_4x4mm_P0.5mm_EP2.6x2.6mm", 4.0, 4.0),+    ("QFN 5x5", "Package_DFN_QFN:QFN-32-1EP_5x5mm_P0.5mm_EP3.45x3.45mm", 5.0, 5.0),+    ("QFN 6x6", "Package_DFN_QFN:QFN-40-1EP_6x6mm_P0.5mm_EP4.6x4.6mm", 6.0, 6.0),+    ("QFN 7x7", "Package_DFN_QFN:QFN-48-1EP_7x7mm_P0.5mm_EP5.6x5.6mm", 7.0, 7.0),+    ("Inductor 6x6", "Inductor_SMD:L_Bourns-SRN6045TA", 6.0, 6.0),+    ("Inductor 8x8", "Inductor_SMD:L_Bourns-SRN8040_8x8.15mm", 8.15, 8.0),+];++/// Ceramic capacitors show only their tan body: the length less both terminations (EIA nominal:+/// 0402 0.25 mm, 0603 0.35, 0805 0.5, 1206 0.5, 1210 0.5 mm per end).+pub const CERAMIC_BODIES: &[(&str, &str, f64, f64)] = &[+    ("0402", "Capacitor_SMD:C_0402_1005Metric", 0.5, 0.5),+    ("0603", "Capacitor_SMD:C_0603_1608Metric", 0.9, 0.8),+    ("0805", "Capacitor_SMD:C_0805_2012Metric", 1.25, 1.0),+    ("1206", "Capacitor_SMD:C_1206_3216Metric", 2.2, 1.6),+    ("1210", "Capacitor_SMD:C_1210_3225Metric", 2.5, 2.2),+];++fn f(v: &Value, k: &str) -> f64 { v[k].as_f64().unwrap_or(0.0) }++fn match_in(table: &[(&str, &str, f64, f64)], l: f64, w: f64) -> Vec<(String, String, f64)> {+    let (l, w) = if l >= w { (l, w) } else { (w, l) };+    let mut m: Vec<(String, String, f64)> = table.iter().map(|(n, fp, pl, pw)| { let (pl, pw) = if pl >= pw { (*pl, *pw) } else { (*pw, *pl) };+        (n.to_string(), fp.to_string(), ((l - pl) / pl).abs().max(((w - pw) / pw).abs())) }).collect();+    m.sort_by(|a, b| a.2.partial_cmp(&b.2).unwrap()); m.truncate(3); m+}++/// Best package matches for measured (length, width) in mm, with the worse-axis relative error.+pub fn match_package(l: f64, w: f64) -> Vec<(String, String, f64)> {+    let mut m: Vec<(String, String, f64)> = PACKAGES.iter().map(|(n, fp, pl, pw)| {+        let e = ((l - pl) / pl).abs().max(((w - pw) / pw).abs()); (n.to_string(), fp.to_string(), e)+    }).collect();+    m.sort_by(|a, b| a.2.partial_cmp(&b.2).unwrap());+    m.truncate(3);+    m+}++fn prefix(colour: &str, pkg: &str) -> &'static str {+    match (colour, pkg) {+        ("tan", _) => "C",+        (_, p) if p.starts_with("SOD") || p.starts_with("SM") => "D",+        (_, p) if p.starts_with("PDFN") || p == "SOT-23" => "Q",+        (_, p) if p.starts_with("Inductor") => "L",+        (_, p) if p.starts_with("QFN") || p.starts_with("SOIC") || p.starts_with("SOT-223") || p.starts_with("SOT-23-") => "U",+        ("black", p) if ["0402", "0603", "0805", "1206", "1210", "2010", "2512"].contains(&p) => "R",+        ("yellow", _) | ("bright-green", _) | ("white", _) => "J",+        _ => "X",+    }+}++/// Bodies (board-image pixels) to candidates in board mm. `side` is top or bottom.+pub fn candidates(bodies: &[Value], side: &str, start: usize) -> Vec<Value> {+    let mut out = vec![];+    for (k, b) in bodies.iter().enumerate() {+        let (l, w) = (f(b, "long") / RES, f(b, "short") / RES);+        let (bl, bw) = (f(&b["body"], "long") / RES, f(&b["body"], "short") / RES);+        if l < 0.7 && w < 0.4 { continue; }+        let colour = b["colour"].as_str().unwrap_or("");+        // terminations count for passives and leaded parts; the moulding alone for square packages+        // ceramics by their tan body; everything else by its full extent or its moulding, whichever fits+        let m = if colour == "tan" { match_in(CERAMIC_BODIES, bl, bw) } else { let a = match_package(l, w); let b = match_package(bl, bw); if b[0].2 < a[0].2 { b } else { a } };+        let fits = m[0].2 < 0.15;+        let pos = to_mm(f(b, "x"), f(b, "y"));+        let id = format!("P{:03}", start + out.len() + 1);+        // KiCad rotation is counter-clockwise on screen; the image angle runs clockwise+        let rot = (-(f(b, "angle"))).rem_euclid(180.0);+        let rot = [0.0, 45.0, 90.0, 135.0, 180.0].iter().copied().find(|s| (rot - s).abs() < 4.0).map(|s| s % 180.0).unwrap_or((rot * 10.0).round() / 10.0);+        out.push(json!({"id": id, "side": side, "pos_mm": [(pos.0 * 1000.0).round() / 1000.0, (pos.1 * 1000.0).round() / 1000.0], "rot": rot,+                        "size_mm": [(l * 100.0).round() / 100.0, (w * 100.0).round() / 100.0], "body_mm": [(bl * 100.0).round() / 100.0, (bw * 100.0).round() / 100.0],+                        "colour": colour, "fits": fits, "prefix": if fits { prefix(colour, &m[0].0) } else { "X" },+                        "package": if fits { json!(m[0].0) } else { Value::Null }, "footprint": if fits { json!(m[0].1) } else { Value::Null },+                        "matches": m.iter().map(|(n, fp, e)| json!({"package": n, "footprint": fp, "error": (e * 1000.0).round() / 1000.0})).collect::<Vec<_>>(),+                        "source": format!("body {k}")}));+    }+    out+}++/// Connector candidates from hole rows (board mm holes) and lug candidates from large square pads.+pub fn hole_parts(holes: &[Value], rows: &[Value], start: usize) -> Vec<Value> {+    let mut out = vec![];+    for r in rows {+        let ids: Vec<usize> = r["holes"].as_array().unwrap().iter().map(|x| x.as_u64().unwrap() as usize).collect();+        let pitch = r["pitch_mm"].as_f64().unwrap_or(0.0);+        let std = crate::board::PITCHES.iter().copied().find(|p| ((pitch - p) / p).abs() < 0.03);+        let Some(p) = std else { continue };+        let n = ids.len();+        let first = &holes[ids[0]]; let last = &holes[ids[n - 1]];+        let (x0, y0, x1, y1) = (f(first, "x"), f(first, "y"), f(last, "x"), f(last, "y"));+        let vertical = (y1 - y0).abs() > (x1 - x0).abs();+        let fp = match p { 2.0 => format!("Connector_PinHeader_2.00mm:PinHeader_1x{n:02}_P2.00mm_Vertical"), 2.54 => format!("Connector_PinHeader_2.54mm:PinHeader_1x{n:02}_P2.54mm_Vertical"),+                           3.5 => format!("TerminalBlock_Phoenix:TerminalBlock_Phoenix_PT-1,5-{n}-3.5-H_1x{n:02}_P3.50mm_Horizontal"), 5.0 | 5.08 => format!("TerminalBlock_Phoenix:TerminalBlock_Phoenix_MKDS-1,5-{n}-5.08_1x{n:02}_P5.08mm_Horizontal"),+                           _ => format!("Connector_PinHeader_2.54mm:PinHeader_1x{n:02}_P2.54mm_Vertical") };+        // pin 1 of a library header sits at the footprint origin, the row running +y at rotation 0+        let rot: f64 = if vertical { if y1 > y0 { 0.0 } else { 180.0 } } else if x1 > x0 { 90.0 } else { 270.0 };+        out.push(json!({"id": format!("P{:03}", start + out.len() + 1), "side": "top", "pos_mm": [x0, y0], "rot": rot, "pitch_mm": p, "pins": n,+                        "holes": ids, "fits": true, "prefix": "J", "package": format!("1x{n} {p} mm"), "footprint": fp, "origin": "pin 1 (first hole of the row: check which end is pin 1 from the square pad)",+                        "source": "hole row"}));+    }+    for (i, h) in holes.iter().enumerate() {+        if f(h, "outer_mm") > 3.5 && f(h, "hole_mm") >= 1.5 {+            out.push(json!({"id": format!("P{:03}", start + out.len() + 1), "side": "top", "pos_mm": [f(h, "x"), f(h, "y")], "rot": 0.0, "fits": true, "prefix": "TP",+                            "package": format!("lug {:.1} mm, hole {:.2} mm", f(h, "outer_mm"), f(h, "hole_mm")), "footprint": "generated:lug", "lug": {"pad_mm": f(h, "outer_mm"), "drill_mm": f(h, "hole_mm")}, "holes": [i], "source": "large square pad"}));+        }+    }+    out+}++/// A crop around a candidate, enlarged for reading markings.+pub fn crop(img: &RgbImage, pos_mm: (f64, f64), size_mm: (f64, f64), scale: u32) -> RgbImage {+    let (cx, cy) = crate::board::to_px(pos_mm.0, pos_mm.1);+    let half = ((size_mm.0.max(size_mm.1) / 2.0 + 1.0) * RES) as i64;+    let (x0, y0) = ((cx as i64 - half).max(0) as u32, (cy as i64 - half).max(0) as u32);+    let w = ((2 * half) as u32).min(img.width() - x0); let h = ((2 * half) as u32).min(img.height() - y0);+    let c = image::imageops::crop_imm(img, x0, y0, w, h).to_image();+    image::imageops::resize(&c, w * scale, h * scale, image::imageops::FilterType::CatmullRom)+}++/// A contact sheet: crops in a grid, each labelled with its id.+pub fn contact_sheet(crops: &[(String, RgbImage)], cell: u32, cols: u32) -> RgbImage {+    let rows = (crops.len() as u32).div_ceil(cols).max(1);+    let mut sheet = RgbImage::from_pixel(cols * cell, rows * cell, image::Rgb([20, 20, 24]));+    for (k, (id, c)) in crops.iter().enumerate() {+        let (gx, gy) = ((k as u32 % cols) * cell, (k as u32 / cols) * cell);+        let s = image::imageops::resize(c, cell - 8, cell - 22, image::imageops::FilterType::Triangle);+        image::imageops::overlay(&mut sheet, &s, (gx + 4) as i64, (gy + 18) as i64);+        crate::draw::label(&mut sheet, (gx as f64 + 4.0, gy as f64 + 3.0), id.trim_start_matches('P'), [255, 255, 0], 3);+    }+    sheet+}++/// Resistor and inductor marking codes: 3-digit (472), 4-digit (4701), R-notation (R002, 4R7) and+/// EIA-96 (30B). Returns ohms (or the code's base unit) and how it was read.+pub fn decode(code: &str) -> Option<(f64, String)> {+    let c = code.trim().to_uppercase();+    const EIA: [f64; 96] = [100., 102., 105., 107., 110., 113., 115., 118., 121., 124., 127., 130., 133., 137., 140., 143., 147., 150., 154., 158., 162., 165., 169., 174., 178., 182., 187., 191., 196., 200., 205., 210., 215., 221., 226., 232., 237., 243., 249., 255., 261., 267., 274., 280., 287., 294., 301., 309., 316., 324., 332., 340., 348., 357., 365., 374., 383., 392., 402., 412., 422., 432., 442., 453., 464., 475., 487., 499., 511., 523., 536., 549., 562., 576., 590., 604., 619., 634., 649., 665., 681., 698., 715., 732., 750., 768., 787., 806., 825., 845., 866., 887., 909., 931., 953., 976.];+    if c.contains('R') && c.chars().all(|ch| ch.is_ascii_digit() || ch == 'R') && c.matches('R').count() == 1 {+        return c.replace('R', ".").parse::<f64>().ok().map(|v| (v, format!("R-notation {c}")));+    }+    if c.len() == 3 && c.chars().take(2).all(|ch| ch.is_ascii_digit()) && c.chars().nth(2).unwrap().is_ascii_alphabetic() {+        let i: usize = c[..2].parse().ok()?;+        if !(1..=96).contains(&i) { return None; }+        let m = match c.chars().nth(2)? { 'Z' => 0.001, 'Y' | 'R' => 0.01, 'X' | 'S' => 0.1, 'A' => 1.0, 'B' | 'H' => 10.0, 'C' => 100.0, 'D' => 1000.0, 'E' => 10000.0, 'F' => 100000.0, _ => return None };+        return Some((EIA[i - 1] * m, format!("EIA-96 {c}: {} x {m}", EIA[i - 1])));+    }+    if (c.len() == 3 || c.len() == 4) && c.chars().all(|ch| ch.is_ascii_digit()) {+        let (sig, exp) = c.split_at(c.len() - 1);+        let v = sig.parse::<f64>().ok()? * 10f64.powi(exp.parse::<i32>().ok()?);+        return Some((v, format!("{}-digit {c}", c.len())));+    }+    None+}++pub fn si(v: f64, unit: &str) -> String {+    let (d, p) = if v >= 1e6 { (1e6, "M") } else if v >= 1e3 { (1e3, "k") } else if v >= 1.0 { (1.0, "") } else { (1e-3, "m") };+    let s = format!("{:.3}", v / d); let s = s.trim_end_matches('0').trim_end_matches('.');+    format!("{s}{p}{unit}")+}++#[cfg(test)]+mod tests {+    use super::*;+    #[test]+    fn codes_and_packages() {+        assert_eq!(decode("472").unwrap().0, 4700.0);+        assert_eq!(decode("4701").unwrap().0, 4700.0);+        assert_eq!(decode("R002").unwrap().0, 0.002);+        assert_eq!(decode("30B").unwrap().0, 2000.0);+        assert_eq!(decode("59C").unwrap().0, 40200.0);+        assert_eq!(decode("01C").unwrap().0, 10000.0);+        assert_eq!(si(40200.0, "Ω"), "40.2kΩ");+        assert_eq!(match_package(1.62, 0.83)[0].0, "0603");+        assert_eq!(match_package(6.1, 5.95)[0].0, "QFN 6x6");+    }+}
src/prep.rsadded+308
@@ -0,0 +1,308 @@+//! Photo preparation: the board outline in each photo (robust to connectors hanging over an edge), the+//! homography that straightens it, the straightened image (bottom photos mirrored into the top view, the+//! KiCad board frame), a sharpness score, and the plated holes.+use crate::geom::{self, P};+use crate::raster::{self, Mask, Plane};+use image::{imageops, Rgb, RgbImage};+use serde_json::{json, Value};++/// Solder-mask green, as opposed to the surface the board lies on.+/// (Bright mint-green parts such as terminal blocks are excluded by the value limit: they can hang over an edge.)+pub fn is_mask_green(p: &Rgb<u8>) -> bool { let (h, s, v) = raster::hsv(p); (65.0..200.0).contains(&h) && s > 0.18 && v > 0.07 && v < 0.62 }++/// The four outer corners (TL, TR, BR, BL) of the board in photo pixels, from straight-line fits to the+/// four edges. Corner radii and anything hanging over an edge are left out of the fits.+pub fn board_corners(img: &RgbImage) -> Result<[P; 4], String> {+    let k = 4u32;+    let small = imageops::resize(img, img.width() / k, img.height() / k, imageops::FilterType::Triangle);+    let m = raster::mask_from(&small, is_mask_green).close(3.0);+    let (lab, cs) = raster::components(&m);+    let big = cs.iter().max_by_key(|c| c.area).ok_or("no green board found in the photo")?;+    if big.area < (m.w * m.h) / 10 { return Err("the green region is under 10 percent of the photo: is the board in frame?".into()); }+    let mut b = Mask::new(m.w, m.h);+    for i in 0..lab.len() { if lab[i] == big.label { b.d[i] = true; } }+    let b = b.fill_holes();+    let (x0, x1, y0, y1) = (big.x0 as f64, big.x1 as f64, big.y0 as f64, big.y1 as f64);+    let (bw, bh) = (x1 - x0, y1 - y0);+    let (mut left, mut right, mut top, mut bot) = (vec![], vec![], vec![], vec![]);+    for y in (y0 + 0.15 * bh) as usize..(y1 - 0.15 * bh) as usize {+        if let Some(x) = (0..b.w).find(|x| b.at(*x, y)) { left.push((y as f64, x as f64)); }+        if let Some(x) = (0..b.w).rev().find(|x| b.at(*x, y)) { right.push((y as f64, x as f64 + 1.0)); }+    }+    for x in (x0 + 0.15 * bw) as usize..(x1 - 0.15 * bw) as usize {+        if let Some(y) = (0..b.h).find(|y| b.at(x, *y)) { top.push((x as f64, y as f64)); }+        if let Some(y) = (0..b.h).rev().find(|y| b.at(x, *y)) { bot.push((x as f64, y as f64 + 1.0)); }+    }+    let f = |v: &[(f64, f64)], n: &str| geom::robust_line(v, 1.5).ok_or(format!("could not fit the {n} edge"));+    let (l, r, t, bo) = (f(&left, "left")?, f(&right, "right")?, f(&top, "top")?, f(&bot, "bottom")?);+    let s = k as f64;+    let c = [geom::cross_vh(l, t), geom::cross_vh(r, t), geom::cross_vh(r, bo), geom::cross_vh(l, bo)];+    Ok(c.map(|p| (p.0 * s, p.1 * s)))+}++/// Straighten the board: output pixel (u, v) samples the photo through the inverse homography.+pub fn rectify(img: &RgbImage, corners: &[P; 4], w: u32, h: u32, mirror: bool) -> Result<(RgbImage, [f64; 9]), String> {+    let dst = [(0.0, 0.0), (w as f64, 0.0), (w as f64, h as f64), (0.0, h as f64)];+    let hm = geom::homography(corners, &dst).ok_or("degenerate board corners")?;+    let inv = geom::invert(&hm).ok_or("homography not invertible")?;+    let planes: Vec<Plane> = (0..3).map(|c| Plane { w: img.width() as usize, h: img.height() as usize, d: img.pixels().map(|p| p[c] as f32).collect() }).collect();+    let out = RgbImage::from_fn(w, h, |u, v| {+        let uu = if mirror { w - 1 - u } else { u };+        let q = geom::apply(&inv, (uu as f64 + 0.5, v as f64 + 0.5));+        Rgb([0, 1, 2].map(|c| planes[c].sample(q.0 as f32 - 0.5, q.1 as f32 - 0.5).clamp(0.0, 255.0) as u8))+    });+    Ok((out, hm))+}++/// Variance of the Laplacian of the luminance: higher is sharper. Compared between photos of one side.+pub fn sharpness(img: &RgbImage) -> f64 {+    let l = raster::luma(img);+    let (mut s, mut s2, mut n) = (0f64, 0f64, 0f64);+    for y in 1..l.h - 1 { for x in 1..l.w - 1 {+        let v = (4.0 * l.at(x, y) - l.at(x - 1, y) - l.at(x + 1, y) - l.at(x, y - 1) - l.at(x, y + 1)) as f64;+        s += v; s2 += v * v; n += 1.0;+    } }+    s2 / n - (s / n).powi(2)+}++pub fn is_metal(p: &Rgb<u8>) -> bool {+    let (h, s, v) = raster::hsv(p);+    ((25.0..72.0).contains(&h) && s > 0.2 && v > 0.38) || (s < 0.2 && v > 0.62)+}++/// Plated holes in a straightened image: metal annuli (gold or solder) enclosing a round opening.+/// Returns (centre, hole diameter, outer size, square?) in that image's pixels.+pub fn holes(img: &RgbImage, min_d: f64, max_d: f64) -> Vec<Value> {+    let m = raster::mask_from(img, is_metal).close(1.5);+    let (lab, cs) = raster::components(&m);+    let mut out = vec![];+    for c in &cs {+        let (bw, bh) = ((c.x1 - c.x0 + 1) as f64, (c.y1 - c.y0 + 1) as f64);+        if bw < min_d || bh < min_d || bw > max_d * 3.5 || bh > max_d * 3.5 || bw / bh > 1.6 || bh / bw > 1.6 { continue; }+        // the opening: background pixels inside the component's box that the component encloses+        let (w, h) = (c.x1 - c.x0 + 3, c.y1 - c.y0 + 3);+        let mut sub = Mask::new(w, h);+        for y in c.y0..=c.y1 { for x in c.x0..=c.x1 { if lab[y * m.w + x] == c.label { sub.set(x - c.x0 + 1, y - c.y0 + 1, true); } } }+        let inner = sub.fill_holes().andnot(&sub);+        let (_, ic) = raster::components(&inner);+        let Some(o) = ic.iter().max_by_key(|q| q.area) else { continue };+        let (ow, oh) = ((o.x1 - o.x0 + 1) as f64, (o.y1 - o.y0 + 1) as f64);+        let d = (ow + oh) / 2.0;+        let roundness = o.area as f64 / (std::f64::consts::PI * (d / 2.0).powi(2));+        if d < min_d || d > max_d || ow / oh > 1.35 || oh / ow > 1.35 || roundness < 0.6 { continue; }+        // the ring around the opening must be substantial: a pad, not a letter's counter+        let ring = c.area as f64 / (o.area as f64).max(1.0);+        if ring < 0.8 { continue; }+        let fill = c.area as f64 / (bw * bh);+        let square = fill > 0.72 * (1.0 - o.area as f64 / (bw * bh)) && (bw - bh).abs() / bw < 0.15 && fill + o.area as f64 / (bw * bh) > 0.88;+        out.push(json!({"x": c.x0 as f64 - 1.0 + o.cx + 0.5, "y": c.y0 as f64 - 1.0 + o.cy + 0.5, "hole": d, "outer": (bw + bh) / 2.0, "square": square}));+    }+    out.sort_by(|a, b| (a["y"].as_f64().unwrap(), a["x"].as_f64().unwrap()).partial_cmp(&(b["y"].as_f64().unwrap(), b["x"].as_f64().unwrap())).unwrap());+    out+}++#[cfg(test)]+mod tests {+    use super::*;+    #[test]+    fn finds_a_tilted_board() {+        // brown background, a green quad, a yellow block hanging over the left edge+        let mut img = RgbImage::from_pixel(800, 600, Rgb([110, 60, 30]));+        let quad = [(100.0, 80.0), (700.0, 100.0), (690.0, 520.0), (90.0, 500.0)];+        let h = geom::homography(&[(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)], &quad).unwrap();+        let hi = geom::invert(&h).unwrap();+        for y in 0..600 { for x in 0..800 {+            let q = geom::apply(&hi, (x as f64 + 0.5, y as f64 + 0.5));+            if (0.0..1.0).contains(&q.0) && (0.0..1.0).contains(&q.1) { img.put_pixel(x, y, Rgb([20, 90, 60])); }+        } }+        for y in 250..330 { for x in 60..160 { img.put_pixel(x, y, Rgb([230, 190, 40])); } }+        let c = board_corners(&img).unwrap();+        for i in 0..4 { assert!(geom::dist(c[i], quad[i]) < 6.0, "{i}: {:?} vs {:?}", c[i], quad[i]); }+    }+}++fn hx(v: &Value) -> (f64, f64) { (v["x"].as_f64().unwrap_or(0.0), v["y"].as_f64().unwrap_or(0.0)) }++/// How the bottom photo maps into the top view: the board is turned over either left-right ("x") or+/// top-bottom ("y"). Decided by which mirroring makes the most bottom holes land on top holes.+/// Coordinates are fractions of the board, so the two photos' different pixel sizes do not matter.+pub fn flip_axis(top: &[Value], top_wh: (f64, f64), bot: &[Value], bot_wh: (f64, f64)) -> (String, usize, usize) {+    let t: Vec<P> = top.iter().map(|h| { let p = hx(h); (p.0 / top_wh.0, p.1 / top_wh.1) }).collect();+    let score = |f: &dyn Fn(P) -> P| bot.iter().filter(|h| { let p = hx(h); let q = f((p.0 / bot_wh.0, p.1 / bot_wh.1)); t.iter().any(|r| geom::dist(*r, q) < 0.012) }).count();+    let sx = score(&|p: P| (1.0 - p.0, p.1));+    let sy = score(&|p: P| (p.0, 1.0 - p.1));+    if sy > sx { ("y".into(), sy, sx) } else { ("x".into(), sx, sy) }+}++/// Rows of three or more holes at an even pitch (headers, terminal blocks): scale candidates.+pub fn hole_rows(holes: &[Value]) -> Vec<Value> {+    let pts: Vec<(usize, P, f64)> = holes.iter().enumerate().map(|(i, h)| (i, hx(h), h["outer"].as_f64().unwrap_or(20.0))).collect();+    let mut rows = vec![];+    for vertical in [false, true] {+        let key = |p: P| if vertical { (p.0, p.1) } else { (p.1, p.0) }; // (cross, along)+        let mut sorted = pts.clone();+        sorted.sort_by(|a, b| key(a.1).partial_cmp(&key(b.1)).unwrap());+        let mut used = vec![false; sorted.len()];+        for i in 0..sorted.len() {+            if used[i] { continue; }+            let (c0, tol) = (key(sorted[i].1).0, sorted[i].2 * 0.35);+            let mut line: Vec<&(usize, P, f64)> = sorted.iter().enumerate().filter(|(j, q)| !used[*j] && (key(q.1).0 - c0).abs() < tol).map(|(_, q)| q).collect();+            line.sort_by(|a, b| key(a.1).1.partial_cmp(&key(b.1).1).unwrap());+            for (j, q) in sorted.iter().enumerate() { if (key(q.1).0 - c0).abs() < tol { used[j] = true; } }+            // split the line into runs of even spacing+            let mut run: Vec<&(usize, P, f64)> = vec![];+            let mut flush = |run: &mut Vec<&(usize, P, f64)>| {+                if run.len() >= 3 {+                    let pitch = (key(run[run.len() - 1].1).1 - key(run[0].1).1) / (run.len() - 1) as f64;+                    rows.push(json!({"holes": run.iter().map(|q| q.0).collect::<Vec<_>>(), "pitch": pitch, "count": run.len(), "vertical": vertical}));+                }+                run.clear();+            };+            for q in line {+                if run.len() >= 2 {+                    let last_gap = key(run[run.len() - 1].1).1 - key(run[run.len() - 2].1).1;+                    let gap = key(q.1).1 - key(run[run.len() - 1].1).1;+                    if (gap - last_gap).abs() > 0.06 * last_gap { flush(&mut run); }+                } else if run.len() == 1 {+                    let gap = key(q.1).1 - key(run[0].1).1;+                    if gap > q.2 * 4.0 { flush(&mut run); }+                }+                run.push(q);+            }+            flush(&mut run);+        }+    }+    rows+}++/// Material of one pixel. Bodies are found from body materials only, so shadows between parts and the+/// silkscreen never join two parts; metal (terminations, pads) is attached to the body it touches.+#[derive(Clone, Copy, PartialEq, Debug)]+pub enum Mat { Mask, Silk, Metal, Gold, Black, Tan, Yellow, BrightGreen, White, Other }++/// Thresholds measured on the Driver photos (10th/50th/90th percentiles, 2026-11-03): solder mask+/// s 0.57/0.78/1.00 v 0.13/0.20/0.29; black moulding s 0.10/0.26/0.60 v 0.06/0.13/0.22 (QFN s 0.18,+/// v 0.16); shadows between parts s 0.58 (mask-like); tan ceramic h 20-32 s 0.64-0.93 v 0.33-0.46;+/// terminations s 0.06-0.35 v 0.17-0.56.+pub fn material(p: &Rgb<u8>) -> Mat {+    let (h, s, v) = raster::hsv(p);+    if v < 0.30 && s < 0.45 { return Mat::Black; }+    if (110.0..200.0).contains(&h) && s >= 0.45 && v < 0.62 { return Mat::Mask; }+    if (80.0..170.0).contains(&h) && s > 0.3 && v >= 0.62 { return Mat::BrightGreen; }+    if (40.0..65.0).contains(&h) && s > 0.55 && v > 0.55 { return Mat::Yellow; }+    if (25.0..60.0).contains(&h) && (0.25..0.55).contains(&s) && v > 0.45 { return Mat::Gold; }+    if (8.0..45.0).contains(&h) && s > 0.45 && (0.25..0.8).contains(&v) { return Mat::Tan; }+    if s < 0.2 && v > 0.75 { return Mat::Silk; }+    if s < 0.35 && v >= 0.3 { return Mat::Metal; }+    Mat::Other+}++/// Part bodies on a straightened photo: connected regions of body material (black moulding, tan+/// ceramic, yellow or bright-green plastic, large white plastic) after an opening of `silk_px` that+/// removes silkscreen strokes and noise. Each body is grown by the metal it touches (terminations,+/// leads) and measured as an oriented rectangle, with and without that metal.+pub fn bodies(img: &RgbImage, silk_px: f32, min_px: f64) -> Vec<Value> {+    let mats: Vec<Mat> = img.pixels().map(material).collect();+    let (w, h) = (img.width() as usize, img.height() as usize);+    let mk = |f: &dyn Fn(Mat) -> bool| crate::raster::Mask { w, h, d: mats.iter().map(|m| f(*m)).collect() };+    // black moulding carries laser marking and highlights: close small gaps and fill what it encloses+    // a hole is filled only when it is marking, highlight or metal: never ceramic, plastic or mask,+    // so a ring of shadows round a capacitor array cannot turn the array into one body+    let black0 = mk(&|m| m == Mat::Black).close(silk_px * 1.5);+    let holes = black0.fill_holes().andnot(&black0);+    let (hl, hc) = raster::components(&holes);+    let mut ok = vec![false; hc.len() + 1];+    { let mut tally = vec![(0usize, 0usize); hc.len() + 1];+      for i in 0..hl.len() { let l = hl[i] as usize; if l == 0 { continue; } tally[l].0 += 1;+          if matches!(mats[i], Mat::Mask | Mat::Tan | Mat::Yellow | Mat::BrightGreen) { tally[l].1 += 1; } }+      for l in 1..=hc.len() { ok[l] = tally[l].1 * 10 < tally[l].0 * 3; } }+    let mut black = black0.clone();+    for i in 0..hl.len() { if hl[i] != 0 && ok[hl[i] as usize] { black.d[i] = true; } }+    // one material at a time: a black body never joins a ceramic one through the shadows between them+    let sets: Vec<(&str, crate::raster::Mask)> = vec![+        ("black", black.open(silk_px)),+        ("tan", mk(&|m| m == Mat::Tan).open(silk_px)),+        ("yellow", mk(&|m| m == Mat::Yellow).open(silk_px)),+        ("bright-green", mk(&|m| m == Mat::BrightGreen).open(silk_px)),+        ("white", mk(&|m| matches!(m, Mat::Silk | Mat::White)).open(silk_px * 2.5)), // only large white plastic survives+    ];+    let metal = mk(&|m| matches!(m, Mat::Metal | Mat::Gold | Mat::Silk));+    let mut lab = vec![0u32; w * h];+    let mut cs: Vec<(raster::Component, &str)> = vec![];+    for (name, m) in &sets {+        let (l, c) = split_components(m, silk_px * 2.0);+        let base = cs.len() as u32;+        for i in 0..l.len() { if l[i] != 0 { lab[i] = l[i] + base; } }+        for mut k in c { k.label += base; cs.push((k, name)); }+    }+    let solid = crate::raster::Mask { w, h, d: lab.iter().map(|l| *l != 0).collect() };+    // metal pixels within reach of a body join it (terminations); measured separately+    let near = solid.dilate(silk_px * 1.5);+    let reach = metal.and(&near);+    let dist_lab = { // nearest-body label for each reach pixel: grow labels by BFS+        let mut l = lab.clone(); let mut frontier: Vec<usize> = (0..w * h).filter(|i| l[*i] != 0).collect();+        for _ in 0..(silk_px * 2.0) as usize {+            let mut next = vec![];+            for &i in &frontier { let (x, y) = (i % w, i / w);+                for (dx, dy) in [(1i64, 0i64), (-1, 0), (0, 1), (0, -1)] {+                    let (nx, ny) = (x as i64 + dx, y as i64 + dy); if nx < 0 || ny < 0 || nx >= w as i64 || ny >= h as i64 { continue; }+                    let j = ny as usize * w + nx as usize; if l[j] == 0 && reach.d[j] { l[j] = l[i]; next.push(j); }+                } }+            frontier = next;+        }+        l+    };+    let mut out = vec![];+    for (c, material_name) in &cs {+        if ((c.x1 - c.x0 + 1) as f64) < min_px && ((c.y1 - c.y0 + 1) as f64) < min_px { continue; }+        if c.area as f64 > 0.2 * (w * h) as f64 { continue; }+        let pad = (silk_px * 3.0) as usize;+        let (x0, y0, x1, y1) = (c.x0.saturating_sub(pad), c.y0.saturating_sub(pad), (c.x1 + pad).min(w - 1), (c.y1 + pad).min(h - 1));+        let (mut core, mut full) = (vec![], vec![]);+        for y in y0..=y1 { for x in x0..=x1 {+            let i = y * w + x;+            if lab[i] == c.label { core.push((x as f64 + 0.5, y as f64 + 0.5)); full.push((x as f64 + 0.5, y as f64 + 0.5)); }+            else if dist_lab[i] == c.label { full.push((x as f64 + 0.5, y as f64 + 0.5)); }+        } }+        let (ctr, long, short, ang) = geom::min_rect(&core);+        let (fctr, flong, fshort, fang) = geom::min_rect(&full);+        let colour = *material_name;+        // a body fills most of its rectangle; a shadow network or a silkscreen frame does not+        if long * short > 0.0 && (c.area as f64) / (long * short) < 0.5 { continue; }+        out.push(json!({"x": fctr.0, "y": fctr.1, "long": flong, "short": fshort, "angle": fang,+                        "body": {"x": ctr.0, "y": ctr.1, "long": long, "short": short, "angle": ang},+                        "area": c.area, "colour": colour, "metal": full.len() - core.len()}));+    }+    out+}++/// Components of a mask, with bodies joined by bridges thinner than 2 x `r` split apart: label the+/// cores left after an erosion by `r`, then grow each label back over its own mask (breadth first, so+/// two bodies meet halfway across the bridge). Pieces too small to leave a core keep their own label.+pub fn split_components(m: &crate::raster::Mask, r: f32) -> (Vec<u32>, Vec<raster::Component>) {+    let (w, h) = (m.w, m.h);+    let core = m.erode(r);+    let (mut lab, _) = raster::components(&core);+    let mut frontier: Vec<usize> = (0..w * h).filter(|i| lab[*i] != 0).collect();+    while !frontier.is_empty() {+        let mut next = vec![];+        for &i in &frontier { let (x, y) = (i % w, i / w);+            for (dx, dy) in [(1i64, 0i64), (-1, 0), (0, 1), (0, -1)] {+                let (nx, ny) = (x as i64 + dx, y as i64 + dy); if nx < 0 || ny < 0 || nx >= w as i64 || ny >= h as i64 { continue; }+                let j = ny as usize * w + nx as usize; if m.d[j] && lab[j] == 0 { lab[j] = lab[i]; next.push(j); }+            } }+        frontier = next;+    }+    // pieces with no core (thin or small): label them on their own+    let rest = crate::raster::Mask { w, h, d: (0..w * h).map(|i| m.d[i] && lab[i] == 0).collect() };+    let (rl, _) = raster::components(&rest);+    let base = lab.iter().copied().max().unwrap_or(0);+    for i in 0..w * h { if rl[i] != 0 { lab[i] = base + rl[i]; } }+    // stats per label+    let n = lab.iter().copied().max().unwrap_or(0) as usize;+    let mut st: Vec<(usize, usize, usize, usize, usize, f64, f64)> = vec![(0, usize::MAX, usize::MAX, 0, 0, 0.0, 0.0); n + 1];+    for i in 0..w * h { let l = lab[i] as usize; if l == 0 { continue; } let (x, y) = (i % w, i / w); let s = &mut st[l];+        s.0 += 1; s.1 = s.1.min(x); s.2 = s.2.min(y); s.3 = s.3.max(x); s.4 = s.4.max(y); s.5 += x as f64; s.6 += y as f64; }+    let comps = (1..=n).filter(|l| st[*l].0 > 0).map(|l| { let s = st[l]; raster::Component { label: l as u32, area: s.0, x0: s.1, y0: s.2, x1: s.3, y1: s.4, cx: s.5 / s.0 as f64, cy: s.6 / s.0 as f64 } }).collect();+    (lab, comps)+}
src/raster.rsadded+208
@@ -0,0 +1,208 @@+//! Image arithmetic the measurements are built on: float planes, masks, separable blurs, morphology by+//! distance, connected components and boundary tracing. Plain Rust, no image-processing crate, so every+//! number the flow reports can be traced to code in this repository.+use image::{Rgb, RgbImage};++#[derive(Clone)]+pub struct Plane { pub w: usize, pub h: usize, pub d: Vec<f32> }++#[derive(Clone)]+pub struct Mask { pub w: usize, pub h: usize, pub d: Vec<bool> }++impl Plane {+    pub fn new(w: usize, h: usize, v: f32) -> Plane { Plane { w, h, d: vec![v; w * h] } }+    #[inline] pub fn at(&self, x: usize, y: usize) -> f32 { self.d[y * self.w + x] }+    /// Bilinear sample, clamped at the edges.+    pub fn sample(&self, x: f32, y: f32) -> f32 {+        let x = x.clamp(0.0, (self.w - 1) as f32); let y = y.clamp(0.0, (self.h - 1) as f32);+        let (x0, y0) = (x.floor() as usize, y.floor() as usize);+        let (x1, y1) = ((x0 + 1).min(self.w - 1), (y0 + 1).min(self.h - 1));+        let (fx, fy) = (x - x0 as f32, y - y0 as f32);+        let a = self.at(x0, y0) * (1.0 - fx) + self.at(x1, y0) * fx;+        let b = self.at(x0, y1) * (1.0 - fx) + self.at(x1, y1) * fx;+        a * (1.0 - fy) + b * fy+    }+    pub fn threshold(&self, t: f32) -> Mask { Mask { w: self.w, h: self.h, d: self.d.iter().map(|v| *v > t).collect() } }+    pub fn mean_in(&self, m: &Mask) -> f32 {+        let (mut s, mut n) = (0.0f64, 0usize);+        for i in 0..self.d.len() { if m.d[i] { s += self.d[i] as f64; n += 1; } }+        if n == 0 { 0.0 } else { (s / n as f64) as f32 }+    }+}++impl Mask {+    pub fn new(w: usize, h: usize) -> Mask { Mask { w, h, d: vec![false; w * h] } }+    #[inline] pub fn at(&self, x: usize, y: usize) -> bool { self.d[y * self.w + x] }+    #[inline] pub fn set(&mut self, x: usize, y: usize, v: bool) { self.d[y * self.w + x] = v; }+    pub fn count(&self) -> usize { self.d.iter().filter(|v| **v).count() }+    pub fn and(&self, o: &Mask) -> Mask { Mask { w: self.w, h: self.h, d: self.d.iter().zip(&o.d).map(|(a, b)| *a && *b).collect() } }+    pub fn or(&self, o: &Mask) -> Mask { Mask { w: self.w, h: self.h, d: self.d.iter().zip(&o.d).map(|(a, b)| *a || *b).collect() } }+    pub fn not(&self) -> Mask { Mask { w: self.w, h: self.h, d: self.d.iter().map(|a| !*a).collect() } }+    pub fn andnot(&self, o: &Mask) -> Mask { Mask { w: self.w, h: self.h, d: self.d.iter().zip(&o.d).map(|(a, b)| *a && !*b).collect() } }++    /// Fill every background region that does not touch the image border.+    pub fn fill_holes(&self) -> Mask {+        let (w, h) = (self.w, self.h);+        let mut outside = vec![false; w * h];+        let mut st: Vec<usize> = vec![];+        for x in 0..w { for y in [0, h - 1] { let i = y * w + x; if !self.d[i] && !outside[i] { outside[i] = true; st.push(i); } } }+        for y in 0..h { for x in [0, w - 1] { let i = y * w + x; if !self.d[i] && !outside[i] { outside[i] = true; st.push(i); } } }+        while let Some(i) = st.pop() {+            let (x, y) = (i % w, i / w);+            let mut push = |j: usize| { if !self.d[j] && !outside[j] { outside[j] = true; st.push(j); } };+            if x > 0 { push(i - 1); } if x + 1 < w { push(i + 1); } if y > 0 { push(i - w); } if y + 1 < h { push(i + w); }+        }+        Mask { w, h, d: outside.iter().map(|o| !*o).collect() }+    }++    /// Euclidean-ish distance (chamfer 3-4, in pixels) from every pixel to the nearest set pixel.+    pub fn distance_to_set(&self) -> Plane {+        let (w, h) = (self.w, self.h);+        let inf = 1e9f32;+        let mut d: Vec<f32> = self.d.iter().map(|v| if *v { 0.0 } else { inf }).collect();+        for y in 0..h { for x in 0..w {+            let i = y * w + x; let mut v = d[i];+            if x > 0 { v = v.min(d[i - 1] + 3.0); }+            if y > 0 { v = v.min(d[i - w] + 3.0); if x > 0 { v = v.min(d[i - w - 1] + 4.0); } if x + 1 < w { v = v.min(d[i - w + 1] + 4.0); } }+            d[i] = v;+        } }+        for y in (0..h).rev() { for x in (0..w).rev() {+            let i = y * w + x; let mut v = d[i];+            if x + 1 < w { v = v.min(d[i + 1] + 3.0); }+            if y + 1 < h { v = v.min(d[i + w] + 3.0); if x + 1 < w { v = v.min(d[i + w + 1] + 4.0); } if x > 0 { v = v.min(d[i + w - 1] + 4.0); } }+            d[i] = v;+        } }+        Plane { w, h, d: d.into_iter().map(|v| v / 3.0).collect() }+    }+    /// Grow by `r` pixels (disk).+    pub fn dilate(&self, r: f32) -> Mask { let d = self.distance_to_set(); Mask { w: self.w, h: self.h, d: d.d.iter().map(|v| *v <= r).collect() } }+    /// Shrink by `r` pixels (disk).+    pub fn erode(&self, r: f32) -> Mask { self.not().dilate(r).not() }+    pub fn open(&self, r: f32) -> Mask { self.erode(r).dilate(r) }+    pub fn close(&self, r: f32) -> Mask { self.dilate(r).erode(r) }+}++/// Separable box blur, `passes` times (three passes approximate a Gaussian of sigma ~ r).+pub fn blur(p: &Plane, r: usize, passes: usize) -> Plane {+    let (w, h) = (p.w, p.h);+    let mut a = p.d.clone();+    let mut b = vec![0f32; w * h];+    for _ in 0..passes {+        for y in 0..h {+            let row = &a[y * w..(y + 1) * w];+            let mut acc = 0f32; let mut n = 0f32;+            for x in 0..=r.min(w - 1) { acc += row[x]; n += 1.0; }+            for x in 0..w {+                b[y * w + x] = acc / n;+                if x + r + 1 < w { acc += row[x + r + 1]; n += 1.0; }+                if x >= r { acc -= row[x - r]; n -= 1.0; }+            }+        }+        for x in 0..w {+            let mut acc = 0f32; let mut n = 0f32;+            for y in 0..=r.min(h - 1) { acc += b[y * w + x]; n += 1.0; }+            for y in 0..h {+                a[y * w + x] = acc / n;+                if y + r + 1 < h { acc += b[(y + r + 1) * w + x]; n += 1.0; }+                if y >= r { acc -= b[(y - r) * w + x]; n -= 1.0; }+            }+        }+    }+    Plane { w, h, d: a }+}++/// Hue (degrees), saturation and value of an RGB pixel.+#[inline]+pub fn hsv(p: &Rgb<u8>) -> (f32, f32, f32) {+    let (r, g, b) = (p[0] as f32 / 255.0, p[1] as f32 / 255.0, p[2] as f32 / 255.0);+    let mx = r.max(g).max(b); let mn = r.min(g).min(b); let c = mx - mn;+    let h = if c < 1e-6 { 0.0 } else if mx == r { 60.0 * (((g - b) / c).rem_euclid(6.0)) } else if mx == g { 60.0 * ((b - r) / c + 2.0) } else { 60.0 * ((r - g) / c + 4.0) };+    (h, if mx > 0.0 { c / mx } else { 0.0 }, mx)+}++pub fn luma(img: &RgbImage) -> Plane {+    Plane { w: img.width() as usize, h: img.height() as usize, d: img.pixels().map(|p| 0.299 * p[0] as f32 + 0.587 * p[1] as f32 + 0.114 * p[2] as f32).collect() }+}++pub fn mask_from(img: &RgbImage, f: impl Fn(&Rgb<u8>) -> bool) -> Mask {+    Mask { w: img.width() as usize, h: img.height() as usize, d: img.pixels().map(|p| f(p)).collect() }+}++pub struct Component { pub label: u32, pub area: usize, pub x0: usize, pub y0: usize, pub x1: usize, pub y1: usize, pub cx: f64, pub cy: f64 }++/// 8-connected components of a mask: the label image (0 = background) and each component's stats.+pub fn components(m: &Mask) -> (Vec<u32>, Vec<Component>) {+    let (w, h) = (m.w, m.h);+    let mut lab = vec![0u32; w * h];+    let mut out = vec![];+    let mut st = vec![];+    for s in 0..w * h {+        if !m.d[s] || lab[s] != 0 { continue; }+        let l = out.len() as u32 + 1;+        lab[s] = l; st.push(s);+        let (mut area, mut x0, mut y0, mut x1, mut y1, mut sx, mut sy) = (0usize, usize::MAX, usize::MAX, 0usize, 0usize, 0f64, 0f64);+        while let Some(i) = st.pop() {+            let (x, y) = (i % w, i / w);+            area += 1; x0 = x0.min(x); y0 = y0.min(y); x1 = x1.max(x); y1 = y1.max(y); sx += x as f64; sy += y as f64;+            for dy in -1i64..=1 { for dx in -1i64..=1 {+                let (nx, ny) = (x as i64 + dx, y as i64 + dy);+                if nx < 0 || ny < 0 || nx >= w as i64 || ny >= h as i64 { continue; }+                let j = ny as usize * w + nx as usize;+                if m.d[j] && lab[j] == 0 { lab[j] = l; st.push(j); }+            } }+        }+        out.push(Component { label: l, area, x0, y0, x1, y1, cx: sx / area as f64, cy: sy / area as f64 });+    }+    (lab, out)+}++/// The outer boundary of one labelled component, as pixel corners in order (Moore neighbour tracing).+pub fn trace_boundary(lab: &[u32], w: usize, h: usize, c: &Component) -> Vec<(f64, f64)> {+    let inside = |x: i64, y: i64| x >= 0 && y >= 0 && x < w as i64 && y < h as i64 && lab[y as usize * w + x as usize] == c.label;+    // start: the first pixel of the component in raster order+    let mut start = (0i64, 0i64);+    'f: for y in c.y0..=c.y1 { for x in c.x0..=c.x1 { if lab[y * w + x] == c.label { start = (x as i64, y as i64); break 'f; } } }+    let dirs = [(1i64, 0i64), (1, 1), (0, 1), (-1, 1), (-1, 0), (-1, -1), (0, -1), (1, -1)];+    let mut pts = vec![(start.0 as f64 + 0.5, start.1 as f64 + 0.5)];+    let (mut cur, mut d) = (start, 6usize);+    for _ in 0..(c.area * 4 + 16) {+        let mut found = false;+        for k in 0..8 {+            let nd = (d + 6 + k) % 8; // start looking back-left of the last direction+            let n = (cur.0 + dirs[nd].0, cur.1 + dirs[nd].1);+            if inside(n.0, n.1) { cur = n; d = nd; found = true; break; }+        }+        if !found || cur == start { break; }+        pts.push((cur.0 as f64 + 0.5, cur.1 as f64 + 0.5));+    }+    pts+}++pub fn to_rgb_plane(p: &Plane) -> RgbImage {+    let (mn, mx) = p.d.iter().fold((f32::MAX, f32::MIN), |(a, b), v| (a.min(*v), b.max(*v)));+    let s = if mx > mn { 255.0 / (mx - mn) } else { 1.0 };+    RgbImage::from_fn(p.w as u32, p.h as u32, |x, y| { let v = ((p.at(x as usize, y as usize) - mn) * s) as u8; Rgb([v, v, v]) })+}++#[cfg(test)]+mod tests {+    use super::*;+    #[test]+    fn morphology_and_components() {+        let mut m = Mask::new(40, 20);+        for y in 5..15 { for x in 5..15 { m.set(x, y, true); } }+        for y in 5..15 { for x in 25..35 { m.set(x, y, true); } }+        m.set(10, 10, false); // a hole+        assert!(m.fill_holes().at(10, 10));+        let (_, cs) = components(&m);+        assert_eq!(cs.len(), 2);+        // a 10 x 10 square eroded by 2 px keeps its 6 x 6 core+        let mut sq = Mask::new(20, 20);+        for y in 5..15 { for x in 5..15 { sq.set(x, y, true); } }+        assert_eq!(sq.erode(2.0).count(), 36);+        let opened = sq.erode(2.0).dilate(2.0).count(); // opening rounds the four corners only+        assert!((84..100).contains(&opened), "{opened}");+        let b = trace_boundary(&components(&m).0, 40, 20, &components(&m).1[0]);+        assert!(b.len() >= 36 && b.len() <= 40, "{}", b.len());+    }+}
src/report.rsadded+88
@@ -0,0 +1,88 @@+//! The generated half of the report: evidence counts, the open items and the tests that settle them,+//! the layer proposal, the review passes and the check states. The AI writes report.md around these;+//! the numbers come from the artifacts, never from memory.+use crate::flow::{self, Flow};+use crate::items::{self, TAGS};+use serde_json::{json, Value};+use std::collections::{BTreeMap, HashMap};+use std::path::Path;++pub fn stats(flow_dir: &Path) -> (String, Value) {+    let all = items::all(flow_dir);+    let mut by: BTreeMap<String, BTreeMap<String, usize>> = BTreeMap::new();+    for it in &all {+        let a = it.artifact.trim_end_matches(".json");+        let label = if a == it.kind { a.to_string() } else { format!("{a}: {}", it.kind) };+        *by.entry(label).or_default().entry(it.tag.clone()).or_default() += 1; }+    let mut t = format!("| Items | Total | {} | Known % |\n|---|---|{}---|\n", TAGS.join(" | "), "---|".repeat(TAGS.len()));+    let mut v = serde_json::Map::new();+    for (k, m) in &by {+        let total: usize = m.values().sum();+        let known: usize = ["SEEN", "MEASURED", "DATASHEET"].iter().map(|x| m.get(*x).copied().unwrap_or(0)).sum();+        let pct = if total > 0 { 100.0 * known as f64 / total as f64 } else { 0.0 };+        t += &format!("| {k} | {total} | {} | {pct:.0} |\n", TAGS.iter().map(|x| m.get(*x).copied().unwrap_or(0).to_string()).collect::<Vec<_>>().join(" | "));+        v.insert(k.clone(), json!({"total": total, "tags": m, "knownPercent": (pct * 10.0).round() / 10.0}));+    }+    (t, Value::Object(v))+}++fn s<'a>(v: &'a Value, k: &str) -> &'a str { v[k].as_str().unwrap_or("") }++pub fn tables(flow_dir: &Path, fl: &Flow) -> String {+    let mut md = String::from("# Generated tables\n\nWritten by `adom-aiflow-reverse report` from the artifacts. Do not edit by hand: change the artifacts and run it again.\n\n");+    md += "## Evidence per item type\n\n";+    md += &stats(flow_dir).0;++    let tests = items::read_json(&flow_dir.join("tests.json")).unwrap_or(json!({}));+    let mut by_item: HashMap<String, Vec<String>> = HashMap::new();+    let tlist: Vec<Value> = tests["tests"].as_array().cloned().unwrap_or_default();+    for t in &tlist {+        for x in t["settles"].as_array().into_iter().flatten().filter_map(|x| x.as_str()) {+            by_item.entry(x.split('.').next().unwrap_or(x).to_string()).or_default().push(s(t, "id").to_string());+        }+    }+    md += "\n## What is not seen, and the test that settles it\n\n| Item | Where | Not known | Tests |\n|---|---|---|---|\n";+    for it in items::all(flow_dir).iter().filter(|i| i.open()) {+        let mut ts = by_item.get(&it.id).cloned().unwrap_or_default();+        ts.extend(it.tests.iter().filter(|t| tlist.iter().any(|x| x["id"].as_str() == Some(t.as_str()))).cloned());+        ts.sort(); ts.dedup();+        md += &format!("| {} | {} | {} | {} |\n", it.id, it.artifact, it.open_what().join(", "), if ts.is_empty() { "**none yet**".into() } else { ts.join(", ") });+    }+    if !tlist.is_empty() {+        md += "\n## Tests\n\n| Test | Settles | Method | Equipment | Expected | If not |\n|---|---|---|---|---|---|\n";+        for t in &tlist {+            let st: Vec<&str> = t["settles"].as_array().into_iter().flatten().filter_map(|x| x.as_str()).collect();+            md += &format!("| {} | {} | {} | {} | {} | {} |\n", s(t, "id"), st.join(", "), s(t, "method"), s(t, "equipment"), s(t, "expected"), s(t, "ifNot"));+        }+    }++    if let Ok(l) = items::read_json(&flow_dir.join("layers.json")) {+        md += &format!("\n## Layer count\n\n**Proposed: {} layers** ({}). {}\n\n| Layers | Open after routing | New DRC errors | Missing per KiCad | Vias | Track mm | Crossing seen copper, mm | Run |\n|---|---|---|---|---|---|---|---|\n", l["proposed"], s(&l, "tag"), s(&l, "why"));+        for c in l["candidates"].as_array().into_iter().flatten() {+            md += &format!("| {} | {} | {} | {} | {} | {} | {} | {} |\n", c["layers"], c["open"], c.get("drcErrors").cloned().unwrap_or(json!("")), c.get("kicadOpen").cloned().unwrap_or(json!("")), c.get("vias").cloned().unwrap_or(json!("")), c.get("trackMm").cloned().unwrap_or(json!("")), c.get("seenCrossingMm").cloned().unwrap_or(json!("")), s(c, "run"));+        }+        let planes = l["planes"].as_array().cloned().unwrap_or_default();+        if !planes.is_empty() {+            md += "\n| Inner layer | Net | Tag | Why |\n|---|---|---|---|\n";+            for p in planes { md += &format!("| {} | {} | {} | {} |\n", s(&p, "layer"), s(&p, "net"), s(&p, "tag"), s(&p, "why")); }+        }+    }++    if let Ok(r) = items::read_json(&flow_dir.join("review.json")) {+        let passes = r["passes"].as_array().cloned().unwrap_or_default();+        md += "\n## Review loop\n\n| Pass | Changes | Note |\n|---|---|---|\n";+        for p in &passes { md += &format!("| {} | {} | {} |\n", p["n"], p["changes"], s(p, "note")); }+        if let Some(last) = passes.last() {+            md += "\n**Confidence after the last pass**\n\n| Category | Level | Why |\n|---|---|---|\n";+            for k in crate::checks::CATEGORIES { md += &format!("| {k} | {} | {} |\n", s(&last["confidence"][k], "level"), s(&last["confidence"][k], "why")); }+        }+    }++    md += "\n## Steps\n\n| Step | State |\n|---|---|\n";+    for st in &fl.steps {+        let done = st.done.iter().all(|x| flow::exists(flow_dir, x));+        let stale: Vec<String> = st.done.iter().flat_map(|x| flow::stale(flow_dir, x)).collect();+        md += &format!("| {} | {} |\n", st.name, if done { "done".into() } else if !stale.is_empty() { format!("stale ({})", stale.join(", ")) } else { "not done".into() });+    }+    md+}
src/route.rsadded+777
@@ -0,0 +1,777 @@+//! The router: closes the connections nobody can see, on every signal layer of the stackup.+//!+//! A through-hole via is the only layer change, because that is what the photos show. The board is a+//! 0.05 mm grid per copper layer. Every piece of copper stamps a keep-out for the centre line of a+//! narrowest track (its own width or shape, plus the clearance, plus half a track), so a cell is usable+//! by a net exactly when all copper near it belongs to that net. A wider track or a via tests a ring+//! around the cell. Stamps are counted, so ripping a net up removes exactly what it added.+//!+//! Only what cannot be moved blocks: pads, holes, vias seen on the board in copper of a known net (a hole through+//! every layer) and the board edge. Seen copper does not block, because a real via passes through a pour and the pour clears around+//! it, and the fill here is computed after routing, so it does the same. Crossing seen copper of another net+//! costs instead: six times the length for copper of unknown net, which may be the net being routed, twelve for+//! a known net. A net's own copper is free and counts as connected. The length that still crosses foreign copper+//! is reported for each stackup: it measures how far the route departs from the photo, and so how hard the board+//! is at that layer count.+//!+//! Inner layers are either planes (no routing, a via landing there is connected) or signal layers.+//! A connection that finds no path is searched again with routed copper made passable at a cost: the nets+//! it crossed are ripped up and queued again, and the congested tiles get dearer so the next try differs.+//! When a whole pass closes nothing new the search stops: the layer count is what limits it, and the+//! caller moves to the next stackup.+use crate::kicad_out::{Grid, Pad};+use crate::raster::Mask;+use serde_json::{json, Value};+use std::cmp::Reverse;+use std::collections::{BinaryHeap, HashMap, HashSet, VecDeque};+use std::time::Instant;++/// Stays clear of rounding in the stamps, so the DRC sees at least the rule.+const MARGIN: f64 = 0.012;+const STEP: u32 = 50;+const DIAG: u32 = 71;+const DIRS: [(i32, i32); 8] = [(1, 0), (-1, 0), (0, 1), (0, -1), (1, 1), (1, -1), (-1, 1), (-1, -1)];++#[derive(Clone, Debug)]+pub struct Rules { pub clr: f64, pub track: f64, pub power_track: f64, pub via: (f64, f64), pub power_via: (f64, f64), pub edge: f64, pub hole_clr: f64, pub hole_hole: f64 }++/// `cells`: the zone's copper on the grid.+pub struct ZoneIn { pub id: String, pub net: String, pub layer: usize, pub cells: Vec<usize> }+pub struct ViaIn { pub id: String, pub net: String, pub x: f64, pub y: f64, pub d: f64, pub h: f64 }+/// A piece of track seen on the photo.+pub struct TrackIn { pub id: String, pub net: String, pub layer: usize, pub path: Vec<(f64, f64)>, pub w: f64 }+/// Copper and pins the photo shows joined already, on one net.+pub struct GroupIn { pub net: String, pub pins: Vec<String>, pub zones: Vec<String>, pub traces: Vec<String>, pub vias: Vec<String> }++pub struct Input<'a> {+    pub pads: &'a [Pad],+    pub layers: Vec<String>,+    /// net name to its plane layer index+    pub planes: HashMap<String, usize>,+    pub inside: &'a Mask,+    pub grid: &'a Grid,+    pub zones: Vec<ZoneIn>,+    pub vias: Vec<ViaIn>,+    pub tracks: Vec<TrackIn>,+    pub groups: Vec<GroupIn>,+    pub rules: Rules,+    pub power: HashSet<String>,+    pub seconds: f64,+    /// passes without a better result before the layer count is called the limit+    pub stall: usize,+}++struct Field { cnt: Vec<u16>, sum: Vec<u32>, sq: Vec<u32> }+impl Field {+    fn new(n: usize) -> Field { Field { cnt: vec![0; n], sum: vec![0; n], sq: vec![0; n] } }+    /// All copper stamped here belongs to net `n`: count, sum and sum of squares only agree when every id equals n.+    #[inline] fn ok(&self, i: usize, n: u32) -> bool { let c = self.cnt[i] as u32; c == 0 || (self.sum[i] == c * n && self.sq[i] == c * n * n) }+    #[inline] fn add(&mut self, i: usize, n: u32, sign: i32) {+        if sign > 0 { self.cnt[i] += 1; self.sum[i] += n; self.sq[i] += n * n; } else { self.cnt[i] -= 1; self.sum[i] -= n; self.sq[i] -= n * n; }+    }+}++#[derive(Clone, Debug)] struct Seg { l: usize, a: (f64, f64), b: (f64, f64), w: f64 }+#[derive(Clone, Debug)] struct Via { x: f64, y: f64, d: f64, h: f64 }++struct Group { cells: Vec<u32>, center: (f64, f64), pins: Vec<String>, root: bool }++#[derive(Clone, Debug, PartialEq)] enum Parent { Group(usize), Conn(usize), Plane }+/// One closed connection: which group it brought in, what it landed on, and its copper. Ripping it up also takes+/// whatever landed on it, and nothing else.+#[derive(Clone, Debug)] struct Conn { gi: usize, parent: Parent, segs: Vec<Seg>, vias: Vec<Via>, alive: bool }++struct Net { id: u32, name: String, plane: Option<usize>, power: bool, groups: Vec<Group>, on: Vec<bool>, conns: Vec<Conn>, reason: HashMap<usize, String>, rips: u32, necked: u32 }+impl Net { fn segs(&self) -> impl Iterator<Item = &Seg> { self.conns.iter().filter(|c| c.alive).flat_map(|c| c.segs.iter()) } fn vias(&self) -> impl Iterator<Item = &Via> { self.conns.iter().filter(|c| c.alive).flat_map(|c| c.vias.iter()) } }++struct R {+    w: usize, h: usize, nl: usize, x0: f64, y0: f64, res: f64,+    fixed: Field, routed: Field,+    /// who owns the seen copper at a cell: 0 nobody, 1 unknown net, else the net's id+    seen: Vec<u16>, routable: Vec<bool>, hist: Vec<u16>,+    best: Vec<u32>, stamp: Vec<u16>, par: Vec<u8>, gen: u16,+    tgt: Vec<u16>, tgen: u16, dt: Vec<u32>,+    vstamp: Vec<u16>, vres: Vec<bool>,+    drills: HashMap<(i32, i32), Vec<(f64, f64, f64)>>,+    rules: Rules,+}++fn ring(r_mm: f64, res: f64) -> Vec<(i32, i32)> {+    if r_mm <= 1e-6 { return vec![]; }+    let r = r_mm * res;+    let n = ((std::f64::consts::TAU * r).ceil() as usize).max(8);+    let mut v: Vec<(i32, i32)> = (0..n).map(|k| { let t = k as f64 / n as f64 * std::f64::consts::TAU; ((r * t.cos()).round() as i32, (r * t.sin()).round() as i32) }).collect();+    v.sort(); v.dedup(); v+}++impl R {+    #[inline] fn idx(&self, l: usize, x: usize, y: usize) -> usize { l * self.w * self.h + y * self.w + x }+    #[inline] fn un(&self, s: usize) -> (usize, usize, usize) { let wh = self.w * self.h; let l = s / wh; let r = s - l * wh; (l, r % self.w, r / self.w) }+    #[inline] fn mm(&self, x: usize, y: usize) -> (f64, f64) { (self.x0 + x as f64 / self.res, self.y0 + y as f64 / self.res) }+    #[inline] fn cell(&self, p: (f64, f64)) -> (i64, i64) { (((p.0 - self.x0) * self.res).round() as i64, ((p.1 - self.y0) * self.res).round() as i64) }++    fn stamp_pad(f: &mut Field, wid: usize, hei: usize, x0: f64, y0: f64, res: f64, l: usize, p: &Pad, rad: f64, n: u32) {+        let (hw, hh) = (p.w / 2.0, p.h / 2.0);+        let (c, s) = (p.rot.to_radians().cos(), p.rot.to_radians().sin());+        let ext = hw.max(hh) * std::f64::consts::SQRT_2 + rad;+        let (u0, u1) = (((p.x - ext - x0) * res).floor().max(0.0) as usize, (((p.x + ext - x0) * res).ceil() as usize).min(wid - 1));+        let (v0, v1) = (((p.y - ext - y0) * res).floor().max(0.0) as usize, (((p.y + ext - y0) * res).ceil() as usize).min(hei - 1));+        for v in v0..=v1 { for u in u0..=u1 {+            let (dx, dy) = (x0 + u as f64 / res - p.x, y0 + v as f64 / res - p.y);+            let (lx, ly) = (dx * c - dy * s, dx * s + dy * c);+            let d = if p.round { (lx * lx + ly * ly).sqrt() - hw } else { let (ax, ay) = ((lx.abs() - hw).max(0.0), (ly.abs() - hh).max(0.0)); (ax * ax + ay * ay).sqrt() };+            if d <= rad { f.add(l * wid * hei + v * wid + u, n, 1); }+        } }+    }++    fn stamp_capsule(&mut self, routed: bool, l: usize, a: (f64, f64), b: (f64, f64), rad: f64, n: u32, sign: i32) {+        let (w, h, x0, y0, res) = (self.w, self.h, self.x0, self.y0, self.res);+        let (u0, u1) = (((a.0.min(b.0) - rad - x0) * res).floor().max(0.0) as usize, (((a.0.max(b.0) + rad - x0) * res).ceil() as usize).min(w - 1));+        let (v0, v1) = (((a.1.min(b.1) - rad - y0) * res).floor().max(0.0) as usize, (((a.1.max(b.1) + rad - y0) * res).ceil() as usize).min(h - 1));+        let (dx, dy) = (b.0 - a.0, b.1 - a.1); let len2 = dx * dx + dy * dy;+        let f = if routed { &mut self.routed } else { &mut self.fixed };+        for v in v0..=v1 { for u in u0..=u1 {+            let (px, py) = (x0 + u as f64 / res - a.0, y0 + v as f64 / res - a.1);+            let t = if len2 > 0.0 { ((px * dx + py * dy) / len2).clamp(0.0, 1.0) } else { 0.0 };+            let (qx, qy) = (px - t * dx, py - t * dy);+            if qx * qx + qy * qy <= rad * rad { f.add(l * w * h + v * w + u, n, sign); }+        } }+    }++    fn keepout_seg(&self, w: f64) -> f64 { w / 2.0 + self.rules.clr + self.rules.track / 2.0 + MARGIN }++    fn add_seg(&mut self, net: u32, s: &Seg, sign: i32) { let r = self.keepout_seg(s.w); self.stamp_capsule(true, s.l, s.a, s.b, r, net, sign); }+    fn add_via(&mut self, net: u32, v: &Via, sign: i32) {+        let r = self.keepout_seg(v.d);+        for l in 0..self.nl { self.stamp_capsule(true, l, (v.x, v.y), (v.x, v.y), r, net, sign); }+        let key = ((v.x).floor() as i32, (v.y).floor() as i32);+        if sign > 0 { self.drills.entry(key).or_default().push((v.x, v.y, v.h / 2.0)); }+        else if let Some(b) = self.drills.get_mut(&key) { if let Some(i) = b.iter().position(|d| (d.0 - v.x).abs() < 1e-6 && (d.1 - v.y).abs() < 1e-6) { b.swap_remove(i); } }+    }++    fn hole_ok(&self, x: f64, y: f64, h: f64) -> bool {+        let (cx, cy) = (x.floor() as i32, y.floor() as i32);+        for j in -3..=3 { for i in -3..=3 { if let Some(b) = self.drills.get(&(cx + i, cy + j)) { for d in b {+            let e = ((d.0 - x).powi(2) + (d.1 - y).powi(2)).sqrt() - d.2 - h / 2.0;+            if e < self.rules.hole_hole + MARGIN { return false; }+        } } } }+        true+    }++    #[inline] fn seen_factor(&self, i: usize, n: u32) -> u32 { match self.seen[i] as u32 { 0 => 1, 1 => 6, o if o == n => 1, _ => 12 } }++    #[inline] fn pass_at(&self, l: usize, x: i64, y: i64, n: u32, soft: bool) -> bool {+        if x < 0 || y < 0 || x >= self.w as i64 || y >= self.h as i64 { return false; }+        let i = self.idx(l, x as usize, y as usize);+        self.fixed.ok(i, n) && (soft || self.routed.ok(i, n))+    }+    #[inline] fn pass_ring(&self, l: usize, x: i64, y: i64, n: u32, soft: bool, rg: &[(i32, i32)]) -> bool {+        if !self.pass_at(l, x, y, n, soft) { return false; }+        rg.iter().all(|o| self.pass_at(l, x + o.0 as i64, y + o.1 as i64, n, soft))+    }++    fn via_ok(&mut self, x: usize, y: usize, n: u32, vring: &[(i32, i32)], vh: f64, soft: bool) -> bool {+        let k = y * self.w + x;+        if self.vstamp[k] == self.gen { return self.vres[k]; }+        let (mx, my) = self.mm(x, y);+        let ok = self.hole_ok(mx, my, vh) && (0..self.nl).all(|l| self.pass_ring(l, x as i64, y as i64, n, soft, vring));+        self.vstamp[k] = self.gen; self.vres[k] = ok; ok+    }++    /// Chamfer distance from the marked target cells, ignoring layers: a heuristic that knows where the targets are.+    fn distance_field(&mut self) {+        let (w, h) = (self.w, self.h);+        self.dt.iter_mut().for_each(|v| *v = u32::MAX / 2);+        for l in 0..self.nl { for k in 0..w * h { if self.tgt[l * w * h + k] == self.tgen { self.dt[k] = 0; } } }+        for y in 0..h { for x in 0..w { let k = y * w + x; let mut d = self.dt[k];+            if x > 0 { d = d.min(self.dt[k - 1] + STEP); }+            if y > 0 { d = d.min(self.dt[k - w] + STEP); if x > 0 { d = d.min(self.dt[k - w - 1] + DIAG); } if x + 1 < w { d = d.min(self.dt[k - w + 1] + DIAG); } }+            self.dt[k] = d; } }+        for y in (0..h).rev() { for x in (0..w).rev() { let k = y * w + x; let mut d = self.dt[k];+            if x + 1 < w { d = d.min(self.dt[k + 1] + STEP); }+            if y + 1 < h { d = d.min(self.dt[k + w] + STEP); if x + 1 < w { d = d.min(self.dt[k + w + 1] + DIAG); } if x > 0 { d = d.min(self.dt[k + w - 1] + DIAG); } }+            self.dt[k] = d; } }+    }++    fn tile(&self, x: usize, y: usize, l: usize) -> usize { let (tw, th) = (self.w / 8 + 1, self.h / 8 + 1); l * tw * th + (y / 8) * tw + x / 8 }++    /// A* from the source cells to a marked target, or to `plane` by a via. Returns the states from source to goal.+    #[allow(clippy::too_many_arguments)]+    fn search(&mut self, n: u32, plane: Option<usize>, power: bool, srcs: &[u32], trk: &[(i32, i32)], vring: &[(i32, i32)], vh: f64, soft: bool, limit: usize) -> Option<Vec<u32>> {+        self.gen = self.gen.wrapping_add(1);+        if self.gen == 0 { self.stamp.iter_mut().for_each(|v| *v = 0); self.vstamp.iter_mut().for_each(|v| *v = 0); self.gen = 1; }+        let (w, h, nl) = (self.w, self.h, self.nl);+        let wh = w * h;+        let use_h = plane.is_none();+        let mut heap: BinaryHeap<Reverse<(u32, u32, u32)>> = BinaryHeap::new();+        for &s in srcs {+            let (l, x, y) = self.un(s as usize);+            if !self.pass_ring(l, x as i64, y as i64, n, soft, trk) { continue; }+            let i = s as usize;+            if self.stamp[i] == self.gen { continue; }+            self.stamp[i] = self.gen; self.best[i] = 0; self.par[i] = 255;+            let hh = if use_h { self.dt[y * w + x] } else { 0 };+            heap.push(Reverse((hh, 0, s)));+        }+        let via_cost: u32 = if power { 1200 } else { 1600 };+        let mut pops = 0usize;+        while let Some(Reverse((_, g, s))) = heap.pop() {+            let si = s as usize;+            if self.best[si] < g { continue; }+            pops += 1; if pops > limit { return None; }+            let (l, x, y) = self.un(si);+            if self.tgt[si] == self.tgen || plane == Some(l) {+                let mut path = vec![s]; let mut c = si;+                loop { let p = self.par[c]; if p == 255 { break; }+                    let (cl, cx, cy) = self.un(c);+                    c = if p < 8 { let d = DIRS[p as usize]; self.idx(cl, (cx as i32 - d.0) as usize, (cy as i32 - d.1) as usize) } else { self.idx(p as usize - 8, cx, cy) };+                    path.push(c as u32); }+                path.reverse();+                return Some(path);+            }+            if self.routable[l] {+                // the inner layers lean to alternate directions, so tracks on one do not fill the channels of the next+                let inner = l > 0 && l + 1 < nl;+                for (di, d) in DIRS.iter().enumerate() {+                    let (nx, ny) = (x as i64 + d.0 as i64, y as i64 + d.1 as i64);+                    if !self.pass_ring(l, nx, ny, n, soft, trk) { continue; }+                    let (nxu, nyu) = (nx as usize, ny as usize);+                    let ni = l * wh + nyu * w + nxu;+                    let mut c = if d.0 != 0 && d.1 != 0 { DIAG } else { STEP };+                    if inner && d.0 != 0 && d.1 != 0 { c += c / 8; } else if inner && ((l % 2 == 1) == (d.0 != 0)) { c += c / 4; }+                    c *= self.seen_factor(ni, n);+                    c += self.hist[self.tile(nxu, nyu, l)] as u32 * 8;+                    if soft && !self.routed.ok(ni, n) { c += 600; }+                    let ng = g + c;+                    if self.stamp[ni] != self.gen || ng < self.best[ni] {+                        self.stamp[ni] = self.gen; self.best[ni] = ng; self.par[ni] = di as u8;+                        let hh = if use_h { self.dt[nyu * w + nxu] + self.dt[nyu * w + nxu] / 4 } else { 0 };+                        heap.push(Reverse((ng + hh, ng, ni as u32)));+                    }+                }+            }+            if x % 2 == 0 && y % 2 == 0 && self.via_ok(x, y, n, vring, vh, soft) {+                let mut c = via_cost;+                if soft && !(0..nl).all(|q| self.pass_ring(q, x as i64, y as i64, n, false, vring)) { c += 3000; }+                for l2 in 0..nl {+                    if l2 == l || (!self.routable[l2] && plane != Some(l2)) { continue; }+                    let ni = l2 * wh + y * w + x;+                    let ng = g + c;+                    if self.stamp[ni] != self.gen || ng < self.best[ni] {+                        self.stamp[ni] = self.gen; self.best[ni] = ng; self.par[ni] = 8 + l as u8;+                        let hh = if use_h { self.dt[y * w + x] + self.dt[y * w + x] / 4 } else { 0 };+                        heap.push(Reverse((ng + hh, ng, ni as u32)));+                    }+                }+            }+        }+        None+    }++    /// Cells of a straight octile run, end included, start excluded. None if a and b are not on one octile line.+    fn walk(a: (i64, i64), b: (i64, i64)) -> Option<Vec<(i64, i64)>> {+        let (dx, dy) = (b.0 - a.0, b.1 - a.1);+        if dx != 0 && dy != 0 && dx.abs() != dy.abs() { return None; }+        let n = dx.abs().max(dy.abs());+        Some((1..=n).map(|i| (a.0 + dx.signum() * i, a.1 + dy.signum() * i)).collect())+    }++    /// Cost of a run on layer l, or None if it hits copper.+    fn run_cost(&self, l: usize, cells: &[(i64, i64)], prev: (i64, i64), n: u32, trk: &[(i32, i32)]) -> Option<u64> {+        let mut c = 0u64; let mut p = prev;+        for q in cells {+            if !self.pass_ring(l, q.0, q.1, n, false, trk) { return None; }+            let st = if q.0 != p.0 && q.1 != p.1 { DIAG } else { STEP } as u64;+            c += st * self.seen_factor(self.idx(l, q.0 as usize, q.1 as usize), n) as u64;+            p = *q;+        }+        Some(c)+    }++    /// Straighten a run of grid cells into few octile segments, never costlier than the path found.+    fn straighten(&self, l: usize, cells: &[(i64, i64)], n: u32, trk: &[(i32, i32)]) -> Vec<(i64, i64)> {+        if cells.len() < 3 { return cells.to_vec(); }+        let mut pre = vec![0u64; cells.len()];+        for i in 1..cells.len() {+            let (a, b) = (cells[i - 1], cells[i]);+            let st = if a.0 != b.0 && a.1 != b.1 { DIAG } else { STEP } as u64;+            pre[i] = pre[i - 1] + st * self.seen_factor(self.idx(l, b.0 as usize, b.1 as usize), n) as u64;+        }+        let mut out = vec![cells[0]]; let mut i = 0;+        while i + 1 < cells.len() {+            let mut span = (cells.len() - 1 - i).max(1); let mut done = false;+            while span >= 1 {+                let j = i + span; let (a, b) = (cells[i], cells[j]); let old = pre[j] - pre[i];+                let (dx, dy) = (b.0 - a.0, b.1 - a.1);+                let d = dx.abs().min(dy.abs());+                let k1 = (a.0 + dx.signum() * d, a.1 + dy.signum() * d);+                let k2 = (b.0 - dx.signum() * d, b.1 - dy.signum() * d);+                let mut best: Option<(u64, (i64, i64))> = None;+                for k in [k1, k2] {+                    let (Some(r1), Some(r2)) = (Self::walk(a, k), Self::walk(k, b)) else { continue };+                    let (Some(c1), Some(c2)) = (self.run_cost(l, &r1, a, n, trk), self.run_cost(l, &r2, k, n, trk)) else { continue };+                    if c1 + c2 <= old + STEP as u64 && best.map(|x| c1 + c2 < x.0).unwrap_or(true) { best = Some((c1 + c2, k)); }+                }+                if let Some((_, k)) = best { if k != a && k != b { out.push(k); } out.push(b); i = j; done = true; break; }+                if span == 1 { break; }+                span = (span * 2) / 3;+            }+            if !done { out.push(cells[i + 1]); i += 1; }+        }+        // merge collinear+        let mut m: Vec<(i64, i64)> = vec![];+        for p in out {+            if m.last() == Some(&p) { continue; }+            if m.len() >= 2 { let (a, b) = (m[m.len() - 2], m[m.len() - 1]);+                if (b.0 - a.0) * (p.1 - b.1) == (b.1 - a.1) * (p.0 - b.0) && (b.0 - a.0) * (p.0 - b.0) + (b.1 - a.1) * (p.1 - b.1) > 0 { *m.last_mut().unwrap() = p; continue; } }+            m.push(p);+        }+        m+    }+}++fn point_seg(p: (f64, f64), a: (f64, f64), b: (f64, f64)) -> f64 {+    let (dx, dy) = (b.0 - a.0, b.1 - a.1); let len2 = dx * dx + dy * dy;+    let t = if len2 > 0.0 { (((p.0 - a.0) * dx + (p.1 - a.1) * dy) / len2).clamp(0.0, 1.0) } else { 0.0 };+    ((p.0 - a.0 - t * dx).powi(2) + (p.1 - a.1 - t * dy).powi(2)).sqrt()+}++struct Uf(Vec<usize>);+impl Uf { fn find(&mut self, x: usize) -> usize { let mut r = x; while self.0[r] != r { r = self.0[r]; } let mut y = x; while self.0[y] != r { let n = self.0[y]; self.0[y] = r; y = n; } r }+          fn join(&mut self, a: usize, b: usize) { let (a, b) = (self.find(a), self.find(b)); if a != b { self.0[a] = b; } } }++fn pad_cells(r: &R, p: &Pad, l: usize) -> Vec<u32> {+    let (hw, hh) = (p.w / 2.0, p.h / 2.0);+    let (c, s) = (p.rot.to_radians().cos(), p.rot.to_radians().sin());+    let ext = hw.max(hh) * std::f64::consts::SQRT_2;+    let (u0, u1) = (((p.x - ext - r.x0) * r.res).floor().max(0.0) as usize, (((p.x + ext - r.x0) * r.res).ceil() as usize).min(r.w - 1));+    let (v0, v1) = (((p.y - ext - r.y0) * r.res).floor().max(0.0) as usize, (((p.y + ext - r.y0) * r.res).ceil() as usize).min(r.h - 1));+    let mut out = vec![];+    for v in v0..=v1 { for u in u0..=u1 {+        let (dx, dy) = (r.x0 + u as f64 / r.res - p.x, r.y0 + v as f64 / r.res - p.y);+        let (lx, ly) = (dx * c - dy * s, dx * s + dy * c);+        let hit = if p.round { lx * lx + ly * ly <= hw * hw } else { lx.abs() <= hw && ly.abs() <= hh };+        if hit { out.push(r.idx(l, u, v) as u32); }+    } }+    out+}++struct Snapshot { open: usize, conns: Vec<Vec<Conn>>, on: Vec<Vec<bool>>, reason: Vec<HashMap<usize, String>> }++pub fn run(inp: &Input, log: &mut dyn FnMut(String)) -> Value {+    let t0 = Instant::now();+    let g = inp.grid;+    let (w, h, nl) = (g.w, g.h, inp.layers.len());+    let total = w * h * nl;+    let rules = inp.rules.clone();+    let tr = rules.track / 2.0 + rules.clr + MARGIN;+    let mut r = R { w, h, nl, x0: g.x0, y0: g.y0, res: g.res, fixed: Field::new(total), routed: Field::new(total), seen: vec![0; total],+        routable: (0..nl).map(|l| !inp.planes.values().any(|v| *v == l)).collect(), hist: vec![0; nl * (w / 8 + 1) * (h / 8 + 1)],+        best: vec![0; total], stamp: vec![0; total], par: vec![0; total], gen: 0, tgt: vec![0; total], tgen: 0, dt: vec![0; w * h], vstamp: vec![0; w * h], vres: vec![false; w * h], drills: HashMap::new(), rules: rules.clone() };+    // net ids: 1 is foreign copper nobody may touch+    let mut ids: HashMap<String, u32> = HashMap::new();+    for p in inp.pads { if !p.net.is_empty() { let k = ids.len() as u32 + 2; ids.entry(p.net.clone()).or_insert(k); } }+    for z in &inp.zones { if !z.net.is_empty() && !z.net.starts_with("UNK") { let k = ids.len() as u32 + 2; ids.entry(z.net.clone()).or_insert(k); } }+    for v in &inp.vias { if !v.net.is_empty() && !v.net.starts_with("UNK") { let k = ids.len() as u32 + 2; ids.entry(v.net.clone()).or_insert(k); } }+    for t in &inp.tracks { if !t.net.is_empty() && !t.net.starts_with("UNK") { let k = ids.len() as u32 + 2; ids.entry(t.net.clone()).or_insert(k); } }+    let idn = |n: &str| ids.get(n).copied().unwrap_or(1);+    // board edge+    let keep = inp.inside.erode(((rules.edge + rules.track / 2.0 + MARGIN) * g.res) as f32);+    for k in 0..w * h { if !keep.d[k] { for l in 0..nl { r.fixed.add(l * w * h + k, 1, 1); } } }+    // pads and holes+    for p in inp.pads {+        let n = if p.net.is_empty() { 1 } else { idn(&p.net) };+        for (l, name) in inp.layers.iter().enumerate() { if p.on(name) { R::stamp_pad(&mut r.fixed, w, h, g.x0, g.y0, g.res, l, p, tr, n); } }+        if p.drill > 0.0 {+            let plated = p.layers.iter().any(|l| l == "*.Cu") && p.w > p.drill;+            let hn = if plated { n } else { 1 };+            for l in 0..nl { r.stamp_capsule(false, l, (p.x, p.y), (p.x, p.y), p.drill / 2.0 + rules.hole_clr + rules.track / 2.0 + MARGIN, hn, 1); }+            r.drills.entry((p.x.floor() as i32, p.y.floor() as i32)).or_default().push((p.x, p.y, p.drill / 2.0));+        }+    }+    // seen vias: a hole through every layer. One of unknown net is the weakest thing on the board (a dome no copper+    // explains may be a dimple), so it does not block: routed copper may displace it, and the count is reported.+    for v in &inp.vias {+        if v.net.starts_with("UNK") || v.net.is_empty() { continue; }+        let n = idn(&v.net);+        for l in 0..nl { r.stamp_capsule(false, l, (v.x, v.y), (v.x, v.y), v.d / 2.0 + tr, n, 1); }+        r.drills.entry((v.x.floor() as i32, v.y.floor() as i32)).or_default().push((v.x, v.y, v.h / 2.0));+    }+    // seen copper: who owns each cell+    for z in &inp.zones {+        let o: u16 = if z.net.starts_with("UNK") || z.net.is_empty() { 1 } else { idn(&z.net) as u16 };+        for k in &z.cells { r.seen[z.layer * w * h + *k] = o; }+    }+    for t in &inp.tracks {+        let o: u16 = if t.net.starts_with("UNK") || t.net.is_empty() { 1 } else { idn(&t.net) as u16 };+        for q in t.path.windows(2) {+            let (a, b) = (r.cell(q[0]), r.cell(q[1])); let n3 = (b.0 - a.0).abs().max((b.1 - a.1).abs()).max(1); let rad = ((t.w / 2.0) * g.res).ceil() as i64;+            for i in 0..=n3 { let (x, y) = (a.0 + (b.0 - a.0) * i / n3, a.1 + (b.1 - a.1) * i / n3);+                for dy in -rad..=rad { for dx in -rad..=rad { let (u, v) = (x + dx, y + dy); if u >= 0 && v >= 0 && (u as usize) < w && (v as usize) < h { r.seen[t.layer * w * h + v as usize * w + u as usize] = o; } } } }+        }+    }+    // nets and their groups of already joined copper+    let mut names: Vec<String> = vec![];+    for p in inp.pads { if !p.net.is_empty() && !names.contains(&p.net) { names.push(p.net.clone()); } }+    let mut nets: Vec<Net> = vec![];+    for name in &names {+        let plane = inp.planes.get(name).copied();+        let pads: Vec<&Pad> = inp.pads.iter().filter(|p| &p.net == name).collect();+        let zones: Vec<&ZoneIn> = inp.zones.iter().filter(|z| &z.net == name).collect();+        let vias: Vec<&ViaIn> = inp.vias.iter().filter(|v| &v.net == name).collect();+        let tracks: Vec<&TrackIn> = inp.tracks.iter().filter(|t| &t.net == name).collect();+        let (np, nz, nv) = (pads.len(), zones.len(), vias.len());+        let nt = tracks.len();+        let root_item = np + nz + nv + nt;+        let mut uf = Uf((0..=root_item).collect());+        let mut pins: HashMap<String, usize> = HashMap::new();+        for (i, p) in pads.iter().enumerate() { let k = format!("{}.{}", p.part, p.num); if let Some(j) = pins.get(&k) { uf.join(i, *j); } else { pins.insert(k, i); } }+        // a pad or via inside a pour of its own net is in that pour's copper+        for (zi, z) in zones.iter().enumerate() {+            let set: HashSet<usize> = z.cells.iter().copied().collect();+            let hit = |x: f64, y: f64| { let (u, v) = (((x - g.x0) * g.res) as i64, ((y - g.y0) * g.res) as i64); u >= 0 && v >= 0 && (u as usize) < w && (v as usize) < h && set.contains(&(v as usize * w + u as usize)) };+            for (i, p) in pads.iter().enumerate() { if p.on(&inp.layers[z.layer]) && hit(p.x, p.y) { uf.join(i, np + zi); } }+            for (vi, v) in vias.iter().enumerate() { if hit(v.x, v.y) { uf.join(np + nz + vi, np + zi); } }+        }+        // what the photo measured as joined+        for grp in inp.groups.iter().filter(|q| &q.net == name) {+            let mut items: Vec<usize> = vec![];+            for q in &grp.pins { if let Some(i) = pins.get(q) { items.push(*i); } }+            for q in &grp.zones { if let Some(i) = zones.iter().position(|z| &z.id == q) { items.push(np + i); } }+            for q in &grp.vias { if let Some(i) = vias.iter().position(|v| &v.id == q) { items.push(np + nz + i); } }+            for q in &grp.traces { if let Some(i) = tracks.iter().position(|t| &t.id == q) { items.push(np + nz + nv + i); } }+            for x in items.windows(2) { uf.join(x[0], x[1]); }+        }+        if plane.is_some() {+            for (i, p) in pads.iter().enumerate() { if p.drill > 0.0 && p.layers.iter().any(|l| l == "*.Cu") { uf.join(i, root_item); } }+            for vi in 0..nv { uf.join(np + nz + vi, root_item); }+        }+        // a through-hole pad and a via join their pieces on every layer+        let mut buckets: HashMap<usize, Vec<usize>> = HashMap::new();+        for i in 0..=root_item { let q = uf.find(i); buckets.entry(q).or_default().push(i); }+        let root_key = uf.find(root_item);+        let mut groups = vec![];+        let mut keys: Vec<usize> = buckets.keys().copied().collect(); keys.sort();+        for key in keys {+            let items = &buckets[&key];+            let is_root = plane.is_some() && key == root_key;+            if items.len() == 1 && items[0] == root_item && !is_root { continue; }+            let mut cells: Vec<u32> = vec![]; let mut pinl = vec![]; let (mut sx, mut sy, mut c) = (0.0, 0.0, 0.0);+            for &it in items {+                if it < np { let p = pads[it]; sx += p.x; sy += p.y; c += 1.0; pinl.push(format!("{}.{}", p.part, p.num));+                    for (l, nm) in inp.layers.iter().enumerate() { if r.routable[l] && p.on(nm) { cells.extend(pad_cells(&r, p, l)); } } }+                else if it < np + nz { let z = zones[it - np]; let mut m = g.empty(); for k in &z.cells { m.d[*k] = true; } let e = m.erode((0.2 * g.res) as f32);+                    let mut any = false; for k in (0..w * h).filter(|k| e.d[*k] && (k % 3 == 0)) { cells.push((z.layer * w * h + k) as u32); any = true; }+                    if any { let (mut ax, mut ay, mut n2) = (0.0, 0.0, 0.0); for k in z.cells.iter().step_by(17) { ax += g.x0 + (*k % w) as f64 / g.res; ay += g.y0 + (*k / w) as f64 / g.res; n2 += 1.0; } if n2 > 0.0 { sx += ax / n2; sy += ay / n2; c += 1.0; } } }+                else if it >= np + nz + nv && it < root_item { let t = tracks[it - np - nz - nv]; for q in t.path.windows(2) { let (a, b) = (r.cell(q[0]), r.cell(q[1])); let n3 = (b.0 - a.0).abs().max((b.1 - a.1).abs()).max(1); for i in 0..=n3 { let (x, y) = (a.0 + (b.0 - a.0) * i / n3, a.1 + (b.1 - a.1) * i / n3); if x >= 0 && y >= 0 && (x as usize) < w && (y as usize) < h { cells.push(r.idx(t.layer, x as usize, y as usize) as u32); } } sx += q[0].0; sy += q[0].1; c += 1.0; } }+                else if it < root_item { let v = vias[it - np - nz]; let (ux, uy) = r.cell((v.x, v.y)); sx += v.x; sy += v.y; c += 1.0;+                    if ux >= 0 && uy >= 0 && (ux as usize) < w && (uy as usize) < h { for l in (0..nl).filter(|l| r.routable[*l]) { cells.push(r.idx(l, ux as usize, uy as usize) as u32); } } }+            }+            pinl.sort(); pinl.dedup();+            groups.push(Group { cells, center: if c > 0.0 { (sx / c, sy / c) } else { (0.0, 0.0) }, pins: pinl, root: is_root });+        }+        // the first root: the plane, or the group of most copper+        let root_i = groups.iter().position(|q| q.root).unwrap_or_else(|| { let mut b = 0; for (i, q) in groups.iter().enumerate() { if q.cells.len() > groups[b].cells.len() { b = i; } } b });+        let mut on = vec![false; groups.len()]; if !on.is_empty() { on[root_i] = true; }+        nets.push(Net { id: idn(name), name: name.clone(), plane, power: inp.power.contains(name), groups, on, conns: vec![], reason: HashMap::new(), rips: 0, necked: 0 });+    }+    let opens = |nets: &[Net]| -> usize { nets.iter().map(|n| n.on.iter().filter(|o| !**o).count()).sum() };+    let total_conn = opens(&nets);+    log(format!("{} layers: {} nets, {} connections to close ({} planes: {:?})", nl, nets.len(), total_conn, inp.planes.len(), inp.planes));+    // order: plane fan-out early (short, and they must find a via near the pad), then power, then signals shortest first+    let hpwl = |n: &Net| { let (mut a, mut b, mut c, mut d) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN); for q in &n.groups { a = a.min(q.center.0); b = b.min(q.center.1); c = c.max(q.center.0); d = d.max(q.center.1); } (c - a) + (d - b) };+    let mut order: Vec<usize> = (0..nets.len()).filter(|i| nets[*i].on.iter().any(|o| !*o)).collect();+    // pins of fine-pitch parts go first of all: they have one way out, and anything nearby can close it+    let fine: HashSet<String> = inp.pads.iter().filter(|p| !p.net.is_empty() && p.w.min(p.h) <= 0.3).map(|p| p.net.clone()).collect();+    order.sort_by(|a, b| { let k = |i: usize| (if fine.contains(&nets[i].name) && nets[i].plane.is_none() && !nets[i].power { 0 } else if nets[i].plane.is_some() { 1 } else if nets[i].power { 2 } else { 3 }, hpwl(&nets[i]) as i64); k(*a).cmp(&k(*b)) });+    let mut best: Option<Snapshot> = None;+    let mut stalled = 0usize; let mut pass = 0usize; let mut stop = String::new();+    let mut queue: VecDeque<usize> = order.iter().copied().collect();+    let mut total_rips = 0u64;+    while pass < 40 {+        pass += 1;+        for n in nets.iter_mut() { n.rips = 0; }+        let budget = nets.len() * 4; let mut worked = 0;+        while let Some(ni) = queue.pop_front() {+            worked += 1; if worked > budget { queue.push_front(ni); break; }+            if t0.elapsed().as_secs_f64() > inp.seconds { queue.push_front(ni); break; }+            let mut attempt = 0;+            loop {+                attempt += 1;+                let closed = route_net(&mut r, &mut nets[ni]);+                if closed { break; }+                // rip up what stands in the way, once or twice per visit, a limited number of times per net+                if attempt > 3 || nets[ni].rips > 6 || std::env::var("ROUTE_NO_RIP").is_ok() { break; }+                nets[ni].rips += 1;+                let blockers = find_blockers(&mut r, &mut nets, ni);+                if blockers.is_empty() { break; }+                for (b, c) in blockers {+                    if b == ni { continue; }+                    total_rips += 1;+                    rip_conn(&mut r, &mut nets[b], c);+                    if !queue.contains(&b) { queue.push_back(b); }+                }+            }+        }+        let open_now = opens(&nets);+        let better = best.as_ref().map(|s| open_now < s.open).unwrap_or(true);+        log(format!("{} layers: pass {pass}, {open_now} open of {total_conn}, {total_rips} rip-ups, {:.0} s", nl, t0.elapsed().as_secs_f64()));+        if better {+            best = Some(Snapshot { open: open_now, conns: nets.iter().map(|n| n.conns.clone()).collect(), on: nets.iter().map(|n| n.on.clone()).collect(), reason: nets.iter().map(|n| n.reason.clone()).collect() });+            stalled = 0;+        } else { stalled += 1; }+        if open_now == 0 && queue.is_empty() { stop = "every connection closed".into(); break; }+        if t0.elapsed().as_secs_f64() > inp.seconds { stop = format!("time limit of {:.0} s", inp.seconds); break; }+        if stalled >= inp.stall { stop = format!("{stalled} passes closed nothing new: this stackup is the limit"); break; }+        // next pass: what is still open goes first+        let mut failed: Vec<usize> = (0..nets.len()).filter(|i| nets[*i].on.iter().any(|o| !*o)).collect();+        failed.retain(|i| !queue.contains(i));+        for i in failed.into_iter().rev() { queue.push_front(i); }+    }+    if stop.is_empty() { stop = "pass limit".into(); }+    let s = best.unwrap_or(Snapshot { open: opens(&nets), conns: vec![], on: vec![], reason: vec![] });+    // the result+    let mut traces = vec![]; let mut vias_out = vec![]; let mut failed = vec![];+    let mut per_layer = vec![0f64; nl]; let mut seen_mm = 0f64;+    for (i, n) in nets.iter().enumerate() {+        let live: Vec<Conn> = s.conns.get(i).cloned().unwrap_or_default().into_iter().filter(|c| c.alive).collect();+        let segs: Vec<Seg> = live.iter().flat_map(|c| c.segs.iter().cloned()).collect(); let vs: Vec<Via> = live.iter().flat_map(|c| c.vias.iter().cloned()).collect();+        let (on, why) = (s.on.get(i).cloned().unwrap_or_default(), s.reason.get(i).cloned().unwrap_or_default());+        for (k, q) in segs.iter().enumerate() {+            let len = crate::geom::dist(q.a, q.b); per_layer[q.l] += len;+            let steps = (len * g.res).ceil().max(1.0) as usize; let mut hit = 0;+            for t in 0..=steps { let f = t as f64 / steps as f64; let (cx, cy) = r.cell((q.a.0 + (q.b.0 - q.a.0) * f, q.a.1 + (q.b.1 - q.a.1) * f));+                if cx >= 0 && cy >= 0 && (cx as usize) < w && (cy as usize) < h && r.seen_factor(r.idx(q.l, cx as usize, cy as usize), n.id) > 1 { hit += 1; } }+            seen_mm += len * hit as f64 / (steps + 1) as f64;+            traces.push(json!({"id": format!("r-{}-{k}", n.name), "net": n.name, "layer": inp.layers[q.l], "width_mm": q.w, "path_mm": [[round3(q.a.0), round3(q.a.1)], [round3(q.b.0), round3(q.b.1)]], "tag": "INFERRED", "source": "router"}));+        }+        for (k, v) in vs.iter().enumerate() { vias_out.push(json!({"id": format!("rv-{}-{k}", n.name), "net": n.name, "kind": "router", "pos_mm": [round3(v.x), round3(v.y)], "diameter_mm": v.d, "drill_mm": v.h, "tag": "INFERRED", "source": "router"})); }+        for (gi, q) in n.groups.iter().enumerate() { if !on.get(gi).copied().unwrap_or(true) {+            // what fixed copper alone allows from here: no legal start, a pocket, or plenty of room+            let starts: Vec<usize> = q.cells.iter().map(|c| *c as usize).filter(|c| r.fixed.ok(*c, n.id)).collect();+            let mut seen_cells: HashSet<usize> = starts.iter().copied().collect(); let mut stack = starts.clone();+            while let Some(c) = stack.pop() { if seen_cells.len() > 40000 { break; } let (l, x, y) = r.un(c);+                for d in DIRS { let (nx, ny) = (x as i64 + d.0 as i64, y as i64 + d.1 as i64); if nx < 0 || ny < 0 || nx >= w as i64 || ny >= h as i64 { continue; } let j = r.idx(l, nx as usize, ny as usize); if r.fixed.ok(j, n.id) && seen_cells.insert(j) { stack.push(j); } } }+            let room = seen_cells.len();+            let mut sites = 0usize; let vr = ring((rules.via.0 - rules.track) / 2.0, g.res);+            for c in seen_cells.iter().step_by(3) { let (_, x, y) = r.un(*c); if x % 2 == 0 && y % 2 == 0 && r.hole_ok(g.x0 + x as f64 / g.res, g.y0 + y as f64 / g.res, rules.via.1) && (0..nl).all(|l2| { let i = r.idx(l2, x, y); r.fixed.ok(i, n.id) && vr.iter().all(|o| { let (a, b) = (x as i64 + o.0 as i64, y as i64 + o.1 as i64); a >= 0 && b >= 0 && a < w as i64 && b < h as i64 && r.fixed.ok(r.idx(l2, a as usize, b as usize), n.id) }) }) { sites += 1; if sites > 50 { break; } } }+            failed.push(json!({"net": n.name, "pins": q.pins, "at_mm": [round3(q.center.0), round3(q.center.1)], "why": why.get(&gi).cloned().unwrap_or_default(), "legalStarts": starts.len(), "roomCells": room, "viaSites": sites}));+        } }+    }+    let layers_used: Vec<Value> = inp.layers.iter().enumerate().map(|(l, nm)| json!({"layer": nm, "plane": inp.planes.iter().find(|(_, v)| **v == l).map(|(k, _)| k.clone()), "trackMm": (per_layer[l] * 10.0).round() / 10.0})).collect();+    let necked: Vec<String> = nets.iter().filter(|n| n.necked > 0).map(|n| n.name.clone()).collect();+    json!({"layers": nl, "open": s.open, "connections": total_conn, "passes": pass, "ripups": total_rips, "stop": stop, "seconds": (t0.elapsed().as_secs_f64() * 10.0).round() / 10.0,+           "trackMm": (per_layer.iter().sum::<f64>() * 10.0).round() / 10.0, "vias": vias_out.len(), "seenCrossingMm": (seen_mm * 10.0).round() / 10.0, "layerUse": layers_used, "narrowedPower": necked,+           "failed": failed, "traces": traces, "routedVias": vias_out})+}++fn round3(v: f64) -> f64 { (v * 1000.0).round() / 1000.0 }++/// Mark everything of this net that is already connected as targets.+fn mark_targets(r: &mut R, net: &Net) -> usize {+    r.tgen = r.tgen.wrapping_add(1);+    if r.tgen == 0 { r.tgt.iter_mut().for_each(|v| *v = 0); r.tgen = 1; }+    let tg = r.tgen; let mut c = 0;+    for (gi, q) in net.groups.iter().enumerate() { if net.on[gi] { for &s in &q.cells { r.tgt[s as usize] = tg; c += 1; } } }+    let segs: Vec<Seg> = net.segs().cloned().collect(); let vias: Vec<Via> = net.vias().cloned().collect();+    for q in &segs {+        let (a, b) = (r.cell(q.a), r.cell(q.b));+        if a.0 < 0 || a.1 < 0 || b.0 < 0 || b.1 < 0 { continue; }+        let i0 = r.idx(q.l, a.0 as usize, a.1 as usize); r.tgt[i0] = tg; c += 1;+        if let Some(cells) = R::walk(a, b) { for p in cells { let i = r.idx(q.l, p.0 as usize, p.1 as usize); r.tgt[i] = tg; c += 1; } }+    }+    for v in &vias { let (x, y) = r.cell((v.x, v.y)); for l in 0..r.nl { if r.routable[l] { let i = r.idx(l, x as usize, y as usize); r.tgt[i] = tg; c += 1; } } }+    c+}++fn commit(r: &mut R, net: &mut Net, gi: usize, path: &[u32], w: f64, trk: &[(i32, i32)], via: (f64, f64)) {+    // split the path at layer changes+    let mut runs: Vec<(usize, Vec<(i64, i64)>)> = vec![];+    for &s in path { let (l, x, y) = r.un(s as usize); let c = (x as i64, y as i64);+        match runs.last_mut() { Some((rl, cells)) if *rl == l => cells.push(c), _ => runs.push((l, vec![c])) } }+    let mut new_segs = vec![]; let mut new_vias = vec![];+    for i in 0..runs.len() {+        let (l, cells) = (&runs[i].0, &runs[i].1);+        if i + 1 < runs.len() { let c = runs[i + 1].1[0]; let (x, y) = r.mm(c.0 as usize, c.1 as usize); new_vias.push(Via { x, y, d: via.0, h: via.1 }); }+        if r.routable[*l] && cells.len() >= 2 {+            let st = r.straighten(*l, cells, net.id, trk);+            for pair in st.windows(2) { let (a, b) = (r.mm(pair[0].0 as usize, pair[0].1 as usize), r.mm(pair[1].0 as usize, pair[1].1 as usize)); new_segs.push(Seg { l: *l, a, b, w }); }+        }+    }+    // what it landed on: a plane, a connection's copper, or a group+    let (gl, gx, gy) = r.un(*path.last().unwrap() as usize);+    let at = r.mm(gx, gy);+    let parent = if net.plane == Some(gl) { Parent::Plane } else {+        let hit = net.conns.iter().position(|c| c.alive && (c.segs.iter().any(|q| q.l == gl && point_seg(at, q.a, q.b) < 0.04) || c.vias.iter().any(|v| crate::geom::dist(at, (v.x, v.y)) < 0.04)));+        match hit { Some(c) => Parent::Conn(c), None => {+            let goal = *path.last().unwrap();+            let g = (0..net.groups.len()).find(|i| net.on[*i] && net.groups[*i].cells.contains(&goal)).unwrap_or(0);+            Parent::Group(g) } }+    };+    for q in &new_segs { r.add_seg(net.id, q, 1); }+    for v in &new_vias { r.add_via(net.id, v, 1); }+    net.conns.push(Conn { gi, parent, segs: new_segs, vias: new_vias, alive: true });+}++/// Close the open groups of a net. true when none is left open.+fn route_net(r: &mut R, net: &mut Net) -> bool {+    let rules = r.rules.clone();+    let (wide, via) = if net.power && net.plane.is_none() { (rules.power_track, rules.power_via) } else { (rules.track, rules.via) };+    let res = r.res;+    let narrow_via = rules.via;+    // a group that finds no path must not stop the rest of the net: each still gets its try+    let mut stuck: HashSet<usize> = HashSet::new();+    loop {+        let open: Vec<usize> = (0..net.groups.len()).filter(|i| !net.on[*i] && !stuck.contains(i)).collect();+        if open.is_empty() { return stuck.is_empty(); }+        // nearest open group to what is connected+        let anchors: Vec<(f64, f64)> = net.groups.iter().enumerate().filter(|(i, _)| net.on[*i]).map(|(_, q)| q.center).chain(net.vias().map(|v| (v.x, v.y))).collect();+        let dist = |gi: usize| anchors.iter().map(|a| crate::geom::dist(*a, net.groups[gi].center)).fold(f64::MAX, f64::min);+        let gi = open.iter().copied().min_by(|a, b| dist(*a).partial_cmp(&dist(*b)).unwrap()).unwrap();+        mark_targets(r, net);+        if net.plane.is_none() { r.distance_field(); }+        let srcs = net.groups[gi].cells.clone();+        let mut found = None;+        // a wide track first on a power net, then the narrow one where a pad's neighbours leave no room+        let tries: Vec<(f64, (f64, f64))> = if wide > rules.track + 1e-6 { vec![(wide, via), (rules.track, narrow_via)] } else { vec![(rules.track, via)] };+        for (ti, (tw, tv)) in tries.iter().enumerate() {+            let trk = ring((tw - rules.track) / 2.0, res);+            let vr = ring((tv.0 - rules.track) / 2.0, res);+            let limit = if net.plane.is_some() { 800_000 } else if ti + 1 < tries.len() { 200_000 } else { 1_200_000 };+            if let Some(p) = r.search(net.id, net.plane, net.power, &srcs, &trk, &vr, tv.1, false, limit) { found = Some((p, *tw, trk, *tv, ti)); break; }+        }+        match found {+            Some((p, tw, trk, tv, ti)) => {+                if ti > 0 { net.necked += 1; }+                commit(r, net, gi, &p, tw, &trk, tv);+                net.on[gi] = true; net.reason.remove(&gi);+            }+            None => { net.reason.insert(gi, "no path among copper already placed".into()); stuck.insert(gi); }+        }+    }+}++/// Take one connection up, and whatever landed on it or on the group it brought in.+fn rip_conn(r: &mut R, net: &mut Net, c: usize) {+    if !net.conns[c].alive { return; }+    let mut stack = vec![c];+    while let Some(k) = stack.pop() {+        if !net.conns[k].alive { continue; }+        net.conns[k].alive = false;+        let segs = net.conns[k].segs.clone(); let vias = net.conns[k].vias.clone(); let gi = net.conns[k].gi;+        for q in &segs { r.add_seg(net.id, q, -1); }+        for v in &vias { r.add_via(net.id, v, -1); }+        net.on[gi] = false;+        for j in 0..net.conns.len() { if net.conns[j].alive && (net.conns[j].parent == Parent::Conn(k) || net.conns[j].parent == Parent::Group(gi)) { stack.push(j); } }+    }+}++/// Search again for the first open group, walking through routed copper at a cost: the nets on that path stand in the way.+fn find_blockers(r: &mut R, nets: &mut [Net], ni: usize) -> Vec<(usize, usize)> {+    let rules = r.rules.clone();+    let open: Vec<usize> = (0..nets[ni].groups.len()).filter(|i| !nets[ni].on[*i]).collect();+    if open.is_empty() { return vec![]; }+    // each visit tries a different stuck group+    let gi = open[nets[ni].rips as usize % open.len()];+    mark_targets(r, &nets[ni]);+    if nets[ni].plane.is_none() { r.distance_field(); }+    let srcs = nets[ni].groups[gi].cells.clone();+    let vr = ring((rules.via.0 - rules.track) / 2.0, r.res);+    let Some(p) = r.search(nets[ni].id, nets[ni].plane, nets[ni].power, &srcs, &[], &vr, rules.via.1, true, 1_500_000) else {+        nets[ni].reason.insert(gi, "walled in by copper seen on the photos (pads, known pours, vias)".into());+        return vec![];+    };+    // tiles on the path get dearer+    for &s in &p { let (l, x, y) = r.un(s as usize); let t = r.tile(x, y, l); r.hist[t] = r.hist[t].saturating_add(1); }+    let mut who: HashSet<(usize, usize)> = HashSet::new();+    let mut prev: Option<usize> = None;+    for &s in &p {+        let (l, x, y) = r.un(s as usize);+        let i = s as usize;+        let changed = prev.map(|pl| pl != l).unwrap_or(false); prev = Some(l);+        if r.routed.ok(i, nets[ni].id) && !changed { continue; }+        let at = r.mm(x, y);+        let extra = if changed { rules.via.0 / 2.0 } else { rules.track / 2.0 };+        for (oi, o) in nets.iter().enumerate() {+            if oi == ni { continue; }+            for (ci, c) in o.conns.iter().enumerate() {+                if !c.alive || who.contains(&(oi, ci)) { continue; }+                let hit = c.segs.iter().any(|q| (q.l == l || changed) && point_seg(at, q.a, q.b) <= q.w / 2.0 + rules.clr + extra + MARGIN)+                    || c.vias.iter().any(|v| crate::geom::dist(at, (v.x, v.y)) <= v.d / 2.0 + rules.clr + extra + MARGIN);+                if hit { who.insert((oi, ci)); }+            }+        }+    }+    let mut v: Vec<(usize, usize)> = who.into_iter().collect(); v.sort(); v+}++#[cfg(test)]+mod tests {+    use super::*;+    fn pad(part: &str, x: f64, y: f64, net: &str) -> Pad { Pad { part: part.into(), num: "1".into(), x, y, w: 1.0, h: 1.0, rot: 0.0, round: false, drill: 0.0, layers: vec!["F.Cu".into()], net: net.into() } }+    fn rules() -> Rules { Rules { clr: 0.2, track: 0.2, power_track: 0.5, via: (0.6, 0.3), power_via: (0.8, 0.4), edge: 0.5, hole_clr: 0.25, hole_hole: 0.25 } }+    fn board(w: f64, h: f64) -> (Grid, Mask) { let g = Grid { x0: 0.0, y0: 0.0, res: 20.0, w: (w * 20.0) as usize + 1, h: (h * 20.0) as usize + 1 }; let mut m = g.empty(); g.poly(&mut m, &[(0.0, 0.0), (w, 0.0), (w, h), (0.0, h)]); (g, m) }+    fn wall(g: &Grid, net: &str, layer: usize, x0: f64, x1: f64) -> ZoneIn { let mut cells = vec![]; for v in 0..g.h { for u in 0..g.w { let x = u as f64 / g.res; if x >= x0 && x <= x1 { cells.push(v * g.w + u); } } } ZoneIn { id: format!("z{layer}"), net: net.into(), layer, cells } }+    fn go(pads: &[Pad], layers: &[&str], planes: &[(&str, usize)], zones: Vec<ZoneIn>, g: &Grid, m: &Mask) -> Value {+        let inp = Input { pads, layers: layers.iter().map(|s| s.to_string()).collect(), planes: planes.iter().map(|(a, b)| (a.to_string(), *b)).collect(), inside: m, grid: g, zones, vias: vec![], tracks: vec![], groups: vec![], rules: rules(), power: HashSet::new(), seconds: 30.0, stall: 2 };+        run(&inp, &mut |_| {})+    }+    #[test]+    fn straight_connection_on_one_layer() {+        let (g, m) = board(10.0, 6.0);+        let o = go(&[pad("A", 2.0, 3.0, "S"), pad("B", 8.0, 3.0, "S")], &["F.Cu", "B.Cu"], &[], vec![], &g, &m);+        assert_eq!(o["open"], json!(0));+        assert_eq!(o["vias"], json!(0));+        assert!(o["trackMm"].as_f64().unwrap() < 6.5, "{o}");+    }+    #[test]+    fn a_track_changes_layer_rather_than_cross_a_wide_pour_of_another_net() {+        // a wide pour of another net: going under it through two vias is cheaper than twelve times its width+        let (g, m) = board(14.0, 6.0);+        let z = vec![wall(&g, "OTHER", 0, 4.0, 10.0)];+        let o = go(&[pad("A", 2.0, 3.0, "S"), pad("B", 12.0, 3.0, "S")], &["F.Cu", "B.Cu"], &[], z, &g, &m);+        assert_eq!(o["open"], json!(0));+        assert_eq!(o["vias"], json!(2), "{o}");+        assert!(o["seenCrossingMm"].as_f64().unwrap() < 0.5, "{o}");+    }+    #[test]+    fn a_plane_net_needs_only_a_via_per_pin() {+        let (g, m) = board(10.0, 6.0);+        let o = go(&[pad("A", 2.0, 3.0, "GND"), pad("B", 8.0, 3.0, "GND")], &["F.Cu", "In1.Cu", "In2.Cu", "B.Cu"], &[("GND", 1)], vec![], &g, &m);+        assert_eq!(o["open"], json!(0));+        assert_eq!(o["vias"], json!(2), "{o}");+        assert!(o["trackMm"].as_f64().unwrap() < 1.0, "the plane carries the current, not a track: {o}");+    }+    #[test]+    fn inner_layers_carry_signals_when_the_outer_are_covered() {+        // wide pours of other nets on both outer layers: two layers must cross one; four go under on an inner layer+        let (g, m) = board(16.0, 6.0);+        let w2 = |l| wall(&g, "OTHER", l, 4.0, 12.0);+        let two = go(&[pad("A", 2.0, 3.0, "S"), pad("B", 14.0, 3.0, "S")], &["F.Cu", "B.Cu"], &[], vec![w2(0), w2(1)], &g, &m);+        assert_eq!(two["open"], json!(0));+        assert!(two["seenCrossingMm"].as_f64().unwrap() > 7.0, "two layers have to cross: {two}");+        let four = go(&[pad("A", 2.0, 3.0, "S"), pad("B", 14.0, 3.0, "S")], &["F.Cu", "In1.Cu", "In2.Cu", "B.Cu"], &[], vec![w2(0), w2(3)], &g, &m);+        assert_eq!(four["open"], json!(0), "{four}");+        assert!(four["seenCrossingMm"].as_f64().unwrap() < 0.5, "{four}");+        let inner: f64 = four["layerUse"].as_array().unwrap().iter().filter(|u| u["layer"].as_str().unwrap().starts_with("In")).map(|u| u["trackMm"].as_f64().unwrap()).sum();+        assert!(inner > 5.0, "{four}");+    }+    #[test]+    fn a_narrow_gap_between_fine_pads_is_found() {+        // 0.5 mm pitch pads, 0.25 wide: only the column straight below the pad clears both neighbours at 0.2 + 0.2+        let (g, m) = board(8.0, 6.0);+        let fine = |n: &str, x: f64, net: &str| Pad { part: "U".into(), num: n.into(), x, y: 2.0, w: 0.25, h: 0.8, rot: 0.0, round: false, drill: 0.0, layers: vec!["F.Cu".into()], net: net.into() };+        let pads = vec![fine("1", 3.5, "A"), fine("2", 4.0, "S"), fine("3", 4.5, "B"), pad("X", 4.0, 5.0, "S")];+        let o = go(&pads, &["F.Cu", "B.Cu"], &[], vec![], &g, &m);+        assert_eq!(o["open"], json!(0), "{o}");+    }+    #[test]+    fn rip_up_restores_the_keep_out_exactly() {+        let (g, m) = board(10.0, 6.0);+        let mut f = Field::new(g.w * g.h);+        f.add(10, 5, 1); f.add(10, 7, 1); assert!(!f.ok(10, 5)); assert!(!f.ok(10, 6), "two nets never read as a third");+        f.add(10, 7, -1); assert!(f.ok(10, 5)); assert!(!f.ok(10, 7));+        f.add(10, 5, -1); assert!(f.ok(10, 7));+        let _ = m;+    }+}
src/rules.rsadded+55
@@ -0,0 +1,55 @@+//! Design rules, set once at `init` and used everywhere after: the KiCad project's net class and board+//! minimums, every zone's clearance, the router, and the DRC gate. The default+//! profile is KiCad's own default rules, because the shared KiCad service checks a lone board file+//! against exactly those: a router working to tighter rules than the gate checks produced hundreds of+//! false clearance errors on the Driver run (2026-11-03).+use serde_json::{json, Value};++/// Named profiles. Sizes in mm; vias as [diameter, drill].+pub fn profile(name: &str) -> Option<Value> {+    match name {+        "kicad-default" => Some(json!({"name": "kicad-default", "clearance": 0.2, "track": 0.2, "powerTrack": 0.5, "viaSignal": [0.6, 0.3], "viaPower": [0.8, 0.4],+                                         "edgeClearance": 0.5, "holeClearance": 0.25, "holeToHole": 0.25, "minDrill": 0.3,+                                         "why": "KiCad's default rules: what the shared KiCad service checks a lone board against"})),+        "jlc-4l" => Some(json!({"name": "jlc-4l", "clearance": 0.127, "track": 0.127, "powerTrack": 0.5, "viaSignal": [0.45, 0.2], "viaPower": [0.6, 0.3],+                                  "edgeClearance": 0.3, "holeClearance": 0.2, "holeToHole": 0.254, "minDrill": 0.2,+                                  "why": "JLCPCB 4-layer standard capabilities; needs a native kicad-cli for the gate to see them"})),+        _ => None,+    }+}++/// A profile name, or a JSON file with the same keys.+pub fn load(arg: &str) -> Result<Value, String> {+    if let Some(p) = profile(arg) { return Ok(p); }+    let v: Value = serde_json::from_str(&std::fs::read_to_string(arg).map_err(|e| format!("rules {arg}: not a profile (kicad-default, jlc-4l) and not a file: {e}"))?).map_err(|e| format!("{arg}: {e}"))?;+    for k in ["clearance", "track", "viaSignal", "edgeClearance"] { if v.get(k).is_none() { return Err(format!("{arg}: `{k}` missing")); } }+    Ok(v)+}++pub fn num(r: &Value, k: &str, d: f64) -> f64 { r[k].as_f64().unwrap_or(d) }+pub fn via(r: &Value, k: &str) -> (f64, f64) { (r[k][0].as_f64().unwrap_or(0.6), r[k][1].as_f64().unwrap_or(0.3)) }++/// The KiCad project file carrying the rules (net class and board minimums).+pub fn kicad_pro(r: &Value, name: &str) -> String {+    let (vd, vh) = via(r, "viaSignal");+    serde_json::to_string_pretty(&json!({+        "board": {"design_settings": {"defaults": {}, "rules": {+            "min_clearance": num(r, "clearance", 0.2), "min_track_width": num(r, "track", 0.2), "min_via_diameter": vd, "min_via_annular_width": ((vd - vh) / 2.0 * 1000.0).round() / 1000.0,+            "min_through_hole_diameter": num(r, "minDrill", 0.3), "min_copper_edge_clearance": num(r, "edgeClearance", 0.5), "min_hole_clearance": num(r, "holeClearance", 0.25), "min_hole_to_hole": num(r, "holeToHole", 0.25)}}},+        "meta": {"filename": format!("{name}.kicad_pro"), "version": 1},+        "net_settings": {"classes": [{"name": "Default", "clearance": num(r, "clearance", 0.2), "track_width": num(r, "track", 0.2), "via_diameter": vd, "via_drill": vh}]},+        "adom_reverse_rules": r,+    })).unwrap() + "\n"+}+++#[cfg(test)]+mod tests {+    use super::*;+    #[test]+    fn profiles_carry_everything() {+        let r = profile("kicad-default").unwrap();+        assert!(kicad_pro(&r, "x").contains("\"min_clearance\": 0.2"));+        assert!(load("nope").is_err());+    }+}
src/sch.rsadded+97
@@ -0,0 +1,97 @@+//! The schematic, generated from the files: one symbol per part whose pins are its footprint's pad+//! numbers (so the schematic and the board cannot disagree), laid out block by block, every pin carrying+//! a label of its net from nets.json or a no-connect mark while it has none. Symbols are generic boxes+//! named by the part's value and function; swapping in library symbols is a later refinement.+use crate::kicad_out::uuid;+use serde_json::Value;+use std::collections::HashMap;+use std::path::Path;++const G: f64 = 2.54;++fn esc(s: &str) -> String { s.replace('\\', "/").replace('"', "'").replace('Ω', "R").replace('µ', "u").chars().map(|c| if c.is_ascii() { c } else { '?' }).collect() }++/// Pad numbers of a footprint text, in order, without repeats or unnamed (mechanical) pads.+pub fn pad_numbers(text: &str) -> Vec<String> {+    let mut v: Vec<String> = vec![];+    if let Ok(n) = crate::sexp::parse(text) {+        for p in n.children("pad") { let k = p.arg(0).unwrap_or_default(); if !k.is_empty() && !v.contains(&k) { v.push(k); } }+    }+    v.sort_by(|a, b| match (a.parse::<f64>(), b.parse::<f64>()) { (Ok(x), Ok(y)) => x.partial_cmp(&y).unwrap(), _ => a.cmp(b) });+    v+}++struct Sym { r: String, pins: Vec<String>, value: String, fp: String, w: f64, h: f64 }++pub fn schematic(cache: &Path, parts: &[Value], blocks: &Value, nets: &Value, project: &str) -> String {+    let pin_net: HashMap<String, String> = nets["nets"].as_array().into_iter().flatten()+        .flat_map(|n| n["pins"].as_array().into_iter().flatten().filter_map(move |p| p.as_str().map(|s| (s.to_string(), n["name"].as_str().unwrap_or("").to_string())))).collect();+    let mut syms: HashMap<String, Sym> = HashMap::new();+    for p in parts {+        let r = p["ref"].as_str().unwrap_or("?").to_string();+        let fp = p["footprint"].as_str().unwrap_or("").to_string();+        let pins = crate::kicad_out::footprint_text(cache, &fp, p).map(|t| pad_numbers(&t)).unwrap_or_default();+        if pins.is_empty() { continue; }+        let per_side = (pins.len() + 1) / 2;+        let w = if pins.len() <= 2 { 2.0 * G } else { 6.0 * G };+        let h = (per_side.max(1) as f64 + 1.0) * G;+        let value = format!("{} {}", p["value"].as_str().unwrap_or(""), if p["value"].as_str().unwrap_or("").is_empty() { p["function"].as_str().unwrap_or("") } else { "" }).trim().to_string();+        syms.insert(r.clone(), Sym { r, pins, value, fp, w, h });+    }+    let root = uuid(&format!("sch/{project}"));+    let mut lib = String::new(); let mut body = String::new();+    // layout: blocks side by side in columns, parts in rows inside each block+    let (mut bx, mut by, mut col_w) = (25.4, 30.48, 0.0f64);+    let sheet_h = 594.0 - 25.4;+    for b in blocks["blocks"].as_array().into_iter().flatten() {+        let refs: Vec<&str> = b["parts"].as_array().into_iter().flatten().filter_map(|x| x.as_str()).filter(|r| syms.contains_key(*r)).collect();+        if refs.is_empty() { continue; }+        let cols = ((refs.len() as f64).sqrt().ceil() as usize).clamp(1, 6);+        let cell_w = refs.iter().map(|r| syms[*r].w).fold(0.0, f64::max) + 14.0 * G;+        let rows_h: Vec<f64> = refs.chunks(cols).map(|c| c.iter().map(|r| syms[*r].h).fold(0.0, f64::max) + 3.0 * G).collect();+        let block_h: f64 = rows_h.iter().sum::<f64>() + 2.0 * G;+        if by + block_h > sheet_h { bx += col_w + 6.0 * G; by = 30.48; col_w = 0.0; }+        body += &format!("\t(text \"{}: {}\" (exclude_from_sim no) (at {:.2} {:.2} 0) (effects (font (size 2.54 2.54) (bold yes)) (justify left bottom)) (uuid \"{}\"))\n", esc(b["name"].as_str().unwrap_or("")), esc(b["function"].as_str().unwrap_or("").chars().take(90).collect::<String>().as_str()), bx, by - G, uuid(&format!("blk/{}", b["id"])));+        let mut y = by;+        for (ri, chunk) in refs.chunks(cols).enumerate() {+            for (ci, r) in chunk.iter().enumerate() {+                let s = &syms[*r];+                let (x0, y0) = (((bx + ci as f64 * cell_w + 7.0 * G) / 1.27).round() * 1.27, ((y + s.h / 2.0) / 1.27).round() * 1.27);+                let name = format!("RE_{}", s.r);+                let per_side = (s.pins.len() + 1) / 2;+                // library symbol: a box, pins left then right+                lib += &format!("\t\t(symbol \"RE:{name}\" (exclude_from_sim no) (in_bom yes) (on_board yes)\n\t\t\t(property \"Reference\" \"{}\" (at 0 {:.2} 0) (effects (font (size 1.27 1.27))))\n\t\t\t(property \"Value\" \"{name}\" (at 0 {:.2} 0) (effects (font (size 1.27 1.27))))\n\t\t\t(property \"Footprint\" \"\" (at 0 0 0) (effects (font (size 1.27 1.27)) hide))\n\t\t\t(property \"Datasheet\" \"~\" (at 0 0 0) (effects (font (size 1.27 1.27)) hide))\n",+                    s.r.trim_end_matches(|c: char| c.is_ascii_digit()), s.h / 2.0 + 1.27, -s.h / 2.0 - 1.27);+                lib += &format!("\t\t\t(symbol \"{name}_0_1\" (rectangle (start {:.2} {:.2}) (end {:.2} {:.2}) (stroke (width 0.254) (type default)) (fill (type background))))\n", -s.w / 2.0, s.h / 2.0, s.w / 2.0, -s.h / 2.0);+                lib += &format!("\t\t\t(symbol \"{name}_1_1\"\n");+                let mut ends = vec![];+                for (k, pin) in s.pins.iter().enumerate() {+                    let left = k < per_side;+                    let row = if left { k } else { k - per_side };+                    let py = s.h / 2.0 - G - row as f64 * G;+                    let (px, rot) = if left { (-s.w / 2.0 - G, 0) } else { (s.w / 2.0 + G, 180) };+                    lib += &format!("\t\t\t\t(pin passive line (at {px:.2} {py:.2} {rot}) (length 2.54) (name \"{}\" (effects (font (size 1.0 1.0)))) (number \"{}\" (effects (font (size 1.0 1.0)))))\n", esc(pin), esc(pin));+                    ends.push((pin.clone(), x0 + px, y0 - py, left));+                }+                lib += "\t\t\t)\n\t\t)\n";+                body += &format!("\t(symbol (lib_id \"RE:{name}\") (at {x0:.2} {y0:.2} 0) (unit 1) (exclude_from_sim no) (in_bom yes) (on_board yes) (dnp no) (uuid \"{}\")\n", uuid(&format!("sym/{}", s.r)));+                body += &format!("\t\t(property \"Reference\" \"{}\" (at {x0:.2} {:.2} 0) (effects (font (size 1.27 1.27))))\n", s.r, y0 - s.h / 2.0 - 1.27);+                body += &format!("\t\t(property \"Value\" \"{}\" (at {x0:.2} {:.2} 0) (effects (font (size 1.0 1.0))))\n", esc(&s.value.chars().take(40).collect::<String>()), y0 + s.h / 2.0 + 1.27);+                body += &format!("\t\t(property \"Footprint\" \"{}\" (at {x0:.2} {y0:.2} 0) (effects (font (size 1.27 1.27)) hide))\n", esc(&s.fp));+                for (pin, _, _, _) in &ends { body += &format!("\t\t(pin \"{}\" (uuid \"{}\"))\n", esc(pin), uuid(&format!("pin/{}/{pin}", s.r))); }+                body += &format!("\t\t(instances (project \"{project}\" (path \"/{root}\" (reference \"{}\") (unit 1))))\n\t)\n", s.r);+                for (pin, ex, ey, left) in &ends {+                    match pin_net.get(&format!("{}.{pin}", s.r)) {+                        Some(n) if !n.is_empty() => body += &format!("\t(label \"{}\" (at {ex:.2} {ey:.2} {}) (effects (font (size 1.0 1.0)) (justify {} bottom)) (uuid \"{}\"))\n", esc(n), if *left { 0 } else { 0 }, if *left { "right" } else { "left" }, uuid(&format!("lbl/{}/{pin}", s.r))),+                        _ => body += &format!("\t(no_connect (at {ex:.2} {ey:.2}) (uuid \"{}\"))\n", uuid(&format!("nc/{}/{pin}", s.r))),+                    }+                }+            }+            y += rows_h[ri];+            let _ = ri;+        }+        col_w = col_w.max(cols as f64 * cell_w);+        by += block_h + 3.0 * G;+    }+    format!("(kicad_sch\n\t(version 20231120)\n\t(generator \"adom-aiflow-reverse\")\n\t(generator_version \"8.0\")\n\t(uuid \"{root}\")\n\t(paper \"A0\")\n\t(title_block (title \"{} (reverse engineered from photos)\") (comment 1 \"Generic symbols: pins are the footprint's pads; nets from nets.json\"))\n\t(lib_symbols\n{lib}\t)\n{body}\t(sheet_instances (path \"/\" (page \"1\")))\n)\n", esc(project))+}
src/server.rsadded+115
@@ -0,0 +1,115 @@+//! `serve`: the flow window. A small local HTTP server (127.0.0.1 only, std library, one thread per+//! request) that serves the flow page, `/state` (the steps, their states and the recorded views) and+//! the files under reverse-flow/ (views, photos, evidence). The page polls /state and shows the newest+//! view as it is recorded, so the window follows the run; its history list replays the process.+use crate::flow::{self, Flow};+use serde_json::{json, Value};+use std::io::{BufRead, BufReader, Write};+use std::net::{TcpListener, TcpStream};+use std::path::{Component, Path, PathBuf};++const PAGE: &str = include_str!("../assets/flow.html");+/// AI Flow's timeline page, copied unchanged from adom/adom-aiflow tools/timeline (MIT, Adom Industries).+const TIMELINE: &str = include_str!("../assets/timeline.html");+const TIMELINE_ICON: &str = include_str!("../assets/timeline-favicon.svg");++pub fn state(flow_dir: &Path, fl: &Flow) -> Value {+    let cur = flow::current(fl, flow_dir).map(|s| s.name.clone());+    let steps: Vec<Value> = fl.steps.iter().map(|s| {+        let done = s.done.iter().all(|d| flow::exists(flow_dir, d));+        let stale: Vec<String> = s.done.iter().flat_map(|d| flow::stale(flow_dir, d)).collect();+        let st = if done { "done" } else if Some(&s.name) == cur.as_ref() { "now" } else if !stale.is_empty() { "stale" } else { "todo" };+        json!({"name": s.name, "state": st, "stale": stale, "do": s.todo})+    }).collect();+    let st = crate::items::read_json(&flow_dir.join("state.json")).unwrap_or(json!({}));+    let views = crate::items::read_json(&flow_dir.join("views.json")).unwrap_or(json!({"entries": []}));+    let evidence: Vec<String> = std::fs::read_dir(flow_dir.join("evidence")).map(|rd| {+        let mut v: Vec<String> = rd.flatten().map(|e| format!("evidence/{}", e.file_name().to_string_lossy())).filter(|n| is_image(n)).collect();+        v.sort(); v+    }).unwrap_or_default();+    let findings = crate::findings::all(flow_dir);+    json!({"flow": fl.name, "project": flow_dir.parent().map(|p| p.display().to_string()), "current": cur, "steps": steps,+           "titles": fl.steps.iter().map(|s| (s.name.clone(), json!(crate::timeline::title(&s.name)))).collect::<serde_json::Map<_, _>>(),+           "photos": st["photos"], "kicad": st["kicad"], "views": views["entries"], "evidence": evidence, "findings": findings})+}++fn is_image(n: &str) -> bool { let l = n.to_lowercase(); [".jpg", ".jpeg", ".png", ".webp", ".svg"].iter().any(|x| l.ends_with(x)) }++fn ctype(p: &Path) -> &'static str {+    match p.extension().and_then(|x| x.to_str()).map(|x| x.to_lowercase()).as_deref() {+        Some("html") => "text/html; charset=utf-8", Some("json") | Some("jsonl") => "application/json", Some("svg") => "image/svg+xml",+        Some("png") => "image/png", Some("jpg") | Some("jpeg") => "image/jpeg", Some("webp") => "image/webp", Some("md") => "text/plain; charset=utf-8",+        _ => "application/octet-stream",+    }+}++fn send(s: &mut TcpStream, code: &str, ct: &str, body: &[u8]) {+    let _ = write!(s, "HTTP/1.1 {code}\r\nContent-Type: {ct}\r\nContent-Length: {}\r\nCache-Control: no-store\r\nConnection: close\r\n\r\n", body.len());+    let _ = s.write_all(body);+}++fn decode(p: &str) -> String {+    let b = p.as_bytes();+    let mut out = vec![];+    let mut i = 0;+    while i < b.len() {+        if b[i] == b'%' && i + 2 < b.len() { if let Ok(v) = u8::from_str_radix(&p[i + 1..i + 3], 16) { out.push(v); i += 3; continue; } }+        out.push(b[i]); i += 1;+    }+    String::from_utf8_lossy(&out).to_string()+}++/// A request path under reverse-flow/, refusing anything that climbs out of it.+pub fn safe(flow_dir: &Path, rel: &str) -> Option<PathBuf> {+    let r = Path::new(rel);+    if r.components().any(|c| !matches!(c, Component::Normal(_))) { return None; }+    let p = flow_dir.join(r);+    p.is_file().then_some(p)+}++fn handle(mut s: TcpStream, flow_dir: &Path, fl: &Flow) {+    let mut line = String::new();+    if BufReader::new(&s).read_line(&mut line).is_err() { return; }+    let path = line.split_whitespace().nth(1).unwrap_or("/").split('?').next().unwrap_or("/").to_string();+    match path.as_str() {+        "/" | "/index.html" => send(&mut s, "200 OK", "text/html; charset=utf-8", PAGE.as_bytes()),+        "/state" => send(&mut s, "200 OK", "application/json", state(flow_dir, fl).to_string().as_bytes()),+        "/timeline/" | "/timeline/index.html" => send(&mut s, "200 OK", "text/html; charset=utf-8", TIMELINE.as_bytes()),+        "/timeline/favicon.svg" => send(&mut s, "200 OK", "image/svg+xml", TIMELINE_ICON.as_bytes()),+        "/timeline/timeline.json" => { let f = crate::findings::all(flow_dir); send(&mut s, "200 OK", "application/json", crate::timeline::timeline(flow_dir, fl, &f).to_string().as_bytes()) }+        "/timeline/presentation.json" => { let f = crate::findings::all(flow_dir); send(&mut s, "200 OK", "application/json", crate::timeline::presentation(flow_dir, fl, &f).to_string().as_bytes()) }+        "/health" => send(&mut s, "200 OK", "application/json", br#"{"ok":true}"#),+        p if p.starts_with("/files/") || p.starts_with("/timeline/files/") => match safe(flow_dir, &decode(p.split_once("/files/").map(|x| x.1).unwrap_or(""))) {+            Some(f) => match std::fs::read(&f) { Ok(b) => send(&mut s, "200 OK", ctype(&f), &b), Err(_) => send(&mut s, "500 Error", "text/plain", b"read failed") },+            None => send(&mut s, "404 Not Found", "text/plain", b"not found"),+        },+        _ => send(&mut s, "404 Not Found", "text/plain", b"not found"),+    }+}++pub fn serve(flow_dir: PathBuf, fl: Flow, port: u16) -> std::io::Result<()> {+    let l = TcpListener::bind(("127.0.0.1", port))?;+    let fl = std::sync::Arc::new(fl);+    let dir = std::sync::Arc::new(flow_dir);+    for c in l.incoming().flatten() {+        let (fl, dir) = (fl.clone(), dir.clone());+        std::thread::spawn(move || handle(c, &dir, &fl));+    }+    Ok(())+}++#[cfg(test)]+mod tests {+    use super::*;+    #[test]+    fn paths_stay_inside() {+        let d = std::env::temp_dir().join(format!("srv-test-{}", std::process::id()));+        std::fs::create_dir_all(d.join("views")).unwrap();+        std::fs::write(d.join("views/a.html"), "x").unwrap();+        assert!(safe(&d, "views/a.html").is_some());+        assert!(safe(&d, "../etc/passwd").is_none());+        assert!(safe(&d, "/etc/passwd").is_none());+        assert_eq!(decode("views/a%20b.html"), "views/a b.html");+        std::fs::remove_dir_all(&d).ok();+    }+}
src/sexp.rsadded+74
@@ -0,0 +1,74 @@+//! A small S-expression tree for KiCad files: parse a footprint or symbol, change it, write it back.+#[derive(Clone, Debug, PartialEq)]+pub enum N { A(String), L(Vec<N>) }++pub fn parse(s: &str) -> Result<N, String> {+    let b = s.as_bytes();+    let mut i = 0;+    fn ws(b: &[u8], i: &mut usize) { while *i < b.len() && (b[*i] as char).is_whitespace() { *i += 1; } }+    fn node(b: &[u8], i: &mut usize) -> Result<N, String> {+        ws(b, i);+        if *i >= b.len() { return Err("unexpected end".into()); }+        if b[*i] == b'(' {+            *i += 1; let mut v = vec![];+            loop { ws(b, i); if *i >= b.len() { return Err("unclosed list".into()); } if b[*i] == b')' { *i += 1; return Ok(N::L(v)); } v.push(node(b, i)?); }+        }+        if b[*i] == b'"' {+            let st = *i; *i += 1;+            while *i < b.len() && b[*i] != b'"' { if b[*i] == b'\\' { *i += 1; } *i += 1; }+            *i += 1; return Ok(N::A(String::from_utf8_lossy(&b[st..*i]).to_string()));+        }+        let st = *i;+        while *i < b.len() && !(b[*i] as char).is_whitespace() && b[*i] != b'(' && b[*i] != b')' { *i += 1; }+        Ok(N::A(String::from_utf8_lossy(&b[st..*i]).to_string()))+    }+    node(b, &mut i)+}++impl N {+    pub fn head(&self) -> &str { match self { N::L(v) => match v.first() { Some(N::A(s)) => s, _ => "" }, _ => "" } }+    pub fn items(&self) -> &[N] { match self { N::L(v) => v, _ => &[] } }+    pub fn items_mut(&mut self) -> &mut Vec<N> { match self { N::L(v) => v, _ => panic!("not a list") } }+    pub fn child(&self, h: &str) -> Option<&N> { self.items().iter().find(|n| n.head() == h) }+    pub fn child_mut(&mut self, h: &str) -> Option<&mut N> { self.items_mut().iter_mut().find(|n| n.head() == h) }+    pub fn children<'a>(&'a self, h: &'a str) -> impl Iterator<Item = &'a N> + 'a { self.items().iter().filter(move |n| n.head() == h) }+    /// The i-th atom after the head, unquoted.+    pub fn arg(&self, i: usize) -> Option<String> { match self.items().get(i + 1) { Some(N::A(s)) => Some(unq(s)), _ => None } }+    pub fn num(&self, i: usize) -> Option<f64> { self.arg(i).and_then(|s| s.parse().ok()) }+    pub fn write(&self, out: &mut String, depth: usize) {+        match self {+            N::A(s) => out.push_str(s),+            N::L(v) => {+                let nested = v.iter().any(|n| matches!(n, N::L(_)));+                out.push('(');+                for (k, n) in v.iter().enumerate() {+                    if k > 0 { if nested && matches!(n, N::L(_)) { out.push('\n'); for _ in 0..=depth { out.push('\t'); } } else { out.push(' '); } }+                    n.write(out, depth + 1);+                }+                out.push(')');+            }+        }+    }+    pub fn to_string(&self) -> String { let mut s = String::new(); self.write(&mut s, 0); s }+}++pub fn unq(s: &str) -> String { if s.starts_with('"') && s.ends_with('"') && s.len() >= 2 { s[1..s.len() - 1].replace("\\\"", "\"") } else { s.to_string() } }+pub fn q(s: &str) -> N { N::A(format!("\"{}\"", s.replace('"', "\\\""))) }+pub fn a(s: impl ToString) -> N { N::A(s.to_string()) }+pub fn l(v: Vec<N>) -> N { N::L(v) }+pub fn f(x: f64) -> N { let s = format!("{:.4}", x); let s = s.trim_end_matches('0').trim_end_matches('.').to_string(); a(if s == "-0" { "0".into() } else { s }) }++#[cfg(test)]+mod tests {+    use super::*;+    #[test]+    fn round_trip() {+        let t = r#"(footprint "R_0603" (layer "F.Cu") (pad "1" smd roundrect (at -0.825 0) (size 0.8 0.95) (layers "F.Cu" "F.Mask")))"#;+        let n = parse(t).unwrap();+        assert_eq!(n.head(), "footprint");+        assert_eq!(n.arg(0).unwrap(), "R_0603");+        let p = n.child("pad").unwrap();+        assert_eq!(p.child("at").unwrap().num(0), Some(-0.825));+        assert_eq!(parse(&n.to_string()).unwrap(), n);+    }+}
src/snap.rsadded+110
@@ -0,0 +1,110 @@+//! `parts snap`: a part's measured position and rotation from its own footprint. The pads of the+//! library footprint are laid on the photo's metal (exposed pads, solder, leads) at every offset within+//! reach and every quarter turn; the pose where the pads cover the most metal and the ring just outside+//! them the least wins. A turn that scores the same as the AI's rotation (a symmetric pad pattern)+//! keeps the AI's rotation: pin 1 comes from the mark, not from the pads.+use crate::board::{to_px, RES};+use crate::raster::{self, Plane};+use crate::sexp;+use image::RgbImage;++#[derive(Clone, Debug)]+pub struct LPad { pub x: f64, pub y: f64, pub w: f64, pub h: f64, pub rot: f64, pub round: bool, pub drill: f64 }++/// Pads of a footprint in its own frame (mm).+pub fn pads(text: &str) -> Vec<LPad> {+    let Ok(n) = sexp::parse(text) else { return vec![] };+    n.children("pad").filter_map(|p| {+        let at = p.child("at")?; let size = p.child("size")?;+        let shape = p.arg(2).unwrap_or_default();+        Some(LPad { x: at.num(0)?, y: at.num(1)?, rot: at.num(2).unwrap_or(0.0), w: size.num(0)?, h: size.num(1)?, round: shape == "circle", drill: p.child("drill").and_then(|d| d.num(0)).unwrap_or(0.0) })+    }).filter(|p| p.w > 0.05 && p.h > 0.05).collect()+}++/// Metal likelihood per pixel (0..1): exposed pads, solder, gold, leads; lightly blurred.+pub fn metal_plane(img: &RgbImage) -> Plane {+    let m = Plane { w: img.width() as usize, h: img.height() as usize, d: img.pixels().map(|p| match crate::prep::material(p) {+        crate::prep::Mat::Metal | crate::prep::Mat::Gold => 1.0, crate::prep::Mat::Silk => 0.6, _ => 0.0 }).collect() };+    raster::blur(&m, 1, 2)+}++/// Sample points inside each pad (skipping its drill) and on a ring 0.25 mm outside it, in footprint mm.+fn probes(pads: &[LPad]) -> (Vec<(f64, f64)>, Vec<(f64, f64)>) {+    let (mut inside, mut ring) = (vec![], vec![]);+    for p in pads {+        let (c, s) = (p.rot.to_radians().cos(), p.rot.to_radians().sin());+        let put = |lx: f64, ly: f64| (p.x + lx * c + ly * s, p.y - lx * s + ly * c);+        let (hw, hh) = (p.w / 2.0, p.h / 2.0);+        let step = (p.w.min(p.h) / 4.0).clamp(0.05, 0.25);+        let mut u = -hw + step / 2.0;+        while u < hw { let mut v = -hh + step / 2.0;+            while v < hh {+                let inpad = if p.round { (u / hw).powi(2) + (v / hh).powi(2) <= 1.0 } else { true };+                if inpad && (u * u + v * v).sqrt() > p.drill / 2.0 + 0.05 { inside.push(put(u, v)); }+                v += step; }+            u += step; }+        let g = 0.25; let n = 16;+        for k in 0..n { let t = k as f64 / n as f64 * std::f64::consts::TAU; ring.push(put((hw + g) * t.cos(), (hh + g) * t.sin())); }+    }+    (inside, ring)+}++/// The best pose near (x, y) for these pads. `turns` are the rotations to try (degrees, KiCad).+/// Returns (x, y, rot, score) with score = metal inside - metal outside (about 0.5+ is a clear match).+pub fn best_pose(metal: &Plane, pads: &[LPad], x: f64, y: f64, turns: &[f64], reach: f64) -> (f64, f64, f64, f64) { best_pose_at(metal, pads, x, y, turns, reach, None) }++/// The pads' bounding-box centre in the footprint frame: where a part's middle is, whatever its origin.+pub fn pad_centre(pads: &[LPad]) -> (f64, f64) {+    let (x0, y0, x1, y1) = pads.iter().fold((f64::MAX, f64::MAX, f64::MIN, f64::MIN), |a, q| (a.0.min(q.x - q.w / 2.0), a.1.min(q.y - q.h / 2.0), a.2.max(q.x + q.w / 2.0), a.3.max(q.y + q.h / 2.0)));+    ((x0 + x1) / 2.0, (y0 + y1) / 2.0)+}++/// As `best_pose`; with `centre` (the pads' centre in the footprint frame), (x, y) is where the part's+/// middle was read on the photo, and each turn searches around the origin that puts the middle there.+pub fn best_pose_at(metal: &Plane, pads: &[LPad], x: f64, y: f64, turns: &[f64], reach: f64, centre: Option<(f64, f64)>) -> (f64, f64, f64, f64) {+    let (inside, ring) = probes(pads);+    if inside.is_empty() { return (x, y, turns[0], 0.0); }+    let mut best = (x, y, turns[0], f64::MIN);+    let step = 1.0 / RES * 2.0;+    for &rot in turns {+        let (c, s) = (rot.to_radians().cos(), rot.to_radians().sin());+        let (x, y) = match centre { Some((cx, cy)) => (x - (cx * c + cy * s), y - (-cx * s + cy * c)), None => (x, y) };+        let tin: Vec<(f64, f64)> = inside.iter().map(|(lx, ly)| (lx * c + ly * s, -lx * s + ly * c)).collect();+        let tout: Vec<(f64, f64)> = ring.iter().map(|(lx, ly)| (lx * c + ly * s, -lx * s + ly * c)).collect();+        let mean = |pts: &[(f64, f64)], ox: f64, oy: f64| -> f64 {+            let mut sum = 0.0; let mut n = 0.0;+            for (dx, dy) in pts { let (u, v) = to_px(ox + dx, oy + dy); if u >= 0.0 && v >= 0.0 && u < metal.w as f64 - 1.0 && v < metal.h as f64 - 1.0 { sum += metal.sample(u as f32, v as f32) as f64; n += 1.0; } }+            if n > 0.0 { sum / n } else { 0.0 }+        };+        let mut oy = -reach;+        while oy <= reach + 1e-9 { let mut ox = -reach;+            while ox <= reach + 1e-9 {+                let sc = mean(&tin, x + ox, y + oy) - mean(&tout, x + ox, y + oy);+                // prefer the AI's own rotation on a tie, and the nearer offset+                let sc_adj = sc - 0.002 * (ox * ox + oy * oy).sqrt() + if rot == turns[0] { 0.01 } else { 0.0 };+                if sc_adj > best.3 { best = (x + ox, y + oy, rot, sc_adj); }+                ox += step; }+            oy += step; }+    }+    best+}++/// Quarter turns that map the pad pattern onto itself (a resistor at 180, a QFN at 90): such a turn is+/// the same part, so the search never tries it and pin 1 stays the AI's.+pub fn symmetric_turns(pads: &[LPad]) -> Vec<f64> {+    let (cx, cy) = pad_centre(pads);+    let mut out = vec![];+    for t in [90.0f64, 180.0, 270.0] {+        let (c, s) = (t.to_radians().cos(), t.to_radians().sin());+        let same = pads.iter().all(|p| {+            let (dx, dy) = (p.x - cx, p.y - cy);+            let (rx, ry) = (cx + dx * c + dy * s, cy - dx * s + dy * c);+            let quarter = (t / 90.0) as i64 % 2 == 1;+            pads.iter().any(|q| (q.x - rx).abs() < 0.05 && (q.y - ry).abs() < 0.05 && {+                let (pw, ph) = if quarter ^ ((p.rot - q.rot).rem_euclid(180.0).abs() > 45.0) { (p.h, p.w) } else { (p.w, p.h) };+                (pw - q.w).abs() < 0.05 && (ph - q.h).abs() < 0.05 })+        });+        if same { out.push(t); }+    }+    out+}
src/timeline.rsadded+126
@@ -0,0 +1,126 @@+//! AI Flow's own timeline window (assets/timeline.html, copied from adom/adom-aiflow tools/timeline)+//! fed by this flow: timeline.json (the step path, phases, statuses, visits, minutes and loop backs)+//! and presentation.json (the project card: current step, its findings, the latest board overlay).+//! Each finished step's line carries what it found, from findings.rs.+use crate::flow::{self, Flow};+use crate::items::read_json;+use serde_json::{json, Value};+use std::path::Path;++pub fn title(step: &str) -> &'static str {+    match step {+        "intake" => "Photos in", "scale" => "Scale", "parts" => "1 · Parts", "blocks" => "2 · Functional blocks",+        "copper" => "3 · Zones and routing", "mimic" => "4 · Copy what is visible", "snapshot" => "5a · Save the project",+        "connect" => "5b · Connections and layers", "review" => "6 · Review loop", "tests" => "7 · Tests for unknowns",+        "report" => "Report", _ => "Step",+    }+}++pub fn phase(step: &str) -> &'static str {+    match step {+        "intake" | "scale" => "Photos", "parts" | "blocks" | "copper" => "Identify", "mimic" | "snapshot" => "Copy",+        "connect" => "Infer", "review" | "tests" => "Verify", _ => "Deliver",+    }+}++/// The step a ledger event belongs to.+pub fn event_step(ev: &Value) -> Option<String> {+    let d = &ev["data"];+    let st = match ev["ev"].as_str()? {+        "init" | "prep" => "intake", "scale" => "scale", "snapshot" => "snapshot", "review" | "review-measure" => "review", "report" => "report",+        "detect" | "parts-init" | "parts-snap" | "parts-reconcile" => "parts", "blocks-propose" => "blocks", "copper-prep" | "copper-fit" => "copper",+        "build" | "kicad" => "mimic", "connect-extract" | "connect-route" => "connect", "tests-generate" => "tests",+        "check" => match d["what"].as_str()? { "photos" | "prep" => "intake", "nets" | "layers" => "connect", w => return Some(w.to_string()) },+        "view" => return d["step"].as_str().map(str::to_string),+        _ => return None,+    };+    Some(st.into())+}++/// Seconds since the epoch for "YYYY-MM-DDTHH:MM:SSZ" (the ledger's own format).+pub fn epoch(t: &str) -> Option<i64> {+    let n = |a: usize, b: usize| t.get(a..b)?.parse::<i64>().ok();+    let (y, mo, d, h, mi, s) = (n(0, 4)?, n(5, 7)?, n(8, 10)?, n(11, 13)?, n(14, 16)?, n(17, 19)?);+    let (y2, m2) = if mo <= 2 { (y - 1, mo + 9) } else { (y, mo - 3) };+    let days = 365 * y2 + y2 / 4 - y2 / 100 + y2 / 400 + (153 * m2 + 2) / 5 + d - 1 - 719468;+    Some(days * 86400 + h * 3600 + mi * 60 + s)+}++fn first_sentence(s: &str) -> String {+    let s = s.split_once(": ").filter(|(a, _)| a.starts_with("STEP")).map(|(_, b)| b).unwrap_or(s);+    s.split(". ").next().unwrap_or(s).trim_end_matches('.').to_string()+}++pub fn timeline(flow_dir: &Path, fl: &Flow, findings: &Value) -> Value {+    let st = read_json(&flow_dir.join("state.json")).unwrap_or(json!({}));+    let ledger: Vec<Value> = std::fs::read_to_string(flow_dir.join("run.jsonl")).unwrap_or_default().lines().filter_map(|l| serde_json::from_str(l).ok()).collect();+    let cur = flow::current(fl, flow_dir).map(|s| s.name.clone());+    let rework = &st["rework"];+    let (rw_from, rw_to) = (rework["from"].as_str().unwrap_or(""), rework["to"].as_str().unwrap_or(""));+    let pos = |n: &str| fl.steps.iter().position(|s| s.name == n);+    // AI time per step: the gap before each ledger event goes to that event's step (idle over 20 minutes is cut)+    let mut minutes = std::collections::HashMap::<String, f64>::new();+    let mut visits = std::collections::HashMap::<String, u64>::new();+    let mut prev: Option<i64> = None;+    for ev in &ledger {+        let t = ev["t"].as_str().and_then(epoch);+        if let (Some(step), Some(t)) = (event_step(ev), t) {+            if let Some(p) = prev { *minutes.entry(step.clone()).or_default() += ((t - p).clamp(0, 1200)) as f64 / 60.0; }+            let passed = ev["ev"] != json!("check") || ev["data"]["ok"] == json!(true);+            if passed && ev["ev"] != json!("view") { *visits.entry(step).or_default() += 1; }+        }+        if t.is_some() { prev = t; }+    }+    let steps: Vec<Value> = fl.steps.iter().map(|s| {+        let done = s.done.iter().all(|d| flow::exists(flow_dir, d));+        let stale = s.done.iter().any(|d| !flow::stale(flow_dir, d).is_empty());+        let in_rework = match (pos(rw_to), pos(rw_from), pos(&s.name)) { (Some(a), Some(b), Some(i)) => i >= a && i <= b, _ => false };+        let status = if Some(&s.name) == cur.as_ref() { "current" } else if done { "done" } else if stale || in_rework { "redo" } else { "todo" };+        let found = findings[&s.name]["summary"].as_str().unwrap_or("");+        let does = if done && !found.is_empty() { found.to_string() } else { first_sentence(&s.todo) };+        json!({"name": s.name, "title": title(&s.name), "does": does, "phase": phase(&s.name), "status": status,+               "visits": visits.get(&s.name).copied().unwrap_or(0).max(if done { 1 } else { 0 }),+               "minutes": (minutes.get(&s.name).copied().unwrap_or(0.0) * 10.0).round() / 10.0})+    }).collect();+    let pcb = st["kicad"]["pcb"].as_str().and_then(|p| Path::new(p).file_name()).map(|f| f.to_string_lossy().to_string());+    let proj = flow_dir.parent().and_then(|p| p.file_name()).map(|f| f.to_string_lossy().to_string()).unwrap_or_default();+    json!({+        "title": format!("Reverse engineering: {proj}"), "label": proj, "engine": "KiCad",+        "board": pcb, "aiThread": ledger.last().and_then(|e| e["aiThread"].as_str()).unwrap_or(""),+        "runFolder": proj, "runDir": flow_dir.display().to_string(),+        "finish": "KiCad copy, layer proposal, confidence and the test list",+        "updated": crate::ledger::now(), "current": cur, "steps": steps, "loops": st["loops"].clone(),+    })+}++/// The timeline's project card: the step in hand, what it found, and the latest board overlay or photo.+pub fn presentation(flow_dir: &Path, fl: &Flow, findings: &Value) -> Value {+    let st = read_json(&flow_dir.join("state.json")).unwrap_or(json!({}));+    let cur = flow::current(fl, flow_dir);+    let proj = flow_dir.parent().and_then(|p| p.file_name()).map(|f| f.to_string_lossy().to_string()).unwrap_or_default();+    let (t, summary) = match cur {+        Some(s) => (format!("Now: {}", title(&s.name)), findings[&s.name]["summary"].as_str().map(str::to_string).unwrap_or_else(|| first_sentence(&s.todo))),+        None => ("Finished".to_string(), findings["review"]["summary"].as_str().unwrap_or("Every step is done.").to_string()),+    };+    let ok_img = |n: &str| { let l = n.to_lowercase(); l.ends_with(".jpg") || l.ends_with(".jpeg") || l.ends_with(".png") || l.ends_with(".webp") };+    let overlay = std::fs::read_dir(flow_dir.join("evidence")).ok().and_then(|rd| rd.flatten().map(|e| e.file_name().to_string_lossy().to_string()).filter(|n| n.starts_with("overlay-top") && ok_img(n)).max());+    let photo = st["photos"].as_array().and_then(|a| a.iter().find(|p| p["side"] == json!("top"))).and_then(|p| p["file"].as_str()).filter(|f| ok_img(f)).map(str::to_string);+    let (image, caption) = match (overlay, photo) {+        (Some(o), _) => (Some(format!("files/evidence/{o}")), format!("Copied board over the top photo ({o})")),+        (None, Some(p)) => (Some(format!("files/photos/{p}")), format!("{p} · top photo")),+        _ => (None, String::new()),+    };+    json!({"projectTitle": format!("Reverse engineering: {proj}"), "title": t, "summary": summary, "image": image, "caption": caption, "alt": caption})+}++#[cfg(test)]+mod tests {+    use super::*;+    #[test]+    fn epoch_and_events() {+        assert_eq!(epoch("1970-01-01T00:00:00Z"), Some(0));+        assert_eq!(epoch("2026-11-03T20:55:13Z").unwrap() - epoch("2026-11-03T20:54:13Z").unwrap(), 60);+        assert_eq!(event_step(&json!({"ev": "check", "data": {"what": "layers"}})).as_deref(), Some("connect"));+        assert_eq!(first_sentence("STEP 2: identify the functional blocks. Write blocks.json"), "identify the functional blocks");+    }+}
src/trace.rsadded+278
@@ -0,0 +1,278 @@+//! Copper traced from the photo, island by island. Under solder mask, copper reads lighter than bare laminate+//! within the same neighbourhood, so a pixel's brightness over the wide local average of mask separates the+//! two. Thin gaps between pieces of copper are blurred by that average, so a bank of oriented dark-line filters+//! adds them: under oblique light a gap is a thin valley that survives photo grain once averaged along its+//! direction. Laminate plus gap lines is the barrier. Breaks are closed by walking on from each end of its+//! skeleton. What is left between barriers, not on parts or pads, is one island of copper: a track, a pour or a+//! via's ring, and the unit of measured connectivity.+use crate::raster::{blur, Mask, Plane};+use image::{Rgb, RgbImage};+use serde_json::{json, Value};+use std::collections::HashMap;++/// `dark`: a pixel this far below its neighbourhood is laminate. `hi` and `lo` are fractions of the photo's+/// 99th percentile line response. `bodies`: this face carries parts, so dark low-saturation pixels are bodies.+pub struct Params { pub dark: f32, pub hi: f32, pub lo: f32, pub minlen: usize, pub ext: i64, pub min_area: usize, pub bodies: bool }+impl Default for Params { fn default() -> Self { Params { dark: 0.90, hi: 0.75, lo: 0.34, minlen: 30, ext: 26, min_area: 90, bodies: false } } }++pub struct Traced { pub w: usize, pub h: usize, pub resp: Plane, pub gap: Mask, pub labels: Vec<u32>, pub regions: Vec<Region>, pub other: Mask, pub ratio: Plane, pub dark: Mask }++#[derive(Clone, Debug)]+pub struct Region { pub id: u32, pub area: usize, pub cx: f64, pub cy: f64, pub x0: usize, pub y0: usize, pub x1: usize, pub y1: usize, pub level: f32, pub half_width: f32, pub other_share: f32 }++/// What is definitely not solder mask: silk and metal (pale), gold and ceramic (red above green), and on a+/// face with parts, black moulding. Grain makes dark mask pixels read any hue, so hue itself is not trusted.+pub fn not_mask(p: &Rgb<u8>, bodies: bool) -> bool {+    let (_, s, v) = crate::raster::hsv(p);+    (s < 0.27 && v > 0.30) || (p[0] as i32 > p[1] as i32 + 10 && v > 0.35) || (bodies && v < 0.30 && s < 0.42)+}++/// Dark-line response and the best direction at each pixel. Pixels that are not solder mask read as neutral.+pub fn response(img: &RgbImage, pr: &Params) -> (Plane, Vec<u8>, Mask, Plane, Mask) {+    let (w, h) = (img.width() as usize, img.height() as usize);+    let mut g = Plane::new(w, h, 0.0); let (mut rp, mut bp) = (Plane::new(w, h, 0.0), Plane::new(w, h, 0.0));+    for y in 0..h { for x in 0..w { let p: &Rgb<u8> = img.get_pixel(x as u32, y as u32); let i = y * w + x; g.d[i] = p[1] as f32; rp.d[i] = p[0] as f32; bp.d[i] = p[2] as f32; } }+    // material is read from a smoothed copy: single grainy pixels are not parts+    let (rs, gs, bs) = (blur(&rp, 2, 1), blur(&g, 2, 1), blur(&bp, 2, 1));+    let mut raw = Mask::new(w, h);+    let mut silk_raw = Mask::new(w, h);+    for i in 0..w * h { let px = Rgb([rs.d[i] as u8, gs.d[i] as u8, bs.d[i] as u8]); if not_mask(&px, pr.bodies) { raw.d[i] = true; } let (_, sa, va) = crate::raster::hsv(&px); if sa < 0.2 && va > 0.55 { silk_raw.d[i] = true; } }+    let other = raw.open(2.0).dilate(3.0);+    // the wide average of mask only, so a bright part does not lift the reference beside it+    let mut masked = g.clone(); let mut weight = Plane::new(w, h, 1.0);+    for i in 0..w * h { if other.d[i] { masked.d[i] = 0.0; weight.d[i] = 0.0; } }+    let (wide, wide_w) = (blur(&masked, 40, 2), blur(&weight, 40, 2));+    let fine = blur(&g, 1, 2);+    let mut n = Plane::new(w, h, 80.0);+    for i in 0..w * h { if !other.d[i] && wide_w.d[i] > 0.02 { n.d[i] = fine.d[i] / (wide.d[i] / wide_w.d[i]).max(1.0) * 80.0; } }+    const NA: usize = 16;+    let threads = std::thread::available_parallelism().map(|v| v.get()).unwrap_or(4).clamp(1, NA);+    let run = |k: usize| -> Plane {+        let a = k as f64 * std::f64::consts::PI / NA as f64;+        let (ca, sa) = (a.cos(), a.sin());+        // across the line: the centre against both sides at two distances+        // a dark line: a Gaussian along it, the second derivative of a Gaussian across it, zero mean+        let (sig_across, sig_along) = (2.6f64, 9.0f64);+        let rad = 27i64;+        let mut kern: Vec<(i64, i64, f32)> = vec![];+        for dy in -rad..=rad { for dx in -rad..=rad {+            let (u, v) = (dx as f64 * ca + dy as f64 * sa, -(dx as f64) * sa + dy as f64 * ca);+            let g = (-u * u / (2.0 * sig_along * sig_along)).exp() * (1.0 - v * v / (sig_across * sig_across)) * (-v * v / (2.0 * sig_across * sig_across)).exp();+            if g.abs() > 0.012 { kern.push((dx, dy, g as f32)); }+        } }+        let mean = kern.iter().map(|k| k.2).sum::<f32>() / kern.len() as f32;+        let norm = kern.iter().map(|k| (k.2 - mean).abs()).sum::<f32>() / 2.0;+        for k in kern.iter_mut() { k.2 = -(k.2 - mean) / norm; }+        let mut r = Plane::new(w, h, 0.0);+        let flat: Vec<(isize, f32)> = kern.iter().map(|k| (k.0 as isize + k.1 as isize * w as isize, k.2)).collect();+        let m = rad as usize;+        for y in 0..h { for x in 0..w { let i = y * w + x; if other.d[i] { continue; }+            let mut v = 0f32;+            if x >= m && y >= m && x + m < w && y + m < h { let ii = i as isize; for (o, g) in &flat { v += *g * n.d[(ii + *o) as usize]; } }+            else { for k in &kern { let (px, py) = ((x as i64 + k.0).clamp(0, w as i64 - 1), (y as i64 + k.1).clamp(0, h as i64 - 1)); v += k.2 * n.d[py as usize * w + px as usize]; } }+            r.d[i] = v; } }+        r+    };+    let mut all: Vec<Option<Plane>> = (0..NA).map(|_| None).collect();+    for chunk in (0..NA).collect::<Vec<_>>().chunks(threads) {+        let res: Vec<(usize, Plane)> = std::thread::scope(|sc| { let hs: Vec<_> = chunk.iter().map(|k| { let k = *k; let run = &run; sc.spawn(move || (k, run(k))) }).collect(); hs.into_iter().map(|h| h.join().unwrap()).collect() });+        for (k, p) in res { all[k] = Some(p); }+    }+    let mut best = Plane::new(w, h, 0.0); let mut ang = vec![0u8; w * h];+    for (k, p) in all.iter().enumerate() { let p = p.as_ref().unwrap(); for i in 0..w * h { if p.d[i] > best.d[i] { best.d[i] = p.d[i]; ang[i] = k as u8; } } }+    for i in 0..w * h { if other.d[i] { best.d[i] = 0.0; } }+    (best, ang, other, n, silk_raw)+}++/// Zhang-Suen thinning, in place.+pub fn thin(m: &mut [bool], w: usize, h: usize) {+    let mut active: Vec<usize> = (0..w * h).filter(|i| m[*i] && i % w > 0 && i % w < w - 1 && i / w > 0 && i / w < h - 1).collect();+    loop {+        let mut changed = false;+        for step in 0..2 {+            let mut rem = vec![];+            for &i in &active {+                if !m[i] { continue; }+                let p = [m[i - w], m[i - w + 1], m[i + 1], m[i + w + 1], m[i + w], m[i + w - 1], m[i - 1], m[i - w - 1]];+                let b = p.iter().filter(|v| **v).count();+                if !(2..=6).contains(&b) { continue; }+                let a = (0..8).filter(|k| !p[*k] && p[(k + 1) % 8]).count();+                if a != 1 { continue; }+                let (p2, p4, p6, p8) = (p[0], p[2], p[4], p[6]);+                let c = if step == 0 { !(p2 && p4 && p6) && !(p4 && p6 && p8) } else { !(p2 && p4 && p8) && !(p2 && p6 && p8) };+                if c { rem.push(i); }+            }+            if !rem.is_empty() { changed = true; for i in rem { m[i] = false; } }+        }+        if !changed { break; }+        active.retain(|i| m[*i]);+    }+}++fn endpoints(m: &[bool], w: usize, h: usize) -> Vec<usize> {+    let mut v = vec![];+    for y in 1..h - 1 { for x in 1..w - 1 { let i = y * w + x; if !m[i] { continue; }+        let n = [i - w - 1, i - w, i - w + 1, i - 1, i + 1, i + w - 1, i + w, i + w + 1].iter().filter(|j| m[**j]).count();+        if n == 1 { v.push(i); } } }+    v+}++fn draw_line(m: &mut [bool], w: usize, a: (i64, i64), b: (i64, i64)) {+    let n = (b.0 - a.0).abs().max((b.1 - a.1).abs()).max(1);+    for t in 0..=n { let (x, y) = (a.0 + (b.0 - a.0) * t / n, a.1 + (b.1 - a.1) * t / n); m[y as usize * w + x as usize] = true; }+}++/// Walk on from each line end, in a cone around its direction, until another line is met.+fn extend(m: &mut Vec<bool>, w: usize, h: usize, ext: i64) -> usize {+    let snap = m.clone(); let mut n = 0;+    for e in endpoints(&snap, w, h) {+        let (x, y) = ((e % w) as i64, (e / w) as i64);+        let (mut sx, mut sy, mut c) = (0f64, 0f64, 0f64);+        for dy in -6i64..=6 { for dx in -6i64..=6 { let (px, py) = (x + dx, y + dy); if px >= 0 && py >= 0 && px < w as i64 && py < h as i64 && snap[py as usize * w + px as usize] { sx += px as f64; sy += py as f64; c += 1.0; } } }+        if c < 2.0 { continue; }+        let base = (y as f64 - sy / c).atan2(x as f64 - sx / c);+        'walk: for t in 3..ext { for da in [0.0, 0.17, -0.17, 0.35, -0.35, 0.6, -0.6] {+            let a = base + da;+            let (px, py) = (x + (a.cos() * t as f64).round() as i64, y + (a.sin() * t as f64).round() as i64);+            if px < 0 || py < 0 || px >= w as i64 || py >= h as i64 { continue; }+            if snap[py as usize * w + px as usize] && (px - x).abs() + (py - y).abs() > 3 { draw_line(m, w, (x, y), (px, py)); n += 1; break 'walk; }+        } }+    }+    n+}++fn label4(open: &[bool], w: usize, h: usize) -> (Vec<u32>, u32) {+    let mut lab = vec![0u32; w * h]; let mut n = 0; let mut st = vec![];+    for s in 0..w * h { if !open[s] || lab[s] != 0 { continue; } n += 1; lab[s] = n; st.push(s);+        while let Some(i) = st.pop() { let (x, y) = (i % w, i / w);+            if x > 0 && open[i - 1] && lab[i - 1] == 0 { lab[i - 1] = n; st.push(i - 1); }+            if x + 1 < w && open[i + 1] && lab[i + 1] == 0 { lab[i + 1] = n; st.push(i + 1); }+            if y > 0 && open[i - w] && lab[i - w] == 0 { lab[i - w] = n; st.push(i - w); }+            if y + 1 < h && open[i + w] && lab[i + w] == 0 { lab[i + w] = n; st.push(i + w); } } }+    (lab, n)+}++fn median(v: &mut [f32]) -> f32 { if v.is_empty() { return 0.0; } v.sort_by(|a, b| a.partial_cmp(b).unwrap()); v[v.len() / 2] }++pub fn trace(img: &RgbImage, pr: &Params) -> Traced {+    let (w, h) = (img.width() as usize, img.height() as usize);+    let (resp, ang, other, _norm, silk_raw) = response(img, pr);+    let debug = std::env::var("TRACE_DEBUG").is_ok();+    let p99 = { let mut v: Vec<f32> = resp.d.iter().step_by(5).copied().filter(|x| *x > 0.0).collect(); v.sort_by(|a, b| a.partial_cmp(b).unwrap()); v.get(v.len() * 99 / 100).copied().unwrap_or(1.0) };+    let (hi, lo) = (pr.hi * p99, pr.lo * p99);+    // brightness over the wide local average of mask+    let mut g = Plane::new(w, h, 0.0); let mut wt = Plane::new(w, h, 1.0);+    for y in 0..h { for x in 0..w { let i = y * w + x; if other.d[i] { wt.d[i] = 0.0; } else { g.d[i] = img.get_pixel(x as u32, y as u32)[1] as f32; } } }+    let (fine, fine_w) = (blur(&g, 3, 1), blur(&wt, 3, 1));+    let (wide, wide_w) = (blur(&g, 120, 1), blur(&wt, 120, 1));+    let mut ratio = Plane::new(w, h, 1.0);+    for i in 0..w * h { if !other.d[i] && fine_w.d[i] > 0.02 && wide_w.d[i] > 0.02 { ratio.d[i] = (fine.d[i] / fine_w.d[i]) / (wide.d[i] / wide_w.d[i]).max(1.0); } }+    // laminate: specks are grain or dimples+    let dm = Mask { w, h, d: ratio.d.iter().map(|r| *r < pr.dark).collect() };+    let (dl, dc) = crate::raster::components(&dm);+    let mut keep_d = vec![false; dc.len() + 1]; for c in &dc { keep_d[c.label as usize] = c.area >= 120; }+    let dark = Mask { w, h, d: (0..w * h).map(|i| dl[i] != 0 && keep_d[dl[i] as usize]).collect() };+    // gap lines: the ridge across the best direction, long and strong somewhere+    let mut ridge = vec![false; w * h];+    for y in 3..h - 3 { for x in 3..w - 3 { let i = y * w + x; let v = resp.d[i]; if v <= lo { continue; }+        let a = ang[i] as f64 * std::f64::consts::PI / 16.0; let (nx, ny) = (-a.sin() * 1.5, a.cos() * 1.5);+        if v >= resp.sample((x as f64 + nx) as f32, (y as f64 + ny) as f32) && v >= resp.sample((x as f64 - nx) as f32, (y as f64 - ny) as f32) { ridge[i] = true; } } }+    let rm = Mask { w, h, d: ridge };+    let (lab, comps) = crate::raster::components(&rm);+    let mut mx = vec![0f32; comps.len() + 1];+    for i in 0..w * h { if lab[i] != 0 { let l = lab[i] as usize; mx[l] = mx[l].max(resp.d[i]); } }+    let mut keep = vec![false; comps.len() + 1];+    for c in &comps { keep[c.label as usize] = mx[c.label as usize] > hi && c.area >= pr.minlen; }+    // the barrier: laminate and gap lines, small breaks closed, then ends walked on along their direction+    let mut barrier = Mask { w, h, d: (0..w * h).map(|i| dark.d[i] || (lab[i] != 0 && keep[lab[i] as usize])).collect() }.close(2.5);+    let mut skel = barrier.d.clone();+    thin(&mut skel, w, h);+    for x in 0..w { skel[x] = true; skel[(h - 1) * w + x] = true; }+    for y in 0..h { skel[y * w] = true; skel[y * w + w - 1] = true; }+    let before = skel.clone();+    extend(&mut skel, w, h, pr.ext);+    extend(&mut skel, w, h, pr.ext / 2);+    for i in 0..w * h { if skel[i] && !before[i] { barrier.d[i] = true; let (x, y) = (i % w, i / w); if x > 0 { barrier.d[i - 1] = true; } if x + 1 < w { barrier.d[i + 1] = true; } if y > 0 { barrier.d[i - w] = true; } if y + 1 < h { barrier.d[i + w] = true; } } }+    for x in 0..w { barrier.d[x] = true; barrier.d[(h - 1) * w + x] = true; }+    for y in 0..h { barrier.d[y * w] = true; barrier.d[y * w + w - 1] = true; }+    // silk over copper does not separate it, but parts and pads do: copper under them cannot be seen+    let silk = silk_raw.close(2.0).dilate(4.0);+    let open: Vec<bool> = (0..w * h).map(|i| !barrier.d[i] && (!other.d[i] || silk.d[i])).collect();+    let (mut labels, n) = label4(&open, w, h);+    if debug { eprintln!("p99 {p99}, dark {:.3}, barrier {:.3}, other {:.3}, regions {n}", dark.count() as f64 / (w * h) as f64, barrier.count() as f64 / (w * h) as f64, other.count() as f64 / (w * h) as f64); }+    // crumbs are not copper we can say anything about+    let mut area = vec![0usize; n as usize + 1];+    for l in &labels { area[*l as usize] += 1; }+    for l in labels.iter_mut() { if *l != 0 && area[*l as usize] < pr.min_area { *l = 0; } }+    let acc = measure_regions(&mut labels, &ratio, &other, w, h);+    Traced { w, h, resp, gap: barrier, labels, regions: acc, other, ratio, dark }+}++/// Renumber the labels 1..n and measure each island.+pub fn measure_regions(labels: &mut [u32], ratio: &Plane, other: &Mask, w: usize, h: usize) -> Vec<Region> {+    let mut remap: HashMap<u32, u32> = HashMap::new();+    for l in labels.iter_mut() { if *l != 0 { let k = remap.len() as u32 + 1; *l = *remap.entry(*l).or_insert(k); } }+    let count = remap.len();+    let inv = Mask { w, h, d: labels.iter().map(|l| *l == 0).collect() };+    let dt = inv.distance_to_set();+    let mut acc: Vec<Region> = (0..count).map(|i| Region { id: i as u32 + 1, area: 0, cx: 0.0, cy: 0.0, x0: usize::MAX, y0: usize::MAX, x1: 0, y1: 0, level: 0.0, half_width: 0.0, other_share: 0.0 }).collect();+    let mut levels: Vec<Vec<f32>> = vec![vec![]; count]; let mut oth = vec![0usize; count];+    for y in 0..h { for x in 0..w { let i = y * w + x; let l = labels[i]; if l == 0 { continue; } let r = &mut acc[l as usize - 1];+        r.area += 1; r.cx += x as f64; r.cy += y as f64; r.x0 = r.x0.min(x); r.y0 = r.y0.min(y); r.x1 = r.x1.max(x); r.y1 = r.y1.max(y); r.half_width = r.half_width.max(dt.d[i]);+        if other.d[i] { oth[l as usize - 1] += 1; } else if (x + y) % 3 == 0 { levels[l as usize - 1].push(ratio.d[i]); } } }+    for (i, r) in acc.iter_mut().enumerate() { if r.area > 0 { r.cx /= r.area as f64; r.cy /= r.area as f64; } r.level = median(&mut levels[i]); r.other_share = oth[i] as f32 / r.area.max(1) as f32; }+    acc+}++/// An overlay: every region its own colour, boundaries dark.+pub fn overlay(img: &RgbImage, t: &Traced) -> RgbImage {+    let mut o = img.clone();+    let col = |l: u32| -> [u8; 3] { let x = l.wrapping_mul(2654435761); [90 + (x >> 8 & 0x9f) as u8, 90 + (x >> 16 & 0x9f) as u8, 90 + (x >> 24 & 0x9f) as u8] };+    for y in 0..t.h { for x in 0..t.w { let i = y * t.w + x; let p = o.get_pixel_mut(x as u32, y as u32);+        if t.gap.d[i] { *p = Rgb([0, 0, 0]); } else if t.labels[i] != 0 { let c = col(t.labels[i]); for k in 0..3 { p[k] = ((p[k] as u32 * 2 + c[k] as u32 * 3) / 5) as u8; } } } }+    o+}++pub fn regions_json(t: &Traced, to_mm: &dyn Fn(f64, f64) -> (f64, f64), px_per_mm: f64) -> Value {+    let v: Vec<Value> = t.regions.iter().map(|r| { let c = to_mm(r.cx, r.cy);+        json!({"id": r.id, "areaMm2": ((r.area as f64 / (px_per_mm * px_per_mm)) * 100.0).round() / 100.0, "centreMm": [(c.0 * 100.0).round() / 100.0, (c.1 * 100.0).round() / 100.0], "halfWidthMm": ((r.half_width as f64 + 1.5) / px_per_mm * 100.0).round() / 100.0, "level": r.level, "otherShare": (r.other_share * 100.0).round() / 100.0,+               "boxPx": [r.x0, r.y0, r.x1, r.y1]}) }).collect();+    json!({"regions": v})+}++#[cfg(test)]+mod tests {+    use super::*;+    fn board(w: u32, h: u32, paint: &dyn Fn(u32, u32) -> u8) -> RgbImage {+        let mut im = RgbImage::new(w, h);+        for y in 0..h { for x in 0..w { let n = ((x.wrapping_mul(7919) ^ y.wrapping_mul(104729)) % 7) as u8; let g = paint(x, y).saturating_add(n); im.put_pixel(x, y, Rgb([g / 6, g, g * 2 / 3])); } }+        im+    }+    #[test]+    fn two_islands_split_by_a_dark_lane_and_grain_does_not_split_them() {+        let im = board(400, 240, &|x, _| if (190..210).contains(&x) { 45 } else { 95 });+        let t = trace(&im, &Params::default());+        let (a, b) = (t.labels[120 * 400 + 80], t.labels[120 * 400 + 320]);+        assert!(a != 0 && b != 0 && a != b, "{a} {b}");+        assert_eq!(t.labels[30 * 400 + 80], a, "grain must not split the left island");+    }+    #[test]+    fn a_track_between_two_lanes_is_its_own_narrow_island() {+        let im = board(400, 240, &|_, y| if (100..112).contains(&y) || (124..136).contains(&y) { 45 } else { 95 });+        let t = trace(&im, &Params::default());+        let mid = t.labels[118 * 400 + 200];+        assert!(mid != 0);+        assert_ne!(t.labels[50 * 400 + 200], mid);+        assert_ne!(t.labels[200 * 400 + 200], mid);+        let r = t.regions.iter().find(|r| r.id == mid).unwrap();+        assert!(r.half_width < 12.0, "half width {}", r.half_width);+    }+    #[test]+    fn thinning_leaves_a_line() {+        let (w, h) = (20, 10); let mut m = vec![false; w * h];+        for y in 3..7 { for x in 2..18 { m[y * w + x] = true; } }+        thin(&mut m, w, h);+        let c = m.iter().filter(|v| **v).count();+        assert!(c > 8 && c < 22, "{c}");+    }+}
src/views.rsadded+119
@@ -0,0 +1,119 @@+//! The flow window's content, and the record of the process. Every time a stage completes or is+//! updated (a check passes, a snapshot is saved, a review pass is recorded, the report is built, or+//! the AI runs `view` after editing the project), the stage's view is generated and appended to+//! views.json: the interactive PCB (adom-2dboard embed) and/or the interactive schematic+//! (adom-schematic embed) of the KiCad project as it stood, or the photos before a project exists.+//! Views are numbered and never overwritten, so views.json plus views/ replay the whole process. An+//! unchanged board or schematic reuses the previous file instead of embedding it again.+//! A view that fails to build is reported as a hint; it never fails the design check.+use crate::{ledger, sha256};+use serde_json::{json, Value};+use std::path::{Path, PathBuf};+use std::process::Command;++/// What each stage shows. Stages before a KiCad project exists fall back to the photos.+pub fn kinds_for(step: &str) -> &'static [&'static str] {+    match step {+        "intake" | "scale" => &["photos"],+        "parts" | "copper" | "snapshot" => &["pcb"],+        "blocks" => &["sch"],+        _ => &["pcb", "sch"],+    }+}++pub fn disabled() -> bool { std::env::var("ADOM_AIFLOW_REVERSE_VIEWS").map(|v| v == "off").unwrap_or(false) }++fn sch_hash(sch: &Path, sheet_dir: &Path) -> String {+    // the root sheet and every sibling sheet: a child-sheet edit changes the schematic view too+    let mut files: Vec<PathBuf> = std::fs::read_dir(sheet_dir).map(|rd| rd.flatten().map(|e| e.path()).filter(|p| p.extension().map(|x| x == "kicad_sch").unwrap_or(false)).collect()).unwrap_or_default();+    if !files.contains(&sch.to_path_buf()) { files.push(sch.to_path_buf()); }+    files.sort();+    files.iter().map(|f| sha256(f)).collect::<Vec<_>>().join("")+}++fn previous(doc: &Value, kind: &str, hash: &str) -> Option<String> {+    doc["entries"].as_array()?.iter().rev().flat_map(|e| e["views"].as_array().cloned().unwrap_or_default())+        .find(|v| v["kind"] == json!(kind) && v["sha"] == json!(hash)).and_then(|v| v["file"].as_str().map(str::to_string))+}++fn embed(tool: &str, args: &[&str]) -> Result<(), String> {+    let o = Command::new(tool).args(args).output().map_err(|e| format!("{tool} is not runnable ({e}): adom-wiki pkg install adom/{tool}"))?;+    let out = String::from_utf8_lossy(&o.stdout).to_string() + &String::from_utf8_lossy(&o.stderr);+    if o.status.success() && out.contains("OK") { Ok(()) } else { Err(format!("{tool} embed failed: {}", out.trim().chars().take(300).collect::<String>())) }+}++/// Record the views for `step`. `pcb_override` shows a specific board (the snapshot's copy).+/// Returns the hints to print (what was recorded, or why a view could not be built).+pub fn record(flow: &Path, state: &Value, step: &str, reason: &str, thread: &str, pcb_override: Option<&Path>) -> Vec<String> {+    if disabled() { return vec![]; }+    let path = flow.join("views.json");+    let mut doc = crate::items::read_json(&path).unwrap_or(json!({"entries": []}));+    let n = doc["entries"].as_array().map(|a| a.len()).unwrap_or(0) + 1;+    std::fs::create_dir_all(flow.join("views")).ok();+    let kicad = &state["kicad"];+    let pcb = pcb_override.map(Path::to_path_buf).or_else(|| kicad["pcb"].as_str().map(PathBuf::from)).filter(|p| p.is_file());+    let sch = kicad["sch"].as_str().map(PathBuf::from).filter(|p| p.is_file());+    let mut views = vec![];+    let mut hints = vec![];+    for kind in kinds_for(step) {+        let (k, src) = match *kind {+            "pcb" if pcb.is_some() => ("pcb", pcb.clone()),+            "sch" if sch.is_some() => ("sch", sch.clone()),+            "pcb" | "sch" if pcb.is_some() => ("pcb", pcb.clone()),+            _ => ("photos", None),+        };+        if views.iter().any(|v: &Value| v["kind"] == json!(k)) { continue; }+        let Some(src) = src else { views.push(json!({"kind": "photos"})); continue };+        let hash = if k == "sch" { sch_hash(&src, src.parent().unwrap_or(Path::new("."))) } else { sha256(&src) };+        let file = match previous(&doc, k, &hash) {+            Some(f) if flow.join(&f).is_file() => f,+            _ => {+                let f = format!("views/{n:03}-{step}-{k}.html");+                let out = flow.join(&f);+                let (o, s) = (out.display().to_string(), src.display().to_string());+                let r = if k == "pcb" { embed("adom-2dboard", &["embed", "--file", &s, "--out", &o]) } else {+                    let dir = src.parent().unwrap_or(Path::new(".")).display().to_string();+                    embed("adom-schematic", &["embed", "--file", &s, "--sheet-dir", &dir, "--out", &o])+                };+                if let Err(e) = r { hints.push(format!("flow window: the {k} view was not built: {e}")); continue; }+                f+            }+        };+        views.push(json!({"kind": k, "file": file, "source": src.display().to_string(), "sha": hash}));+    }+    let entry = json!({"n": n, "at": ledger::now(), "step": step, "reason": reason, "aiThread": thread, "views": views});+    doc["entries"].as_array_mut().unwrap().push(entry);+    if let Err(e) = std::fs::write(&path, serde_json::to_string_pretty(&doc).unwrap() + "\n") { hints.push(format!("flow window: views.json not written: {e}")); return hints; }+    let shown: Vec<String> = views.iter().filter_map(|v| v["kind"].as_str().map(str::to_string)).collect();+    hints.insert(0, format!("flow window: view {n} recorded for {step} ({})", if shown.is_empty() { "nothing".into() } else { shown.join(" + ") }));+    if state["kicad"]["pcb"].is_null() && step != "intake" && step != "scale" {+        hints.push("No KiCad project registered yet, so the flow window shows the photos. Register it with `kicad --pcb <board> --sch <root sheet>` as soon as the model writes one.".into());+    }+    hints+}++#[cfg(test)]+mod tests {+    use super::*;++    #[test]+    fn stage_views() {+        assert_eq!(kinds_for("intake"), &["photos"]);+        assert_eq!(kinds_for("blocks"), &["sch"]);+        assert_eq!(kinds_for("connect"), &["pcb", "sch"]);+    }++    #[test]+    fn photos_without_a_project_and_history_numbering() {+        let d = std::env::temp_dir().join(format!("views-test-{}", std::process::id()));+        std::fs::create_dir_all(&d).unwrap();+        let st = json!({});+        let h = record(&d, &st, "parts", "check parts", "t", None);+        assert!(h[0].contains("view 1 recorded for parts (photos)"), "{h:?}");+        let h = record(&d, &st, "blocks", "check blocks", "t", None);+        assert!(h[0].contains("view 2"), "{h:?}");+        let doc = crate::items::read_json(&d.join("views.json")).unwrap();+        assert_eq!(doc["entries"].as_array().unwrap().len(), 2);+        std::fs::remove_dir_all(&d).ok();+    }+}
uninstall.shadded+7
@@ -0,0 +1,7 @@+#!/bin/bash+# adom-wiki package uninstall hook: remove the binary and the skills this app installed.+HERE="$(cd "$(dirname "$0")" && pwd)"+NAME="$(sed -n 's/^name = "\(.*\)"/\1/p' "$HERE/Cargo.toml" | head -1)"+rm -f "$HOME/.local/bin/$NAME"+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"