app
Adom Fields
Public Made by Adomby adom
Adom Fields: where the current and the heat go on your board.
← Commit history
the fields app's own source at 0.3.0, restoring what a mistaken push overwrote
20 files changed
+1608−343
.gitignore+2Cargo.lock+139−98Cargo.toml+9−29README.md+31−139docs/screenshots/heat-iso.pnginstall.sh+4−10page.json+38−64src/board.rs+116src/fields.rs+306src/main.rs+120src/raster.rs+89src/serve.rs+154src/sexp.rs+39src/solve.rs+69src/tex.rs+53src/ui/app.js+226src/ui/index.html+120src/vendor.rs+66tools/fetch-viewer.sh+26uninstall.sh+1−3.gitignoreadded+2@@ -0,0 +1,2 @@+target/+vendor/3d-viewer/
Cargo.lock+139−98@@ -3,101 +3,20 @@ version = 4 [[package]]-name = "adom-aiflow"-version = "0.1.25"-dependencies = [- "aiflow-analyze",- "aiflow-board",- "aiflow-bridge",- "aiflow-copper",- "aiflow-grid",- "aiflow-place",- "aiflow-pours",- "aiflow-router",- "aiflow-run",- "clap",- "serde",- "serde_json",-]--[[package]]-name = "aiflow-analyze"-version = "0.1.25"-dependencies = [- "serde",- "serde_json",-]--[[package]]-name = "aiflow-board"-version = "0.1.25"-dependencies = [- "serde",- "serde_json",-]--[[package]]-name = "aiflow-bridge"-version = "0.1.25"-dependencies = [- "serde",- "serde_json",-]--[[package]]-name = "aiflow-copper"-version = "0.1.25"-dependencies = [- "aiflow-board",- "aiflow-grid",- "serde",- "serde_json",-]--[[package]]-name = "aiflow-grid"-version = "0.1.25"-dependencies = [- "aiflow-board",- "serde",- "serde_json",-]--[[package]]-name = "aiflow-place"-version = "0.1.25"-dependencies = [- "aiflow-board",- "serde",- "serde_json",-]--[[package]]-name = "aiflow-pours"-version = "0.1.25"-dependencies = [- "aiflow-board",- "aiflow-copper",- "serde",- "serde_json",-]--[[package]]-name = "aiflow-router"-version = "0.1.25"-dependencies = [- "aiflow-board",- "aiflow-grid",- "serde",- "serde_json",-]+name = "adler2"+version = "2.0.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]]-name = "aiflow-run"-version = "0.1.25"+name = "adom-fields"+version = "0.3.0" dependencies = [+ "clap",+ "png", "serde", "serde_json",+ "tiny_http", ] [[package]]@@ -150,11 +69,35 @@ dependencies = [ "windows-sys", ] +[[package]]+name = "ascii"+version = "1.1.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16"++[[package]]+name = "bitflags"+version = "1.3.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"++[[package]]+name = "cfg-if"+version = "1.0.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"++[[package]]+name = "chunked_transfer"+version = "1.5.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901"+ [[package]] name = "clap"-version = "4.6.6"+version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca"+checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" dependencies = [ "clap_builder", "clap_derive",@@ -162,9 +105,9 @@ dependencies = [ [[package]] name = "clap_builder"-version = "4.6.6"+version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889"+checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" dependencies = [ "anstream", "anstyle",@@ -174,9 +117,9 @@ dependencies = [ [[package]] name = "clap_derive"-version = "4.6.4"+version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061"+checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" dependencies = [ "heck", "proc-macro2",@@ -186,9 +129,9 @@ dependencies = [ [[package]] name = "clap_lex"-version = "1.1.0"+version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"+checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" [[package]] name = "colorchoice"@@ -196,12 +139,47 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]]+name = "crc32fast"+version = "1.5.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78"+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 = "httpdate"+version = "1.0.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"+ [[package]] name = "is_terminal_polyfill" version = "1.70.2"@@ -214,18 +192,57 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]]+name = "log"+version = "0.4.34"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"+ [[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 = "once_cell_polyfill" version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]]+name = "png"+version = "0.17.16"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526"+dependencies = [+ "bitflags",+ "crc32fast",+ "fdeflate",+ "flate2",+ "miniz_oxide 0.8.9",+]+ [[package]] name = "proc-macro2" version = "1.0.107"@@ -287,6 +304,12 @@ dependencies = [ "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"@@ -304,6 +327,18 @@ dependencies = [ "unicode-ident", ] +[[package]]+name = "tiny_http"+version = "0.12.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82"+dependencies = [+ "ascii",+ "chunked_transfer",+ "httpdate",+ "log",+]+ [[package]] name = "unicode-ident" version = "1.0.24"@@ -331,6 +366,12 @@ 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"
Cargo.toml+9−29@@ -1,37 +1,17 @@-[workspace]-resolver = "2"-members = [- "crates/aiflow-board",- "crates/aiflow-grid",- "crates/aiflow-router",- "crates/aiflow-copper",- "crates/aiflow-place",- "crates/aiflow-pours",- "crates/aiflow-analyze",- "crates/aiflow-run",- "crates/aiflow-bridge",- "crates/adom-aiflow",-]--[workspace.package]-version = "0.1.25"+[package]+name = "adom-fields"+version = "0.3.0" edition = "2021" license = "MIT"-repository = "https://wiki.adom.inc/adom/adom-aiflow"+description = "Adom Fields: where the current and the heat go on your board. Current density, copper heat flow and board temperature from the board file itself, in 3D, for the AI to drive and the human to explore."+repository = "https://wiki.adom.inc/adom/adom-fields" -[workspace.dependencies]+[dependencies]+clap = { version = "4", features = ["derive"] } serde = { version = "1", features = ["derive"] } serde_json = "1"-clap = { version = "4", features = ["derive"] }-aiflow-board = { path = "crates/aiflow-board" }-aiflow-grid = { path = "crates/aiflow-grid" }-aiflow-router = { path = "crates/aiflow-router" }-aiflow-copper = { path = "crates/aiflow-copper" }-aiflow-place = { path = "crates/aiflow-place" }-aiflow-pours = { path = "crates/aiflow-pours" }-aiflow-analyze = { path = "crates/aiflow-analyze" }-aiflow-run = { path = "crates/aiflow-run" }-aiflow-bridge = { path = "crates/aiflow-bridge" }+tiny_http = "0.12"+png = "0.17" [profile.release] opt-level = 3
README.md+31−139@@ -1,161 +1,53 @@-# AI Flow+# Adom Fields -**Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board.** The AI thinks its way from parts placement through routing, copper pours, current and thermal analysis to a delivered video. The binary does the fast, deterministic parts of every step, hands back hints written for an AI, and keeps a ledger of every turn, every return to an earlier step, and the AI's time from your prompt to "done" (sessions, one per prompt, the idle cut out; docs/time.md). One flow, one clock, one file, so Claude, Codex and any other engine are compared on the same thing.+**Adom Fields: where the current and the heat go on your board.** Solved from the board file itself, painted on the board in 3D, ordered by what matters, with the fix next to every issue. The AI does the analysis and drives the view; the human orbits, zooms and hovers for the number. -```-adom-wiki pkg install adom/adom-aiflow-adom-aiflow --version-```--> Screenshots and video clips on this page marked **after the first run** are placeholders until the first measured run under Fable 5.1 lands. The comparison numbers are examples of what the ledger produces, not results.--## The final video--The evidence of a run is its video, two minutes at most: only the moments something moved, every step in order, the AI's own current density and temperature rise drawings fullscreen, the run and step clocks in the corner, the words cut to the picture. Every run's page carries its own; the pair is the piece people share.--**The first pair: Claude Fable 5.1 versus Codex (Astra) on the ESC G431.** Same board, same spec, same prompt, same flow, measured the same way. [The comparison page](docs/comparisons/board-claude-fable-5-1-20260914-vs-source-unplaced-codex-astra-20260914-0314/README.md) leads with the side-by-side video and links both runs in full.--<video width="100%" controls><source src="/blob/app/adom-aiflow/docs/comparisons/board-claude-fable-5-1-20260914-vs-source-unplaced-codex-astra-20260914-0314/comparison-20260915224717.mp4" type="video/mp4"></video>--*Fable delivered in 53 min of AI time with 2 returns to earlier steps; Astra in 78 min with 14. The runs: [Fable](docs/runs/board-claude-fable-5-1-20260914/README.md), [Astra](docs/runs/source-unplaced-codex-astra-20260914-0314/README.md).*--## The comparison: what two runs look like side by side--Everything below comes from the two runs' ledgers and nothing else. **These are example numbers**; the first measured runs replace them.--### The number: prompt to "done, here is your video"--| Engine | Prompt to delivered | Finish at | Returns | Turns | Cost at API rates | Cost on a 20x plan |-|---|---|---|---|---|---|---|-| Claude Fable 5.1 | **93 min** | 71 min | 2 | 41 | $6.10 | $0.31 |-| Codex (Astra) | **146 min** | 118 min | 4 | 63 | $9.40 | $0.47 |--Commercial users on API plans pay the API figure; a 20x subscription plan pays a twentieth. Both lines, always.--### Per step: the AI's thinking versus the binary's time (minutes)+ -| Step | Fable think | Fable tool | Fable wall | Codex think | Codex tool | Codex wall | Returns F / C |-|---|---|---|---|---|---|---|---|-| intake | 6 | 0 | 6 | 9 | 0 | 9 | 0 / 0 |-| placement | 18 | 2 | 20 | 30 | 3 | 33 | 1 / 2 |-| routing | 6 | 12 | 18 | 20 | 14 | 34 | 0 / 1 |-| pours | 8 | 6 | 14 | 12 | 6 | 18 | 1 / 1 |-| current | 3 | 0 | 3 | 4 | 0 | 4 | 0 / 0 |-| thermal | 5 | 0 | 5 | 10 | 0 | 10 | 0 / 0 |-| capture | 1 | 3 | 4 | 2 | 3 | 5 | 0 / 0 |-| finish and video | 18 | 5 | 23 | 24 | 9 | 33 | 0 / 0 |-| **total** | **65** | **28** | **93** | **111** | **35** | **146** | 2 / 4 |+*The ESC G431's top copper with the current density of every loaded net at 20 A. The yellow arcs are the necks: 20 A through 1.5 mm of 35 µm copper.* -Thinking is the gap before each command, charged to the step the AI declared. Tool is the binary's own seconds. The sum over steps is the whole run.+## What it solves -+- **Current density** per loaded net: a DC conduction solve on the filled copper of every layer, between the net's terminals (the wire pads and connectors on one side, the parts that draw the current on the other, each part's share by its pad area), through the via barrels. Amps per square millimetre on every cell, the peak and where it is.+- **Heat flow in the copper**: four layers, each conducting in its plane, coupled through the dielectric and through every via barrel, losing to the air on both faces. Heat comes from the hot tabs in the spec and from the Joule loss of the current above. Read as watts per millimetre of width on each layer, with each chip's split: along its layer, down its vias, through the bare dielectric.+- **Temperature over the board**: the same solve read as the rise above ambient, in still air (10 W/m²K) and in moving air (25 W/m²K).+- **Issues, with the fix**: a net over 60 A/mm² (about what IPC-2221 allows at 35 µm for 10 °C), a tab over its rise budget, a chip with fewer thermal vias than the spec asks. -### The rework map: where each engine went back, and why+ -| Engine | Return | Why, from the ledger | Cost |-|---|---|---|---|-| Fable | thermal to placement | Q4's tab had no room for a B.Cu spreader under the gate driver's routing; moved TP12 and R28 | 6 min |-| Fable | thermal to pours | re-poured phase B after the move | 5 min |-| Codex | routing to placement | router closed with 3 unconnected in the escape band; moved TP4 and LED1 | 8 min |-| Codex | placement to routing | re-routed after the move | 9 min |-| Codex | current to pours | +VBAT stitch vias landed outside the pour: 2 unconnected | 7 min |-| Codex | thermal to pours | Q2 and Q6 tabs short of copper | 7 min |+*Heat flow in the top copper. Q3 runs hottest: it has no thermal via within 3 mm, so its 0.6 W has only the top copper to spread on.* -### The board that came out+ -| Outcome | Fable | Codex |-|---|---|---|-| unrouted at finish | 0 | 0 |-| new DRC errors (13 inherited counted apart) | 0 | 0 |-| copper kept, F.Cu / B.Cu | 66 % / 74 % | 61 % / 70 % |-| pours landed / refused first | 31 / 3 | 27 / 5 |-| vias (thermal and stitch) | 13 | 9 |-| current: loaded nets passing | 7 / 7 | 7 / 7 |-| thermal: worst tab rise at spec watts | 42 C (Q4) | 55 C (Q6) |-| Kelvin taps kept clean | yes | yes |+*The temperature over the board, the FR4 spreading, still air.* -Both must reach 0 / 0 or the run is not a result. The rest is where the engineering judgement shows: copper kept for ablation, tab temperatures, how many refusals it took.+## The app -### What it cost+`adom-fields serve` puts the board in 3D on the canonical Adom 3D viewer (adom/adom-3d-viewer, vendored and served by the binary: its camera, ViewCube, studio lighting, framing and Z-up frame, as 3d-viewer-design asks): the picked field on the picked layer, the other layers faded, the vias as dots, the hot parts labelled, the legend, the number under the cursor. On the left the nets by what they carry and the hot parts hottest first; on the right the issues and the method. Every click is a command: the state lives on the server and goes out over a live channel, so `adom-fields ui view --quantity heat --layer B.Cu` does exactly what the button does, and `adom-fields tour` walks the whole board for the AI Flow video. -| Measure | Fable | Codex |-|---|---|---|-| turns (commands run) | 41 | 63 |-| thinking, total | 61 min | 104 min |-| tool, total | 32 min | 42 min |-| longest single think | 11 min (placement) | 19 min (placement) |-| steps the AI took itself | placement | placement, routing |-| tokens in / out | 1.9 M / 140 k | 3.1 M / 210 k |-| cost at API rates | $6.10 | $9.40 |-| cost on a 20x plan | $0.31 | $0.47 |+ -Token and dollar figures are self-reported by each engine into run.json; everything else is stamped by the binary.+*Click an issue and the camera flies to it: 20 A of +VBAT through a 1.5 mm neck on the top copper.* -+## Run it -## The ESC G431, the first board--The proving run (the binary on a placement made earlier, not a measured run of the AI) ended with the ESC routed to 100 percent, 31 pours landed, both analyses passed:----## What the binary is made of--One Rust binary, a crate per module: `aiflow-board` (the KiCad board model), `aiflow-grid`, `aiflow-router` (0.1 mm grid A*, escapes, plane stubs, per-net vias, rip-up, Kelvin taps), `aiflow-copper` (plans to copper, KiCad's DRC offline), `aiflow-place` (courtyards, packing, moves), `aiflow-pours` (zones, keepouts, thermal and stitching vias from the spec), `aiflow-analyze` (IPC-2221, tab copper, rise budgets), `aiflow-run` (the ledger and the manifest), `aiflow-bridge` (the live half through the Adom KiCad Bridge: landing, recording, measurement, validation), and `adom-aiflow`, the command.--KiCad Bridge today. Next backends: the Altium bridge, the Fusion bridge, then Adom's own web apps (adom-schematic, adom-2dboard, adom-3dboard). The board model and the bridge crate are the only ones that know KiCad; the flow, the spec, the ledger and the analyses do not change when the backend does.---## Roadmap--Everything below is what John has asked for, in the order it is likely to land. 0.1 is what this page describes; nothing here is built until its version says so.--**Steps in front of placement (the flow grows at the front)**-- **components**: choose the parts from the requirements; a SPICE result or a thermal result can send the AI back here.-- **libraries**: build the symbols, footprints and 3D chips for every part (with adom-symbol, adom-footprint, chip-thumbnailer).-- **schematic**: draw it, and derive the spec from it, so the spec stops being hand-written.-- **simulation**: SPICE on the critical loops before the board exists (the KiCad Bridge's SPICE verbs, #89), with the loop back to components when a part choice is wrong.--**Steps after the board (the flow grows at the back)**-- **moleculize**: add the machine pins so the board can live in the probing workcell as a molecule.-- **paste**: solder paste jetting calculations and analysis.-- **probe**: work out how to probe the board when it comes off the InstaPCB process.--**Analyses (0.2)**-- **current 0.2**: cross-sections through the filled copper instead of the narrowest-track heuristic; a current-density heat map per net.-- **thermal 0.2**: copper area weighted by distance from the tab, via conduction, the other layer's contribution; a heat map per hot part.-- **impedance**: controlled-impedance and return-path checks on the nets the spec marks.-- **ablation**: the copper-kept metric per layer as a mill-time estimate for the InstaPCB process, so "least copper to ablate" is a number the AI can push.--**The video (0.2)**-- The step's own code frames the shot: zoom to fit before a landing, the part on screen when a move lands.-- Done in 0.1.14: `compose` cuts the final video from the motion of every step visit and the AI's drawings, two minutes at most, the picture leading and the adom-tts words cut to it. Left for 0.2: the AI writing its own words per segment, gang-takes for the charts.-- The split screen for two engines, and the grid for five (Fable, Astra, Antigravity, Grok, Kimi), cut from the same step tags.-- A clip for each step on this page, and the final video at the top of it.+```+adom-fields analyze --board esc.kicad_pcb --spec spec.json --out fields # about two minutes+adom-fields serve --fields fields --port 8874 & # https://<slug>.adom.cloud/proxy/8874/+adom-fields ui view --quantity current --net +VBAT --camera top # drive it+adom-fields tour # the walkthrough, for a clip+``` -**Backends**-- The Altium bridge and the Fusion bridge, behind the same commands.-- Adom's own web apps: adom-schematic, adom-2dboard, adom-3dboard, so users can stop relying on the legacy tools; the board model and the bridge crate are the only crates that change.+The spec is Adom AI Flow's: `loads` (net: amps, maxRiseC), `hot` (ref: watts, tabNet), `copperUm`, `hotMaxRiseC`, `thermalVias`, optional `stackupMm`. The board must be saved with its zones filled. -**The binary and the bridge**-- A batched landing verb in the KiCad Bridge (one DRC preflight for a whole plan) to bring a clean pass from 18 minutes to about 4.-- An ungated free-disk-space verb in ab (adom-bridge#196) so the recording budget can see the test box.-- Every step the AI replaces with its own better code comes back as a crate, filed on this page.+## In the AI Flow -**Measurement**-- Token and dollar accounting written by the binary from the engine's own usage where an engine exposes it, instead of self-reported.-- The comparison page generated from the ledgers by the binary itself (`adom-aiflow compare run1 run2 ...`), so nobody assembles a chart by hand.+Adom Fields is the analysis step of [Adom's AI Flow](https://wiki.adom.inc/adom/adom-aiflow): after the pours land, the AI solves the fields, reads the issues, and walks the board on camera with `adom-fields tour` while the flow records the window. The clip goes into the run's video; the numbers go on the run page. -## Skills in this package+## Honest limits, 0.1 -- `adom-aiflow`: the flow, the commands, the spec, the honesty rules.-- `aiflow-comparison-video`: how to make the comparison video and page for two runs: pull the other run, `compose --with` and its narration script (the words lead, no dead air, who won each step, how the clock works), `compare --push`.-- `aiflow-measurement`: how a run is measured, in John's words: the clock from the prompt to "done, here is your video", the AI's thinking per step, the rework loops, the append-only run.jsonl, the cost both ways.+DC only; one copper thickness for all layers; a flat convection coefficient, no heatsink, no enclosure, no radiation; the tab watts come from the spec. A screen that finds the necks and the starved tabs and shows them, not a datasheet number. Next: heatsinks as conducting blocks, pour capacitance, differential pair and impedance checks (the signals tab), and an Altium and Fusion reader. -## Honesty rules+## Give it back -- The clock starts at the prompt and stops at `deliver`. A late `start` takes `--prompt-time`.-- A stage the AI takes itself is stamped by the AI and named `ai`; the binary's are `binary`. Both go on the chart.-- A run without `deliver` is not a result, and its minutes are still counting.-- A run assembled from earlier work with a back-dated prompt is a proving run of the tool, not a measured run of the AI. Say so.-- 0.1's analyses are conservative heuristics (IPC-2221 for tracks; presence, connection style, area and vias for pours and tabs) and say so in their output. 0.2 computes cross-sections through the filled copper.+Anything wrong, rigid or missing: an issue or a PR on this page. Adom processes PRs within minutes during business hours.
docs/screenshots/heat-iso.png⋯ 1 unchanged line ⋯
install.sh+4−10@@ -1,13 +1,7 @@ #!/bin/bash-# adompkg install hook for adom-aiflow: the prebuilt binary to ~/.local/bin and the skill to ~/.claude/skills. set -e HERE="$(cd "$(dirname "$0")" && pwd)"-mkdir -p "$HOME/.local/bin" "$HOME/.claude/skills/adom-aiflow" "$HOME/.claude/skills/aiflow-measurement" "$HOME/.claude/skills/aiflow-comparison-video"-cp "$HERE/bin/adom-aiflow" "$HOME/.local/bin/adom-aiflow"-chmod +x "$HOME/.local/bin/adom-aiflow"-cp "$HERE/skills/adom-aiflow/SKILL.md" "$HOME/.claude/skills/adom-aiflow/SKILL.md"-cp "$HERE/skills/aiflow-measurement/SKILL.md" "$HOME/.claude/skills/aiflow-measurement/SKILL.md"-cp "$HERE/skills/aiflow-comparison-video/SKILL.md" "$HOME/.claude/skills/aiflow-comparison-video/SKILL.md"-command -v service-kicad >/dev/null 2>&1 || echo "Hint: the offline gate needs service-kicad (adom-wiki pkg install adom/service-kicad)"-command -v adom-bridge >/dev/null 2>&1 || echo "Hint: the live stages need adom-bridge (the Adom Bridge CLI) and the KiCad Bridge on a desktop"-echo "OK: adom-aiflow installed at ~/.local/bin/adom-aiflow. Run 'adom-aiflow --version'."+mkdir -p "$HOME/.local/bin" "$HOME/.claude/skills/adom-fields"+cp "$HERE/bin/adom-fields" "$HOME/.local/bin/adom-fields" && chmod +x "$HOME/.local/bin/adom-fields"+cp "$HERE/skills/adom-fields/SKILL.md" "$HOME/.claude/skills/adom-fields/SKILL.md"+echo "OK: adom-fields installed at ~/.local/bin/adom-fields. Run 'adom-fields --version'."
page.json+38−64@@ -1,83 +1,57 @@ {- "slug": "adom-aiflow",+ "slug": "adom-fields", "type": "app",- "version": "0.1.25",- "title": "AI Flow",- "description": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. One Rust binary with a crate per step (placement helpers, a grid router with Kelvin taps, pours with keepouts, KiCad's DRC gate, live landing through the KiCad Bridge, copper measurement, current and thermal analysis) and a finish line that refuses an unfinished board. Every command answers with hints for the AI; every turn, its thinking time and every rework loop go into run.jsonl, so Claude, Codex and any other engine are compared on the same flow. KiCad today; Altium, Fusion and Adom's own web apps next.",- "summary": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. The AI thinks its way from placement through routing, pours, current and thermal analysis to a delivered video; the binary does the fast, deterministic parts of every step, hands back hints, and keeps a ledger of every turn, every return to an earlier step, and the clock from the prompt to done.",- "brief": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board.",+ "version": "0.3.0",+ "title": "Adom Fields",+ "brief": "Adom Fields: where the current and the heat go on your board.",+ "summary": "Current density on the copper, heat flow through the pours and the vias, and the temperature over the board, solved from the board file itself and shown on the board in 3D. The AI does the analysis and drives the view; the human orbits, zooms, hovers for the number under the cursor, and reads the nets by what they carry and the issues by what to fix. A step in Adom's AI Flow: the walkthrough goes into the video.",+ "description": "Adom Fields reads a KiCad board (its filled polygons, tracks, pads and vias) and a spec (which nets carry what current, which parts dissipate what) and solves three fields on it: the DC current density of every loaded net between its terminals; the heat flow in each copper layer, coupled through the dielectric and every via barrel, from the hot tabs and from the Joule loss of that current; and the temperature rise over the whole board in still and in moving air. Then it serves the board in 3D: the fields painted on each layer, the vias drawn, the hot parts labelled, the nets ordered by what they carry, the issues (a neck where the current squeezes, a tab over its rise budget, a chip missing its thermal vias) with the fix, and the number under the cursor. Everything the UI does a command does, over a live channel, so the AI shows the board off on camera.", "category": "Developer Tools", "tags": [ "kicad", "pcb",- "eda",- "routing",- "placement",- "copper pours",- "ablation", "current density", "thermal",- "kelvin",- "comparison"+ "heat",+ "copper pours",+ "vias",+ "3d",+ "analysis",+ "eda" ], "status": "live",- "discovery_pitch": "Take a KiCad board from parked parts to a routed, poured, analysed board through one command that keeps the clock, records who did each stage, and refuses to finish early. Both Claude and Codex run the same flow, so their numbers compare.",+ "discovery_pitch": "Solve where the current and the heat go on a KiCad board and show it on the board in 3D: current density per net, heat flow per layer with the vias, temperature over the board, the nets by what they carry, the issues with the fix. One command to solve, one to serve, one to drive it on camera.", "discovery_triggers": [- "100% routed",- "ablation copper",- "adom-aiflow",- "ai flow",- "aiflow",- "board flow",- "board loop",- "compare engines on a board",- "copper pours",+ "adom-fields", "current density",- "fable vs codex board",- "finish the board",- "flow for making a board",- "from placement to a probed board",- "kelvin keepout",- "measure the ai per step",- "place the parts",- "route the board",- "run.jsonl",- "thermal analysis"+ "heat map of a board",+ "thermal analysis of a pcb",+ "copper pour analysis",+ "thermal vias",+ "where does the current go",+ "hot mosfet tab",+ "temperature rise of a board",+ "IR drop",+ "current neck",+ "heat flow in copper",+ "fields" ],- "dependencies": {},+ "sample_prompts": [+ "solve the fields of this board and show me where the 20 A goes",+ "which mosfet tab runs hottest and why",+ "walk me through the copper pours with the heat on them",+ "is any track carrying too much current"+ ],+ "hero": {+ "path": "docs/hero.png"+ },+ "bundled": true, "scripts": { "install": "./install.sh", "uninstall": "./uninstall.sh" },- "visibility": "public", "install": {- "binary_name": "adom-aiflow"- },- "hero": {- "type": "image",- "path": "screenshots/hero.png"+ "binary": "bin/adom-fields" },- "bundled": true,- "sample_prompts": [- {- "label": "Run the flow on a board",- "prompt": "aiflow the ESC: place, route, pour, analyze and deliver the video"- },- {- "label": "Route to 100 percent",- "prompt": "route this board to 100 percent and refuse to finish with anything unconnected"- },- {- "label": "Pours and thermal",- "prompt": "pour the copper, keep the Kelvin taps clean, and tell me if any FET tab is too hot"- },- {- "label": "Measure the AI per step",- "prompt": "how long did the AI think at each step of the board flow, and where did it go back"- },- {- "label": "Compare engines",- "prompt": "run the same board flow on Fable and Codex and show me the comparison"- }- ]-}+ "dependencies": {}+}
src/board.rsadded+116@@ -0,0 +1,116 @@+//! The board as the solver needs it: outline, filled copper per net and layer, tracks, pads, vias.+use crate::sexp::{parse, Sx};++pub const LAYERS: [&str; 4] = ["F.Cu", "In1.Cu", "In2.Cu", "B.Cu"];++#[derive(Debug, Clone)]+pub struct Poly { pub net: String, pub layer: String, pub pts: Vec<(f64, f64)> }+#[derive(Debug, Clone)]+pub struct Track { pub net: String, pub layer: String, pub a: (f64, f64), pub b: (f64, f64), pub w: f64 }+#[derive(Debug, Clone)]+pub struct Via { pub net: String, pub x: f64, pub y: f64, pub size: f64, pub drill: f64 }+#[derive(Debug, Clone)]+pub struct Pad { pub reference: String, pub pad: String, pub net: String, pub x: f64, pub y: f64, pub layers: Vec<String>, pub area: f64 }++#[derive(Debug, Default, Clone)]+pub struct Board {+ pub x0: f64, pub y0: f64, pub x1: f64, pub y1: f64,+ pub polys: Vec<Poly>, pub tracks: Vec<Track>, pub vias: Vec<Via>, pub pads: Vec<Pad>,+ /// pad copper shapes as polygons (already in board coordinates)+ pub pad_polys: Vec<Poly>,+ pub layer_names: Vec<String>,+}++fn rot(x: f64, y: f64, deg: f64) -> (f64, f64) { let a = deg.to_radians(); (x * a.cos() - y * a.sin(), x * a.sin() + y * a.cos()) }++impl Board {+ pub fn load(path: &str) -> Result<Board, String> {+ let text = std::fs::read_to_string(path).map_err(|e| format!("{path}: {e}"))?;+ let root = parse(&text);+ let mut b = Board::default();+ b.layer_names = root.kid("layers").map(|l| l.list().iter().skip(1).filter_map(|x| x.atom(1).map(str::to_string)).collect()).unwrap_or_default();+ // outline: the Edge.Cuts graphics' bounding box+ let (mut xs, mut ys) = (Vec::new(), Vec::new());+ for key in ["gr_line", "gr_rect", "gr_arc", "gr_poly", "gr_circle"] {+ for g in root.kids(key) {+ if g.kid("layer").and_then(|l| l.atom(1)) != Some("Edge.Cuts") { continue; }+ for k in ["start", "end", "mid", "center"] { if let Some(p) = g.kid(k) { if let (Some(x), Some(y)) = (p.num(1), p.num(2)) { xs.push(x); ys.push(y); } } }+ if let Some(pts) = g.kid("pts") { for xy in pts.kids("xy") { if let (Some(x), Some(y)) = (xy.num(1), xy.num(2)) { xs.push(x); ys.push(y); } } }+ }+ }+ if xs.is_empty() { return Err("no Edge.Cuts outline on the board".into()); }+ b.x0 = xs.iter().cloned().fold(f64::MAX, f64::min); b.x1 = xs.iter().cloned().fold(f64::MIN, f64::max);+ b.y0 = ys.iter().cloned().fold(f64::MAX, f64::min); b.y1 = ys.iter().cloned().fold(f64::MIN, f64::max);+ for z in root.kids("zone") {+ let net = z.kid("net_name").and_then(|n| n.atom(1)).map(str::to_string).or_else(|| z.kid("net").and_then(|n| n.last_atom()).map(str::to_string)).unwrap_or_default();+ for fp in z.kids("filled_polygon") {+ let layer = fp.kid("layer").and_then(|l| l.atom(1)).unwrap_or("").to_string();+ let pts: Vec<(f64, f64)> = fp.kid("pts").map(|p| p.kids("xy").iter().filter_map(|xy| Some((xy.num(1)?, xy.num(2)?))).collect()).unwrap_or_default();+ if pts.len() > 2 { b.polys.push(Poly { net: net.clone(), layer, pts }); }+ }+ }+ for s in root.kids("segment") {+ let (Some(st), Some(en)) = (s.kid("start"), s.kid("end")) else { continue };+ b.tracks.push(Track { net: s.kid("net").and_then(|n| n.last_atom()).unwrap_or("").to_string(), layer: s.kid("layer").and_then(|l| l.atom(1)).unwrap_or("").to_string(), a: (st.num(1).unwrap_or(0.0), st.num(2).unwrap_or(0.0)), b: (en.num(1).unwrap_or(0.0), en.num(2).unwrap_or(0.0)), w: s.kid("width").and_then(|w| w.num(1)).unwrap_or(0.2) });+ }+ for v in root.kids("via") {+ let Some(at) = v.kid("at") else { continue };+ b.vias.push(Via { net: v.kid("net").and_then(|n| n.last_atom()).unwrap_or("").to_string(), x: at.num(1).unwrap_or(0.0), y: at.num(2).unwrap_or(0.0), size: v.kid("size").and_then(|s| s.num(1)).unwrap_or(0.6), drill: v.kid("drill").and_then(|d| d.num(1)).unwrap_or(0.3) });+ }+ for fp in root.kids("footprint") {+ let reference = fp.kids("property").iter().find(|p| p.atom(1) == Some("Reference")).and_then(|p| p.atom(2)).unwrap_or("?").to_string();+ let at = fp.kid("at");+ let (fx, fy, fa) = (at.and_then(|a| a.num(1)).unwrap_or(0.0), at.and_then(|a| a.num(2)).unwrap_or(0.0), at.and_then(|a| a.num(3)).unwrap_or(0.0));+ for p in fp.kids("pad") {+ let Some(net) = p.kid("net").and_then(|n| n.last_atom()).map(str::to_string) else { continue };+ let pat = p.kid("at");+ let (dx, dy) = (pat.and_then(|a| a.num(1)).unwrap_or(0.0), pat.and_then(|a| a.num(2)).unwrap_or(0.0));+ let pa = pat.and_then(|a| a.num(3)).unwrap_or(fa);+ // KiCad rotates counter-clockwise in a y-down frame+ let (rx, ry) = rot(dx, -dy, fa);+ let (x, y) = (fx + rx, fy - ry);+ let (sw, sh) = (p.kid("size").and_then(|s| s.num(1)).unwrap_or(1.0), p.kid("size").and_then(|s| s.num(2)).unwrap_or(1.0));+ let kind = p.atom(2).unwrap_or("smd");+ let shape = p.atom(3).unwrap_or("rect");+ let lay: Vec<String> = p.kid("layers").map(|l| l.list().iter().skip(1).filter_map(|x| if let Sx::Atom(a) = x { Some(a.clone()) } else { None }).collect()).unwrap_or_default();+ let on: Vec<String> = if kind == "thru_hole" { LAYERS.iter().map(|s| s.to_string()).collect() } else { LAYERS.iter().filter(|l| lay.iter().any(|x| x == *l || x == "*.Cu")).map(|s| s.to_string()).collect() };+ let mut area = sw * sh;+ for l in &on {+ let mut shapes: Vec<Vec<(f64, f64)>> = Vec::new();+ if shape == "custom" {+ if let Some(prims) = p.kid("primitives") {+ for gp in prims.kids("gr_poly") {+ let pts: Vec<(f64, f64)> = gp.kid("pts").map(|q| q.kids("xy").iter().filter_map(|xy| { let (qx, qy) = rot(xy.num(1)?, -xy.num(2)?, fa); Some((x + qx, y - qy)) }).collect()).unwrap_or_default();+ if pts.len() > 2 { area = area.max(poly_area(&pts)); shapes.push(pts); }+ }+ }+ shapes.push(circle_poly(x, y, sw.min(sh) / 2.0));+ } else if shape == "circle" {+ shapes.push(circle_poly(x, y, sw / 2.0));+ } else if shape == "oval" {+ shapes.push(stadium_poly(x, y, sw, sh, pa));+ } else {+ let corners = [(-sw / 2.0, -sh / 2.0), (sw / 2.0, -sh / 2.0), (sw / 2.0, sh / 2.0), (-sw / 2.0, sh / 2.0)];+ shapes.push(corners.iter().map(|(cx, cy)| { let (qx, qy) = rot(*cx, -*cy, pa); (x + qx, y - qy) }).collect());+ }+ for pts in shapes { b.pad_polys.push(Poly { net: net.clone(), layer: l.clone(), pts }); }+ }+ b.pads.push(Pad { reference: reference.clone(), pad: p.atom(1).map(str::to_string).or_else(|| p.num(1).map(|n| format!("{}", n as i64))).unwrap_or_default(), net, x, y, layers: on, area });+ }+ }+ Ok(b)+ }+ pub fn width(&self) -> f64 { self.x1 - self.x0 }+ pub fn height(&self) -> f64 { self.y1 - self.y0 }+}++fn poly_area(p: &[(f64, f64)]) -> f64 { let mut a = 0.0; for i in 0..p.len() { let j = (i + 1) % p.len(); a += p[i].0 * p[j].1 - p[j].0 * p[i].1; } (a / 2.0).abs() }+fn circle_poly(x: f64, y: f64, r: f64) -> Vec<(f64, f64)> { (0..24).map(|k| { let a = k as f64 / 24.0 * std::f64::consts::TAU; (x + r * a.cos(), y + r * a.sin()) }).collect() }+fn stadium_poly(x: f64, y: f64, w: f64, h: f64, deg: f64) -> Vec<(f64, f64)> {+ let (long, short) = (w.max(h), w.min(h)); let r = short / 2.0; let d = (long - short) / 2.0;+ let mut pts = Vec::new();+ for k in 0..=12 { let a = -std::f64::consts::FRAC_PI_2 + k as f64 / 12.0 * std::f64::consts::PI; pts.push((d + r * a.cos(), r * a.sin())); }+ for k in 0..=12 { let a = std::f64::consts::FRAC_PI_2 + k as f64 / 12.0 * std::f64::consts::PI; pts.push((-d + r * a.cos(), r * a.sin())); }+ let ang = if w >= h { deg } else { deg + 90.0 };+ pts.into_iter().map(|(px, py)| { let (qx, qy) = rot(px, -py, ang); (x + qx, y - qy) }).collect()+}
src/fields.rsadded+306@@ -0,0 +1,306 @@+//! The fields: current density per loaded net, heat flow in the copper, temperature over the board.+use crate::board::{Board, LAYERS};+use crate::raster::{Grid, Raster};+use crate::solve::{harm, System};+use serde::{Deserialize, Serialize};+use serde_json::{json, Value};+use std::collections::BTreeMap;++pub const SIGMA: f64 = 5.8e4; // S/mm, copper+pub const K_CU: f64 = 0.40; // W/(mm K)+pub const K_FR4_XY: f64 = 0.0006; // W/(mm K), in plane+pub const K_FR4_Z: f64 = 0.0003; // W/(mm K), through+pub const H_STILL: f64 = 1.0e-5; // W/(mm2 K): about 10 W/m2K, still air+pub const H_MOVING: f64 = 2.5e-5; // moving air++#[derive(Deserialize, Default, Clone)]+pub struct Spec {+ #[serde(default = "d35")] pub copperUm: f64,+ #[serde(default)] pub loads: BTreeMap<String, Load>,+ #[serde(default)] pub hot: BTreeMap<String, Hot>,+ #[serde(default = "d60")] pub hotMaxRiseC: f64,+ #[serde(default)] pub thermalVias: Vec<Value>,+ #[serde(default)] pub board: String,+ #[serde(default)] pub stackupMm: Vec<f64>,+}+fn d35() -> f64 { 35.0 }+fn d60() -> f64 { 60.0 }+#[derive(Deserialize, Clone)] pub struct Load { pub amps: f64, #[serde(default)] pub maxRiseC: Option<f64> }+#[derive(Deserialize, Clone)] pub struct Hot { pub watts: f64, pub tabNet: String }++#[derive(Serialize, Clone)]+pub struct NetResult { pub net: String, pub amps: f64, pub layers: Vec<String>, pub peakJ: f64, pub peakAt: [f64; 2], pub peakLayer: String, pub copperMm2: f64, pub jouleW: f64, pub vias: usize, pub iterations: usize }+#[derive(Serialize, Clone)]+pub struct ChipResult { pub reference: String, pub watts: f64, pub tabNet: String, pub x: f64, pub y: f64, pub riseC: f64, pub riseMovingC: f64, pub planeW: f64, pub viaW: f64, pub vias: usize, pub otherViaW: f64, pub otherVias: usize, pub dielW: f64, pub airW: f64, pub viasWanted: usize }+#[derive(Serialize, Clone)]+pub struct Issue { pub severity: String, pub kind: String, pub what: String, pub at: Option<[f64; 2]>, pub layer: Option<String>, pub net: Option<String>, pub reference: Option<String>, pub fix: String }++pub struct Fields {+ pub d: f64, pub h: usize, pub w: usize, pub x0: f64, pub y0: f64,+ pub frac: Vec<Grid>, // all copper per layer+ pub j_net: BTreeMap<String, Vec<Grid>>, // A/mm2 per net per layer+ pub cu_net: BTreeMap<String, Vec<Grid>>, // copper fraction per net per layer, for isolating a pour+ pub j_all: Vec<Grid>,+ pub q: Vec<Grid>, // W/mm per layer+ pub t: Vec<Grid>, // C rise per layer, still air+ pub t_moving: Vec<Grid>,+ pub nets: Vec<NetResult>, pub chips: Vec<ChipResult>, pub issues: Vec<Issue>,+ pub joule_total: f64, pub tab_total: f64,+}++pub fn analyze(b: &Board, spec: &Spec, pitch: f64, neck: f64, log: &mut dyn FnMut(&str)) -> Fields {+ let fine = pitch / 2.0;+ let mut ras = Raster::new(b, fine);+ ras.draw_board(b);+ let pool = 2;+ let (w, h) = (ras.w / pool, ras.h / pool);+ let d = pitch;+ let t_cu = spec.copperUm / 1000.0;+ let n = w * h;+ let cell = |x: f64, y: f64| -> Option<(usize, usize)> { let (c, r) = (((x - b.x0) / d) as isize, ((y - b.y0) / d) as isize); if r >= 0 && c >= 0 && (r as usize) < h && (c as usize) < w { Some((r as usize, c as usize)) } else { None } };+ // all copper per layer+ let mut frac: Vec<Grid> = LAYERS.iter().map(|_| Grid::new(w, h)).collect();+ for (k, l) in LAYERS.iter().enumerate() { for net in ras.nets_on(l) { let g = ras.fraction(&net, l, pool); frac[k].max_with(&g); } }+ // ---- current, one DC solve per loaded net --------------------------------------------------+ let mut j_net: BTreeMap<String, Vec<Grid>> = BTreeMap::new();+ let mut j_all: Vec<Grid> = LAYERS.iter().map(|_| Grid::new(w, h)).collect();+ let mut p_joule = Grid::new(w, h);+ let mut nets: Vec<NetResult> = Vec::new();+ for (net, load) in &spec.loads {+ let fr: Vec<Grid> = LAYERS.iter().map(|l| ras.fraction(net, l, pool)).collect();+ let live: Vec<usize> = (0..LAYERS.len()).filter(|k| fr[*k].sum() > 0.0).collect();+ if live.is_empty() { log(&format!("{net}: no copper on the board, skipped")); continue; }+ let nl = live.len();+ let mut sys = System::new(nl, h, w);+ for (a, k) in live.iter().enumerate() {+ let f = &fr[*k];+ for r in 0..h { for c in 0..w - 1 { sys.gx[a][r * (w - 1) + c] = SIGMA * t_cu * harm(f.at(r, c) as f64, f.at(r, c + 1) as f64); } }+ for r in 0..h - 1 { for c in 0..w { sys.gy[a][r * w + c] = SIGMA * t_cu * harm(f.at(r, c) as f64, f.at(r + 1, c) as f64); } }+ for i in 0..n { sys.leak[a][i] = if f.v[i] > 0.0 { 1e-9 } else { 1.0 }; }+ }+ let mut nvias = 0;+ for v in b.vias.iter().filter(|v| v.net == *net) {+ let Some((r, c)) = cell(v.x, v.y) else { continue };+ nvias += 1;+ let g = SIGMA * std::f64::consts::PI * ((v.drill / 2.0 + 0.025).powi(2) - (v.drill / 2.0).powi(2)) / 1.6;+ for a in 0..nl.saturating_sub(1) { sys.gz[a][r * w + c] += g; }+ }+ // terminals: the external pads (wire pads, connectors) on one side, the chips that draw the+ // current on the other, each chip's share by its pad area on the net (a gate driver's sense pin+ // takes almost nothing, a FET tab takes its part); the density does not care which way it flows+ let pads: Vec<&crate::board::Pad> = b.pads.iter().filter(|p| p.net == *net).collect();+ let is_ext = |p: &crate::board::Pad| p.reference.starts_with("MC") || p.reference.starts_with('J') || p.reference.starts_with('P');+ let is_chip = |p: &crate::board::Pad| p.reference.starts_with('Q') || p.reference.starts_with('U');+ let mut ext: Vec<&crate::board::Pad> = pads.iter().cloned().filter(|p| is_ext(p)).collect();+ // the parts that draw the current: the spec's hot parts when any sit on this net (a FET's+ // drain tab and its source pins both count), else the Q and U parts on the net+ let hot_pads: Vec<&crate::board::Pad> = pads.iter().cloned().filter(|p| spec.hot.contains_key(&p.reference)).collect();+ let mut chips: Vec<&crate::board::Pad> = if !hot_pads.is_empty() { hot_pads } else { pads.iter().cloned().filter(|p| is_chip(p)).collect() };+ if chips.is_empty() { chips = pads.iter().cloned().filter(|p| !is_ext(p)).collect(); }+ if ext.is_empty() {+ // no external pad (a regulator's output net): the part with the biggest pads on the net is+ // the source, the other Q and U parts (or whatever else is left) are the sinks+ let cands: Vec<&crate::board::Pad> = pads.iter().cloned().filter(|p| !p.reference.starts_with('C') && !p.reference.starts_with("TP")).collect();+ let mut by_ref: BTreeMap<String, f64> = BTreeMap::new();+ for p in &cands { *by_ref.entry(p.reference.clone()).or_default() += p.area; }+ if let Some((big, _)) = by_ref.iter().max_by(|a, bb| a.1.partial_cmp(bb.1).unwrap()) {+ let big = big.clone();+ ext = cands.iter().cloned().filter(|p| p.reference == big).collect();+ let rest: Vec<&crate::board::Pad> = cands.iter().cloned().filter(|p| p.reference != big && is_chip(p)).collect();+ chips = if rest.is_empty() { cands.iter().cloned().filter(|p| p.reference != big).collect() } else { rest };+ }+ }+ if ext.is_empty() || chips.is_empty() { log(&format!("{net}: no terminals found (needs pads of two parts on the net), skipped")); continue; }+ let mut area_by_ref: BTreeMap<String, f64> = BTreeMap::new();+ for p in &chips { *area_by_ref.entry(p.reference.clone()).or_default() += p.area.max(0.05); }+ let area_total: f64 = area_by_ref.values().sum();+ let mut rhs = vec![0.0; nl * n];+ let mut spots_of = |p: &crate::board::Pad| -> Vec<(usize, usize, usize)> {+ let Some((r0, c0)) = cell(p.x, p.y) else { return Vec::new() };+ let rad = (1.0 / d) as isize;+ let mut spots: Vec<(usize, usize, usize)> = Vec::new();+ for (a, k) in live.iter().enumerate() {+ if !p.layers.iter().any(|l| l == LAYERS[*k]) { continue; }+ for dr in -rad..=rad { for dc in -rad..=rad {+ let (r, c) = (r0 as isize + dr, c0 as isize + dc);+ if r < 0 || c < 0 || r as usize >= h || c as usize >= w || dr * dr + dc * dc > rad * rad { continue; }+ if fr[*k].at(r as usize, c as usize) > 0.0 { spots.push((a, r as usize, c as usize)); }+ } }+ }+ spots+ };+ let (mut hs, mut hk) = (0usize, 0usize);+ let per_ext = load.amps / ext.len() as f64;+ for p in &ext { let sp = spots_of(p); if sp.is_empty() { continue; } hs += 1; for (a, r, c) in &sp { rhs[a * n + r * w + c] += per_ext / sp.len() as f64; } }+ let mut pinned = false;+ for p in &chips {+ let share = load.amps * area_by_ref[&p.reference] / area_total / chips.iter().filter(|q| q.reference == p.reference).count() as f64;+ let sp = spots_of(p); if sp.is_empty() { continue; } hk += 1;+ for (a, r, c) in &sp { rhs[a * n + r * w + c] -= share / sp.len() as f64; }+ if !pinned { let (a, r, c) = sp[0]; sys.leak[a][r * w + c] = 1e3; pinned = true; }+ }+ if hs == 0 || hk == 0 { log(&format!("{net}: terminals not on copper (sources {hs}, sinks {hk}), skipped")); continue; }+ let (v, it, res) = sys.solve(&rhs, 1e-7, 6000);+ log(&format!("{net}: {} A, {} layer(s), {it} iterations, residual {res:.1e}", load.amps, nl));+ let mut grids: Vec<Grid> = LAYERS.iter().map(|_| Grid::new(w, h)).collect();+ let (mut peak, mut peak_at, mut peak_layer) = (0.0f64, [0.0, 0.0], String::new());+ let mut joule = 0.0;+ for (a, k) in live.iter().enumerate() {+ let f = &fr[*k];+ let mut cx = vec![0.0f64; n]; let mut cy = vec![0.0f64; n];+ for r in 0..h { for c in 0..w - 1 { let g = sys.gx[a][r * (w - 1) + c]; let i = g * (v[a * n + r * w + c] - v[a * n + r * w + c + 1]); cx[r * w + c] += i / 2.0; cx[r * w + c + 1] += i / 2.0; } }+ for r in 0..h - 1 { for c in 0..w { let g = sys.gy[a][r * w + c]; let i = g * (v[a * n + r * w + c] - v[a * n + (r + 1) * w + c]); cy[r * w + c] += i / 2.0; cy[(r + 1) * w + c] += i / 2.0; } }+ let mut vals: Vec<f32> = Vec::new();+ for i in 0..n {+ let fi = f.v[i] as f64;+ if fi > 0.05 {+ let mag = (cx[i] * cx[i] + cy[i] * cy[i]).sqrt();+ let jj = mag / (d * fi.max(0.25) * t_cu);+ grids[*k].v[i] = jj as f32;+ joule += jj * jj / SIGMA * fi * d * d * t_cu;+ p_joule.v[i] += (jj * jj / SIGMA * fi * d * d * t_cu) as f32;+ if fi >= 0.25 { vals.push(jj as f32); }+ }+ }+ if !vals.is_empty() {+ vals.sort_by(|a, b2| a.partial_cmp(b2).unwrap());+ let cap = vals[((vals.len() as f64 * 0.995) as usize).min(vals.len() - 1)] as f64;+ for i in 0..n { if grids[*k].v[i] as f64 > cap { grids[*k].v[i] = cap as f32; } if f.v[i] >= 0.25 && (grids[*k].v[i] as f64) > peak { peak = grids[*k].v[i] as f64; peak_at = [b.x0 + (i % w) as f64 * d + d / 2.0, b.y0 + (i / w) as f64 * d + d / 2.0]; peak_layer = LAYERS[*k].to_string(); } }+ }+ j_all[*k].max_with(&grids[*k]);+ }+ let copper: f64 = live.iter().map(|k| fr[*k].sum() * d * d).sum();+ nets.push(NetResult { net: net.clone(), amps: load.amps, layers: live.iter().map(|k| LAYERS[*k].to_string()).collect(), peakJ: (peak * 10.0).round() / 10.0, peakAt: peak_at, peakLayer: peak_layer, copperMm2: (copper * 10.0).round() / 10.0, jouleW: (joule * 1000.0).round() / 1000.0, vias: nvias, iterations: it });+ j_net.insert(net.clone(), grids);+ }+ let joule_total: f64 = nets.iter().map(|x| x.jouleW).sum();+ // every net with real copper on the board, so the walkthrough can show one pour at a time+ let mut cu_net: BTreeMap<String, Vec<Grid>> = BTreeMap::new();+ for l in LAYERS { for net in ras.nets_on(l) { if cu_net.contains_key(&net) { continue; }+ let per: Vec<Grid> = LAYERS.iter().map(|ll| ras.fraction(&net, ll, pool)).collect();+ let area: f64 = per.iter().map(|g| g.sum()).sum::<f64>() * d * d;+ if area > 5.0 { cu_net.insert(net.clone(), per); }+ } }+ // ---- heat: four layers, the dielectric and the vias between them, the air on the faces --------+ let nl = LAYERS.len();+ let t_diel: Vec<f64> = if spec.stackupMm.len() == 3 { spec.stackupMm.clone() } else { vec![0.2, 1.065, 0.2] };+ let share = [t_diel[0] / 2.0, t_diel[0] / 2.0 + t_diel[1] / 2.0, t_diel[1] / 2.0 + t_diel[2] / 2.0, t_diel[2] / 2.0];+ let build = |h_air: f64| -> System {+ let mut sys = System::new(nl, h, w);+ for k in 0..nl {+ let kt: Vec<f64> = frac[k].v.iter().map(|f| *f as f64 * K_CU * t_cu + K_FR4_XY * share[k]).collect();+ for r in 0..h { for c in 0..w - 1 { sys.gx[k][r * (w - 1) + c] = harm(kt[r * w + c], kt[r * w + c + 1]); } }+ for r in 0..h - 1 { for c in 0..w { sys.gy[k][r * w + c] = harm(kt[r * w + c], kt[(r + 1) * w + c]); } }+ let leak = if k == 0 || k == nl - 1 { h_air * d * d } else { 0.0 };+ for i in 0..n { sys.leak[k][i] = leak + 1e-12; }+ }+ for k in 0..nl - 1 { let g = K_FR4_Z * d * d / t_diel[k]; for i in 0..n { sys.gz[k][i] = g; } }+ for v in &b.vias {+ let Some((r, c)) = cell(v.x, v.y) else { continue };+ let area = std::f64::consts::PI * ((v.drill / 2.0 + 0.025).powi(2) - (v.drill / 2.0).powi(2));+ for k in 0..nl - 1 { sys.gz[k][r * w + c] += K_CU * area / t_diel[k]; }+ }+ sys+ };+ let mut q_rhs = vec![0.0; nl * n];+ for i in 0..n { q_rhs[i] += p_joule.v[i] as f64 / 2.0; q_rhs[(nl - 1) * n + i] += p_joule.v[i] as f64 / 2.0; }+ let mut chip_pos: Vec<(String, f64, f64, f64, String)> = Vec::new();+ for (reference, hot) in &spec.hot {+ let cells: Vec<(f64, f64)> = b.pads.iter().filter(|p| p.reference == *reference && p.net == hot.tabNet).map(|p| (p.x, p.y)).collect();+ if cells.is_empty() { log(&format!("{reference}: no pad on {} (tab net), its watts are not placed", hot.tabNet)); continue; }+ let per = hot.watts / cells.len() as f64;+ for (x, y) in &cells {+ let Some((r0, c0)) = cell(*x, *y) else { continue };+ let rad = (1.2 / d) as isize; let mut spots = Vec::new();+ for dr in -rad..=rad { for dc in -rad..=rad { let (r, c) = (r0 as isize + dr, c0 as isize + dc); if r >= 0 && c >= 0 && (r as usize) < h && (c as usize) < w && dr * dr + dc * dc <= rad * rad { spots.push((r as usize, c as usize)); } } }+ for (r, c) in &spots { q_rhs[r * w + c] += per / spots.len() as f64; }+ }+ chip_pos.push((reference.clone(), cells[0].0, cells[0].1, hot.watts, hot.tabNet.clone()));+ }+ let tab_total: f64 = spec.hot.values().map(|h| h.watts).sum();+ let sys_still = build(H_STILL);+ let (tv, it, res) = sys_still.solve(&q_rhs, 1e-7, 8000);+ log(&format!("heat, still air: {it} iterations, residual {res:.1e}"));+ let sys_mov = build(H_MOVING);+ let (tm, it2, _) = sys_mov.solve(&q_rhs, 1e-7, 8000);+ log(&format!("heat, moving air: {it2} iterations"));+ let mut t: Vec<Grid> = Vec::new(); let mut t_moving: Vec<Grid> = Vec::new(); let mut q: Vec<Grid> = Vec::new();+ let mut fx_all: Vec<Vec<f64>> = Vec::new(); let mut fy_all: Vec<Vec<f64>> = Vec::new();+ for k in 0..nl {+ let mut g = Grid::new(w, h); g.v.copy_from_slice(&tv[k * n..(k + 1) * n].iter().map(|x| *x as f32).collect::<Vec<_>>()); t.push(g);+ let mut g2 = Grid::new(w, h); g2.v.copy_from_slice(&tm[k * n..(k + 1) * n].iter().map(|x| *x as f32).collect::<Vec<_>>()); t_moving.push(g2);+ let mut cx = vec![0.0f64; n]; let mut cy = vec![0.0f64; n];+ let mut fx = vec![0.0f64; h * (w - 1)]; let mut fy = vec![0.0f64; (h - 1) * w];+ for r in 0..h { for c in 0..w - 1 { let f = sys_still.gx[k][r * (w - 1) + c] * (tv[k * n + r * w + c] - tv[k * n + r * w + c + 1]); fx[r * (w - 1) + c] = f; cx[r * w + c] += f / 2.0; cx[r * w + c + 1] += f / 2.0; } }+ for r in 0..h - 1 { for c in 0..w { let f = sys_still.gy[k][r * w + c] * (tv[k * n + r * w + c] - tv[k * n + (r + 1) * w + c]); fy[r * w + c] = f; cy[r * w + c] += f / 2.0; cy[(r + 1) * w + c] += f / 2.0; } }+ let mut qg = Grid::new(w, h);+ for i in 0..n { if frac[k].v[i] >= 0.25 { qg.v[i] = ((cx[i] * cx[i] + cy[i] * cy[i]).sqrt() / d) as f32; } }+ q.push(qg); fx_all.push(fx); fy_all.push(fy);+ }+ // per chip: where its watts go within 3 mm of the tab+ let r_chip = 3.0;+ let via_wanted = |reference: &str| -> usize { spec.thermalVias.iter().filter(|v| v["pad"].as_str().map(|p| p.split('.').next() == Some(reference)).unwrap_or(false)).map(|v| v["count"].as_u64().unwrap_or(0) as usize).sum() };+ let mut chips: Vec<ChipResult> = Vec::new();+ for (reference, x, y, watts, tab) in &chip_pos {+ let Some((r0, c0)) = cell(*x, *y) else { continue };+ let inside = |r: usize, c: usize| -> bool { let (dr, dc) = (r as f64 - r0 as f64, c as f64 - c0 as f64); (dr * dr + dc * dc) * d * d <= r_chip * r_chip };+ let mut plane = 0.0;+ for r in 0..h { for c in 0..w - 1 { let (a, bb) = (inside(r, c), inside(r, c + 1)); if a && !bb { plane += fx_all[0][r * (w - 1) + c]; } else if !a && bb { plane -= fx_all[0][r * (w - 1) + c]; } } }+ for r in 0..h - 1 { for c in 0..w { let (a, bb) = (inside(r, c), inside(r + 1, c)); if a && !bb { plane += fy_all[0][r * w + c]; } else if !a && bb { plane -= fy_all[0][r * w + c]; } } }+ // the tab's own thermal vias are the ones on its net; other nets' barrels nearby still carry+ // heat (through the dielectric first) and are counted apart, never as the tab's provision+ let (mut via_w, mut other_w, mut diel_w, mut air_w, mut nv, mut no) = (0.0, 0.0, 0.0, 0.0, 0usize, 0usize);+ let g_diel = K_FR4_Z * d * d / t_diel[0];+ let mut own_cells: std::collections::HashSet<usize> = std::collections::HashSet::new();+ for v in b.vias.iter().filter(|v| v.net == *tab) { if let Some((r, c)) = cell(v.x, v.y) { if inside(r, c) { own_cells.insert(r * w + c); nv += 1; } } }+ for v in b.vias.iter().filter(|v| v.net != *tab) { if let Some((r, c)) = cell(v.x, v.y) { if inside(r, c) { no += 1; } } }+ for r in 0..h { for c in 0..w { if !inside(r, c) { continue; } let i = r * w + c; let dt = tv[i] - tv[n + i]; let gv = sys_still.gz[0][i] - g_diel; if gv > 0.0 { if own_cells.contains(&i) { via_w += gv * dt; } else { other_w += gv * dt; } } diel_w += g_diel * dt; air_w += H_STILL * d * d * tv[i]; } }+ chips.push(ChipResult { reference: reference.clone(), watts: *watts, tabNet: tab.clone(), x: *x, y: *y, riseC: (tv[r0 * w + c0] * 10.0).round() / 10.0, riseMovingC: (tm[r0 * w + c0] * 10.0).round() / 10.0, planeW: (plane * 1000.0).round() / 1000.0, viaW: (via_w * 1000.0).round() / 1000.0, vias: nv, otherViaW: (other_w * 1000.0).round() / 1000.0, otherVias: no, dielW: (diel_w * 1000.0).round() / 1000.0, airW: (air_w * 1000.0).round() / 1000.0, viasWanted: via_wanted(reference) });+ }+ // ---- issues -------------------------------------------------------------------------------+ let mut issues: Vec<Issue> = Vec::new();+ for x in &nets {+ // IPC-2221 external, 10 C rise: about 60 A/mm2 at 35 um; a pinch well past that is a hot neck+ // every loaded net over the threshold is listed; the severity follows the current, since half an+ // amp through a thin track is a warm trace and twenty amps through a neck is a fuse+ if x.peakJ > neck {+ let severity = if x.amps < 1.0 { "low" } else if x.peakJ > 2.5 * neck { "high" } else { "medium" };+ issues.push(Issue { severity: severity.into(), kind: "current-neck".into(), what: format!("{}: {:.0} A/mm² at its worst point on {} ({} A through a neck about {:.2} mm wide at {:.0} µm; the threshold is {neck:.0} A/mm²)", x.net, x.peakJ, x.peakLayer, x.amps, x.amps / (x.peakJ * t_cu), spec.copperUm), at: Some(x.peakAt), layer: Some(x.peakLayer.clone()), net: Some(x.net.clone()), reference: None, fix: if x.amps < 1.0 { "A small current: widen the track if it is long, otherwise note it and move on.".into() } else { "Widen the neck or add a parallel path on the other layer with vias; the density map shows where the current squeezes.".into() } });+ }+ }+ for c in &chips {+ if c.riseC > spec.hotMaxRiseC {+ issues.push(Issue { severity: if c.riseMovingC > spec.hotMaxRiseC { "high".into() } else { "medium".into() }, kind: "tab-rise".into(), what: format!("{}: +{:.0} °C at its tab in still air (+{:.0} °C in moving air) against a {:.0} °C budget", c.reference, c.riseC, c.riseMovingC, spec.hotMaxRiseC), at: Some([c.x, c.y]), layer: Some("F.Cu".into()), net: Some(c.tabNet.clone()), reference: Some(c.reference.clone()), fix: if c.riseMovingC > spec.hotMaxRiseC { "More copper under the tab and more vias to the other layers, or a heatsink; moving air alone does not bring it in.".into() } else { "Moving air brings it in; in still air it needs more copper under the tab, more vias, or a heatsink.".into() } });+ }+ if c.vias < c.viasWanted {+ issues.push(Issue { severity: "medium".into(), kind: "thermal-vias".into(), what: format!("{}: {} via(s) within {r_chip:.0} mm of its tab, the spec asks for {}", c.reference, c.vias, c.viasWanted), at: Some([c.x, c.y]), layer: Some("F.Cu".into()), net: Some(c.tabNet.clone()), reference: Some(c.reference.clone()), fix: "Land the thermal vias under the tab pad; without them the tab's heat has only the top copper to spread on.".into() });+ }+ }+ let _ = (tab_total, joule_total);+ Fields { d, h, w, x0: b.x0, y0: b.y0, frac, j_net, cu_net, j_all, q, t, t_moving, nets, chips, issues, joule_total: (joule_total * 1000.0).round() / 1000.0, tab_total }+}++/// The summary the app and the page read.+pub fn summary(f: &Fields, b: &Board, spec: &Spec, textures: &[String]) -> Value {+ let mut nets = f.nets.clone();+ // relevance: amps first, then the peak density+ nets.sort_by(|a, bb| bb.amps.partial_cmp(&a.amps).unwrap().then(bb.peakJ.partial_cmp(&a.peakJ).unwrap()));+ let mut chips = f.chips.clone();+ chips.sort_by(|a, bb| bb.riseC.partial_cmp(&a.riseC).unwrap());+ let tmax = f.t[0].max().max(f.t[f.t.len() - 1].max());+ // the nets with real copper, biggest first: the walkthrough shows one pour at a time+ let mut areas: Vec<(String, f64)> = f.cu_net.iter().map(|(n, g)| (n.clone(), g.iter().map(|x| x.sum()).sum::<f64>() * f.d * f.d)).collect();+ areas.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));+ let pour_nets: Vec<Value> = areas.into_iter().map(|(n, a)| json!({"net": n, "copperMm2": (a * 10.0).round() / 10.0})).collect();+ json!({+ "board": {"name": spec.board, "x0": b.x0, "y0": b.y0, "widthMm": b.width(), "heightMm": b.height(), "layers": LAYERS, "copperUm": spec.copperUm, "thicknessMm": 1.6, "cellMm": f.d, "cols": f.w, "rows": f.h},+ "nets": nets, "chips": chips, "issues": f.issues,+ "vias": b.vias.iter().map(|v| json!({"net": v.net, "x": v.x, "y": v.y, "size": v.size, "drill": v.drill})).collect::<Vec<_>>(),+ "pads": b.pads.iter().filter(|p| spec.hot.contains_key(&p.reference) || spec.loads.contains_key(&p.net)).map(|p| json!({"ref": p.reference, "pad": p.pad, "net": p.net, "x": p.x, "y": p.y})).collect::<Vec<_>>(),+ "scales": {"jMax": f.j_all.iter().map(|g| g.max()).fold(0.0, f32::max), "qMax": f.q.iter().map(|g| g.max()).fold(0.0, f32::max), "tMax": tmax, "tMovingMax": f.t_moving.iter().map(|g| g.max()).fold(0.0, f32::max)},+ "heat": {"tabW": f.tab_total, "jouleW": f.joule_total, "peakRiseC": tmax, "peakRiseMovingC": f.t_moving.iter().map(|g| g.max()).fold(0.0, f32::max), "budgetC": spec.hotMaxRiseC, "stillAirWPerM2K": H_STILL * 1e6, "movingAirWPerM2K": H_MOVING * 1e6},+ "textures": textures,+ "pourNets": pour_nets,+ "method": "Copper fraction per cell from the board's filled polygons, tracks, pads and vias; DC conduction per loaded net between its terminals (σ 5.8e4 S/mm × thickness); heat: four layers coupled through the dielectric and every via barrel, copper 0.40 W/mm·K, FR4 0.3/0.6 W/m·K, 10 (still) or 25 (moving) W/m²K to the air on both faces; one steady-state solve each."+ })+}
src/main.rsadded+120@@ -0,0 +1,120 @@+//! adom-fields: where the current and the heat go on your board.+mod board; mod fields; mod raster; mod sexp; mod solve; mod tex; mod serve; mod vendor;+use clap::{Parser, Subcommand};+use std::path::PathBuf;++#[derive(Parser)]+#[command(name = "adom-fields", version, about = "Adom Fields: current density, copper heat flow and board temperature from the board file, in 3D")]+struct Cli { #[command(subcommand)] cmd: Cmd }++#[derive(Subcommand)]+enum Cmd {+ /// Solve the fields of a board: current density per loaded net, heat flow in the copper, temperature over the board; writes fields.json and the textures to --out+ Analyze { #[arg(long)] board: String, #[arg(long)] spec: String, #[arg(long, default_value = "fields")] out: PathBuf, /// solve cell in mm (0.2 is a minute on a 65 x 75 mm board; 0.1 is four times longer)+ #[arg(long, default_value_t = 0.2)] pitch: f64, /// the current density a loaded net may not exceed before it is an issue, A/mm² (60 is about IPC-2221 external at 35 µm for 10 °C)+ #[arg(long, default_value_t = 60.0)] neck: f64 },+ /// Serve the app for a solved board (or for a board and spec, solving first): the 3D board with the fields on it, the nets by relevance, the issues, and the live channel the AI drives+ Serve { #[arg(long, default_value = "fields")] fields: PathBuf, #[arg(long, default_value_t = 8874)] port: u16, #[arg(long)] board: Option<String>, #[arg(long)] spec: Option<String> },+ /// Drive the running app: `ui view --quantity current|heat|temperature [--net X] [--layer F.Cu] [--camera top|iso|bottom|fit]`, `ui toast "..."`, `ui select --net X`, `ui issue --index 0`+ Ui { #[command(subcommand)] what: UiCmd, #[arg(long, default_value_t = 8874)] port: u16 },+ /// A scripted walkthrough over the live channel: the nets by relevance, each field, the issues; for the AI's video step+ Tour { #[arg(long, default_value_t = 8874)] port: u16, /// milliseconds to hold each beat while the board drifts; gentle on purpose, the flow speeds the clip up ten times+ #[arg(long, default_value_t = 14000)] pause_ms: u64 },+ /// Is the server up+ Health { #[arg(long, default_value_t = 8874)] port: u16 },+ /// Install the skill from the wiki page+ Install,+}++#[derive(Subcommand)]+enum UiCmd {+ View { #[arg(long)] quantity: Option<String>, #[arg(long)] net: Option<String>, #[arg(long)] layer: Option<String>, #[arg(long)] camera: Option<String>, #[arg(long)] air: Option<String> },+ Toast { text: String, #[arg(long, default_value = "info")] kind: String },+ Select { #[arg(long)] net: Option<String>, #[arg(long)] chip: Option<String> },+ Issue { #[arg(long)] index: usize },+ State,+ Console,+}++pub fn ok(msg: &str, hints: &[String]) { println!("OK: {msg}"); for h in hints { if !h.is_empty() { println!("Hint: {h}"); } } }+pub fn err(msg: &str, hints: &[String]) -> ! { println!("ERROR: {msg}"); for h in hints { if !h.is_empty() { println!("Hint: {h}"); } } std::process::exit(1) }++fn slug(s: &str) -> String { s.chars().map(|c| if c.is_alphanumeric() { c } else { '_' }).collect() }++pub fn run_analyze(board: &str, spec_path: &str, out: &PathBuf, pitch: f64, neck: f64) -> Result<serde_json::Value, String> {+ let b = board::Board::load(board)?;+ let spec: fields::Spec = serde_json::from_str(&std::fs::read_to_string(spec_path).map_err(|e| format!("{spec_path}: {e}"))?).map_err(|e| format!("{spec_path}: {e}"))?;+ if spec.loads.is_empty() && spec.hot.is_empty() { return Err("the spec has no loads and no hot parts: nothing to solve".into()); }+ std::fs::create_dir_all(out.join("tex")).map_err(|e| e.to_string())?;+ let t0 = std::time::Instant::now();+ let mut lines: Vec<String> = Vec::new();+ let f = fields::analyze(&b, &spec, pitch, neck, &mut |s| { eprintln!("{s}"); lines.push(s.to_string()); });+ let tex = out.join("tex");+ let mut textures: Vec<String> = Vec::new();+ let jhi = f.j_all.iter().map(|g| g.max()).fold(1.0, f32::max);+ let qhi = f.q.iter().map(|g| g.max()).fold(0.01, f32::max);+ let thi = f.t.iter().map(|g| g.max()).fold(1.0, f32::max);+ for (k, l) in board::LAYERS.iter().enumerate() {+ let ls = slug(l);+ tex::write_mask(&tex.join(format!("copper-{ls}.png")).display().to_string(), &f.frac[k], [214, 148, 90])?; textures.push(format!("copper-{ls}.png"));+ tex::write(&tex.join(format!("J-{ls}.png")).display().to_string(), &f.j_all[k], Some(&f.frac[k]), &tex::Scale::Log { lo: jhi / 300.0, hi: jhi }, false)?; textures.push(format!("J-{ls}.png"));+ tex::write_raw(&tex.join(format!("J-{ls}.raw.png")).display().to_string(), &f.j_all[k], jhi)?;+ tex::write(&tex.join(format!("q-{ls}.png")).display().to_string(), &f.q[k], Some(&f.frac[k]), &tex::Scale::Log { lo: qhi / 300.0, hi: qhi }, false)?; textures.push(format!("q-{ls}.png"));+ tex::write_raw(&tex.join(format!("q-{ls}.raw.png")).display().to_string(), &f.q[k], qhi)?;+ tex::write(&tex.join(format!("T-{ls}.png")).display().to_string(), &f.t[k], None, &tex::Scale::Linear { hi: thi }, true)?; textures.push(format!("T-{ls}.png"));+ tex::write_raw(&tex.join(format!("T-{ls}.raw.png")).display().to_string(), &f.t[k], thi)?;+ tex::write(&tex.join(format!("Tm-{ls}.png")).display().to_string(), &f.t_moving[k], None, &tex::Scale::Linear { hi: thi }, true)?; textures.push(format!("Tm-{ls}.png"));+ tex::write_raw(&tex.join(format!("Tm-{ls}.raw.png")).display().to_string(), &f.t_moving[k], thi)?;+ // one pour at a time: the heat flow masked to a single net's copper+ for (net, cu) in &f.cu_net {+ let ns = slug(net);+ let mut only = crate::raster::Grid::new(f.q[k].w, f.q[k].h);+ for i in 0..only.v.len() { if cu[k].v[i] > 0.05 { only.v[i] = f.q[k].v[i]; } }+ tex::write(&tex.join(format!("q-{ns}-{ls}.png")).display().to_string(), &only, Some(&cu[k]), &tex::Scale::Log { lo: qhi / 300.0, hi: qhi }, false)?; textures.push(format!("q-{ns}-{ls}.png"));+ }+ for (net, grids) in &f.j_net {+ let ns = slug(net);+ tex::write(&tex.join(format!("J-{ns}-{ls}.png")).display().to_string(), &grids[k], Some(&f.frac[k]), &tex::Scale::Log { lo: jhi / 300.0, hi: jhi }, false)?; textures.push(format!("J-{ns}-{ls}.png"));+ }+ }+ let mut summary = fields::summary(&f, &b, &spec, &textures);+ summary["board"]["name"] = serde_json::json!(spec.board.split('(').next().unwrap_or(&spec.board).trim());+ summary["board"]["description"] = serde_json::json!(spec.board);+ summary["log"] = serde_json::json!(lines);+ summary["seconds"] = serde_json::json!((t0.elapsed().as_secs_f64() * 10.0).round() / 10.0);+ summary["scales"]["jHi"] = serde_json::json!(jhi); summary["scales"]["qHi"] = serde_json::json!(qhi); summary["scales"]["tHi"] = serde_json::json!(thi);+ summary["source"] = serde_json::json!({"board": std::fs::canonicalize(board).map(|p| p.display().to_string()).unwrap_or(board.to_string()), "spec": spec_path});+ std::fs::write(out.join("fields.json"), serde_json::to_string_pretty(&summary).unwrap()).map_err(|e| e.to_string())?;+ Ok(summary)+}++fn main() {+ let cli = Cli::parse();+ match cli.cmd {+ Cmd::Analyze { board, spec, out, pitch, neck } => {+ match run_analyze(&board, &spec, &out, pitch, neck) {+ Ok(s) => {+ let nets = s["nets"].as_array().map(|a| a.len()).unwrap_or(0);+ let issues = s["issues"].as_array().cloned().unwrap_or_default();+ ok(&format!("{} nets solved, {} hot parts, {} issue(s), in {} s: {}", nets, s["chips"].as_array().map(|a| a.len()).unwrap_or(0), issues.len(), s["seconds"], out.join("fields.json").display()), &[+ format!("Peak current density {:.0} A/mm²; peak rise {:.0} °C still air, {:.0} °C moving air, budget {:.0} °C; Joule loss {} W on top of {} W from the tabs.", s["scales"]["jMax"].as_f64().unwrap_or(0.0), s["heat"]["peakRiseC"].as_f64().unwrap_or(0.0), s["heat"]["peakRiseMovingC"].as_f64().unwrap_or(0.0), s["heat"]["budgetC"].as_f64().unwrap_or(0.0), s["heat"]["jouleW"], s["heat"]["tabW"]),+ issues.iter().take(3).map(|i| format!("{} {}: {}", i["severity"].as_str().unwrap_or(""), i["kind"].as_str().unwrap_or(""), i["what"].as_str().unwrap_or(""))).collect::<Vec<_>>().join(" | "),+ format!("Next: `adom-fields serve --fields {}` and open it (pup or the browser); `adom-fields ui view --quantity current --net <net>` drives it; `adom-fields tour` walks it for a video.", out.display()),+ ]);+ }+ Err(e) => err(&e, &["The board must be a .kicad_pcb saved with its zones filled; the spec is the aiflow spec (loads, hot, copperUm, hotMaxRiseC, thermalVias).".into()]),+ }+ }+ Cmd::Serve { fields, port, board, spec } => {+ if let (Some(bd), Some(sp)) = (&board, &spec) { if let Err(e) = run_analyze(bd, sp, &fields, 0.2, 60.0) { err(&e, &[]); } }+ serve::serve(&fields, port);+ }+ Cmd::Ui { what, port } => serve::ui(what, port),+ Cmd::Tour { port, pause_ms } => serve::tour(port, pause_ms),+ Cmd::Health { port } => match serve::get(port, "/state") { Ok(v) => ok(&format!("adom-fields serving on {port}: {}", v["board"]["name"].as_str().unwrap_or("no board")), &[]), Err(e) => err(&format!("not serving on {port}: {e}"), &[format!("Start it: `adom-fields serve --fields <dir> --port {port} &`")]) },+ Cmd::Install => {+ let st = std::process::Command::new("adom-wiki").args(["skills", "install", "adom/adom-fields"]).status();+ match st { Ok(s) if s.success() => ok("skill installed from adom/adom-fields", &[]), _ => err("adom-wiki skills install adom/adom-fields failed", &["Is adom-wiki on the PATH and signed in?".into()]) }+ }+ }+}
src/raster.rsadded+89@@ -0,0 +1,89 @@+//! Copper masks on a fine grid, pooled to the solve grid as copper fractions.+use crate::board::{Board, Poly, LAYERS};+use std::collections::HashMap;++pub struct Raster { pub w: usize, pub h: usize, pub res: f64, pub x0: f64, pub y0: f64, pub masks: HashMap<(String, String), Vec<u8>> }++impl Raster {+ pub fn new(b: &Board, res: f64) -> Raster {+ Raster { w: ((b.width() / res).round() as usize).max(1), h: ((b.height() / res).round() as usize).max(1), res, x0: b.x0, y0: b.y0, masks: HashMap::new() }+ }+ fn mask(&mut self, net: &str, layer: &str) -> &mut Vec<u8> {+ let n = self.w * self.h;+ self.masks.entry((net.to_string(), layer.to_string())).or_insert_with(|| vec![0u8; n])+ }+ fn px(&self, x: f64, y: f64) -> (f64, f64) { ((x - self.x0) / self.res, (y - self.y0) / self.res) }+ pub fn fill_poly(&mut self, net: &str, layer: &str, pts: &[(f64, f64)]) {+ let (w, h) = (self.w, self.h);+ let p: Vec<(f64, f64)> = pts.iter().map(|(x, y)| self.px(*x, *y)).collect();+ let m = self.mask(net, layer);+ let ymin = p.iter().map(|q| q.1).fold(f64::MAX, f64::min).floor().max(0.0) as usize;+ let ymax = p.iter().map(|q| q.1).fold(f64::MIN, f64::max).ceil().min(h as f64 - 1.0) as usize;+ let mut xs: Vec<f64> = Vec::new();+ for row in ymin..=ymax {+ let yc = row as f64 + 0.5;+ xs.clear();+ 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 pair in xs.chunks(2) {+ if pair.len() < 2 { break; }+ let (xa, xb) = ((pair[0].round().max(0.0)) as usize, (pair[1].round().min(w as f64)) as usize);+ for col in xa..xb { m[row * w + col] = 1; }+ }+ }+ }+ pub fn disc(&mut self, net: &str, layer: &str, x: f64, y: f64, r: f64) {+ let (cx, cy) = self.px(x, y); let rp = r / self.res; let (w, h) = (self.w, self.h);+ let m = self.mask(net, layer);+ let (r0, r1) = (((cy - rp).floor().max(0.0)) as usize, ((cy + rp).ceil().min(h as f64 - 1.0)) as usize);+ let (c0, c1) = (((cx - rp).floor().max(0.0)) as usize, ((cx + rp).ceil().min(w as f64 - 1.0)) as usize);+ for row in r0..=r1 { for col in c0..=c1 { let (dx, dy) = (col as f64 + 0.5 - cx, row as f64 + 0.5 - cy); if dx * dx + dy * dy <= rp * rp { m[row * w + col] = 1; } } }+ }+ pub fn thick_line(&mut self, net: &str, layer: &str, a: (f64, f64), b: (f64, f64), wmm: f64) {+ let (ax, ay) = self.px(a.0, a.1); let (bx, by) = self.px(b.0, b.1); let r = wmm / 2.0 / self.res; let (w, h) = (self.w, self.h);+ let m = self.mask(net, layer);+ let (r0, r1) = (((ay.min(by) - r).floor().max(0.0)) as usize, ((ay.max(by) + r).ceil().min(h as f64 - 1.0)) as usize);+ let (c0, c1) = (((ax.min(bx) - r).floor().max(0.0)) as usize, ((ax.max(bx) + r).ceil().min(w as f64 - 1.0)) as usize);+ let (dx, dy) = (bx - ax, by - ay); let len2 = dx * dx + dy * dy;+ for row in r0..=r1 { for col in c0..=c1 {+ let (px, py) = (col as f64 + 0.5, row as f64 + 0.5);+ let t = if len2 > 0.0 { ((px - ax) * dx + (py - ay) * dy) / len2 } else { 0.0 }.clamp(0.0, 1.0);+ let (qx, qy) = (ax + t * dx, ay + t * dy);+ if (px - qx).powi(2) + (py - qy).powi(2) <= r * r { m[row * w + col] = 1; }+ } }+ }+ /// Draw everything on the board: filled polygons, tracks, pads, via annular rings.+ pub fn draw_board(&mut self, b: &Board) {+ let polys: Vec<Poly> = b.polys.iter().chain(b.pad_polys.iter()).cloned().collect();+ for p in &polys { self.fill_poly(&p.net, &p.layer, &p.pts); }+ for t in &b.tracks { let (net, layer, a, bb, w) = (t.net.clone(), t.layer.clone(), t.a, t.b, t.w); self.thick_line(&net, &layer, a, bb, w); }+ for v in &b.vias { for l in LAYERS { let (net, x, y, s) = (v.net.clone(), v.x, v.y, v.size); self.disc(&net, l, x, y, s / 2.0); } }+ }+ /// The copper fraction of one net on one layer per solve cell (pool x pool fine pixels each).+ pub fn fraction(&self, net: &str, layer: &str, pool: usize) -> Grid {+ let (gw, gh) = (self.w / pool, self.h / pool);+ let mut g = Grid::new(gw, gh);+ if let Some(m) = self.masks.get(&(net.to_string(), layer.to_string())) {+ for r in 0..gh { for c in 0..gw {+ let mut s = 0u32;+ for i in 0..pool { for j in 0..pool { s += m[(r * pool + i) * self.w + c * pool + j] as u32; } }+ g.v[r * gw + c] = s as f32 / (pool * pool) as f32;+ } }+ }+ g+ }+ pub fn nets_on(&self, layer: &str) -> Vec<String> { let mut v: Vec<String> = self.masks.keys().filter(|(_, l)| l == layer).map(|(n, _)| n.clone()).collect(); v.sort(); v.dedup(); v }+}++#[derive(Clone, Debug)]+pub struct Grid { pub w: usize, pub h: usize, pub v: Vec<f32> }+impl Grid {+ pub fn new(w: usize, h: usize) -> Grid { Grid { w, h, v: vec![0.0; w * h] } }+ pub fn at(&self, r: usize, c: usize) -> f32 { self.v[r * self.w + c] }+ pub fn max_with(&mut self, o: &Grid) { for (a, b) in self.v.iter_mut().zip(o.v.iter()) { if *b > *a { *a = *b; } } }+ pub fn sum(&self) -> f64 { self.v.iter().map(|x| *x as f64).sum() }+ pub fn max(&self) -> f32 { self.v.iter().cloned().fold(0.0, f32::max) }+}
src/serve.rsadded+154@@ -0,0 +1,154 @@+//! The server: the app, the fields, the state, the console, the live channel, the drivers.+use crate::UiCmd;+use serde_json::{json, Value};+use std::io::Write;+use std::path::PathBuf;+use std::sync::{Arc, Mutex};++pub fn get(port: u16, path: &str) -> Result<Value, String> { http(port, "GET", path, None) }+fn post(port: u16, path: &str, body: &Value) -> Result<Value, String> { http(port, "POST", path, Some(body)) }+fn http(port: u16, method: &str, path: &str, body: Option<&Value>) -> Result<Value, String> {+ use std::io::Read;+ let mut s = std::net::TcpStream::connect(("127.0.0.1", port)).map_err(|e| e.to_string())?;+ s.set_read_timeout(Some(std::time::Duration::from_secs(10))).ok();+ let b = body.map(|v| v.to_string()).unwrap_or_default();+ // HTTP/1.0 so the server answers with a Content-Length instead of chunks (a 40 KB fields.json comes chunked over 1.1)+ write!(s, "{method} {path} HTTP/1.0\r\nHost: 127.0.0.1\r\nUser-Agent: adom-fields-cli/{}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", env!("CARGO_PKG_VERSION"), b.len(), b).map_err(|e| e.to_string())?;+ let mut buf = String::new(); s.read_to_string(&mut buf).map_err(|e| e.to_string())?;+ let body = buf.split("\r\n\r\n").nth(1).unwrap_or("");+ serde_json::from_str(body).map_err(|e| format!("{e}: {}", body.chars().take(200).collect::<String>()))+}++struct App { dir: PathBuf, fields: Value, view: Value, console: Vec<String>, clients: Vec<std::sync::mpsc::Sender<String>>, started: String, seq: u64 }++fn now() -> String { let t = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs(); format!("{t}") }++pub fn serve(dir: &PathBuf, port: u16) {+ let fields: Value = std::fs::read_to_string(dir.join("fields.json")).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_else(|| crate::err(&format!("no fields.json in {}", dir.display()), &["Run `adom-fields analyze --board <pcb> --spec <json> --out <dir>` first, or pass --board and --spec to serve.".into()]));+ let app = Arc::new(Mutex::new(App { dir: dir.clone(), fields, view: json!({"quantity": "current", "net": null, "layer": null, "camera": "iso", "air": "still", "issue": null, "chip": null, "drift": true, "seq": 0, "changed": []}), console: Vec::new(), clients: Vec::new(), started: now(), seq: 0 }));+ let server = tiny_http::Server::http(("0.0.0.0", port)).unwrap_or_else(|e| crate::err(&format!("cannot listen on {port}: {e}"), &[format!("Another adom-fields may be up: `adom-fields health --port {port}`; or pick --port.")]));+ crate::ok(&format!("adom-fields serving on http://127.0.0.1:{port}/ ({})", app.lock().unwrap().fields["board"]["name"].as_str().unwrap_or("")), &["Open it from outside the container through the proxy: https://<slug>.adom.cloud/proxy/".to_string() + &port.to_string() + "/ (pup, the browser, or a Hydrogen webview).", "Drive it: `adom-fields ui view --quantity heat --layer F.Cu`, `ui select --net +VBAT`, `ui issue --index 0`, `ui toast \"...\"`; `adom-fields tour` walks the whole thing.".into(), "GET /state, GET /console, GET /events (SSE) for the AI; POST /ui/view, /ui/toast, /ui/select, /ui/issue.".into()]);+ for req in server.incoming_requests() {+ let app = app.clone();+ std::thread::spawn(move || handle(req, app));+ }+}++fn broadcast(app: &mut App, event: &str, data: &Value) {+ let msg = format!("event: {event}\ndata: {}\n\n", data);+ app.clients.retain(|c| c.send(msg.clone()).is_ok());+}++fn handle(mut req: tiny_http::Request, app: Arc<Mutex<App>>) {+ let url = req.url().to_string();+ let path = url.split('?').next().unwrap_or("/").to_string();+ let method = req.method().as_str().to_string();+ let mut body = String::new();+ let _ = req.as_reader().read_to_string(&mut body);+ let jsonh = tiny_http::Header::from_bytes("Content-Type", "application/json").unwrap();+ let cors = tiny_http::Header::from_bytes("Access-Control-Allow-Origin", "*").unwrap();+ let reply = |req: tiny_http::Request, code: u16, v: Value| { let _ = req.respond(tiny_http::Response::from_string(v.to_string()).with_status_code(code).with_header(jsonh.clone()).with_header(cors.clone())); };+ match (method.as_str(), path.as_str()) {+ ("GET", "/") | ("GET", "/index.html") => { let _ = req.respond(tiny_http::Response::from_string(include_str!("ui/index.html")).with_header(tiny_http::Header::from_bytes("Content-Type", "text/html; charset=utf-8").unwrap()).with_header(tiny_http::Header::from_bytes("Cache-Control", "no-cache").unwrap())); }+ ("GET", "/app.js") => { let _ = req.respond(tiny_http::Response::from_string(include_str!("ui/app.js")).with_header(tiny_http::Header::from_bytes("Content-Type", "application/javascript").unwrap()).with_header(tiny_http::Header::from_bytes("Cache-Control", "no-cache").unwrap())); }+ ("GET", "/health") => reply(req, 200, json!({"status": "ok", "version": env!("CARGO_PKG_VERSION")})),+ ("GET", "/state") => { let a = app.lock().unwrap(); reply(req, 200, json!({"status": "ok", "view": a.view, "board": a.fields["board"], "nets": a.fields["nets"].as_array().map(|x| x.len()).unwrap_or(0), "issues": a.fields["issues"].as_array().map(|x| x.len()).unwrap_or(0), "clients": a.clients.len(), "started": a.started, "_hint": "POST /ui/view {quantity, net, layer, camera, air}; /ui/select {net|chip}; /ui/issue {index}; /ui/toast {text, kind}; GET /events is the SSE channel; GET /fields.json is everything solved."})); }+ ("GET", "/fields.json") => { let a = app.lock().unwrap(); reply(req, 200, a.fields.clone()); }+ ("GET", "/console") => { let a = app.lock().unwrap(); reply(req, 200, json!({"status": "ok", "lines": a.console})); }+ ("POST", "/console") => { let v: Value = serde_json::from_str(&body).unwrap_or(json!({})); let mut a = app.lock().unwrap(); a.console.push(format!("{} {}", v["level"].as_str().unwrap_or("log"), v["text"].as_str().unwrap_or(""))); if a.console.len() > 500 { a.console.remove(0); } reply(req, 200, json!({"status": "ok"})); }+ ("GET", "/events") => {+ let (tx, rx) = std::sync::mpsc::channel::<String>();+ let first = { let mut a = app.lock().unwrap(); a.clients.push(tx); format!("event: state\ndata: {}\n\n", json!({"view": a.view})) };+ let mut w = req.into_writer();+ let _ = write!(w, "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nCache-Control: no-cache\r\nAccess-Control-Allow-Origin: *\r\nConnection: keep-alive\r\n\r\n{first}");+ let _ = w.flush();+ loop {+ match rx.recv_timeout(std::time::Duration::from_secs(15)) {+ Ok(m) => { if write!(w, "{m}").is_err() || w.flush().is_err() { break; } }+ Err(std::sync::mpsc::RecvTimeoutError::Timeout) => { if write!(w, ": ping\n\n").is_err() || w.flush().is_err() { break; } }+ Err(_) => break,+ }+ }+ }+ ("POST", "/ui/view") | ("POST", "/ui/select") | ("POST", "/ui/issue") => {+ let v: Value = serde_json::from_str(&body).unwrap_or(json!({}));+ let mut a = app.lock().unwrap();+ if path == "/ui/issue" {+ let i = v["index"].as_u64().unwrap_or(0) as usize;+ let Some(issue) = a.fields["issues"].get(i).cloned() else { drop(a); reply(req, 404, json!({"status": "error", "error": "no such issue", "hint": "GET /fields.json .issues lists them, 0-based"})); return; };+ a.view["issue"] = json!(i); a.view["net"] = issue["net"].clone(); if let Some(l) = issue["layer"].as_str() { a.view["layer"] = json!(l); }+ a.view["quantity"] = json!(match issue["kind"].as_str() { Some("current-neck") => "current", _ => "heat" });+ a.view["camera"] = json!("zoom"); a.view["focus"] = issue["at"].clone();+ } else {+ for k in ["quantity", "net", "layer", "camera", "air", "chip", "drift"] { if !v[k].is_null() { a.view[k] = v[k].clone(); } }+ if v["net"].as_str() == Some("") { a.view["net"] = Value::Null; }+ if path == "/ui/select" { a.view["issue"] = Value::Null; if let Some(c) = v["chip"].as_str() { if let Some(ch) = a.fields["chips"].as_array().and_then(|x| x.iter().find(|x| x["reference"].as_str() == Some(c))).cloned() { a.view["focus"] = json!([ch["x"], ch["y"]]); a.view["camera"] = json!("zoom"); a.view["quantity"] = json!("heat"); } } }+ }+ // every command gets a sequence number and the keys it changed, so the page animates the+ // camera only when a command moved it, and never on a poll that merely re-read the view+ let before = a.view.clone();+ a.seq += 1;+ let seq = a.seq;+ let mut changed: Vec<String> = Vec::new();+ if let (Some(bo), Some(no)) = (before.as_object(), a.view.as_object()) { for (k, val) in no { if k == "seq" || k == "changed" { continue; } if bo.get(k) != Some(val) { changed.push(k.clone()); } } }+ if path == "/ui/view" && !v["camera"].is_null() && !changed.iter().any(|k| k == "camera") { changed.push("camera".into()); }+ if path == "/ui/issue" || (path == "/ui/select" && !v["chip"].is_null()) { if !changed.iter().any(|k| k == "focus") { changed.push("focus".into()); } }+ a.view["seq"] = json!(seq); a.view["changed"] = json!(changed);+ let view = a.view.clone();+ broadcast(&mut a, "view", &view);+ reply(req, 200, json!({"status": "ok", "view": view}));+ }+ ("POST", "/ui/toast") => { let v: Value = serde_json::from_str(&body).unwrap_or(json!({})); let mut a = app.lock().unwrap(); broadcast(&mut a, "toast", &json!({"text": v["text"], "kind": v["kind"].as_str().unwrap_or("info"), "sticky": v["kind"].as_str() == Some("error")})); reply(req, 200, json!({"status": "ok"})); }+ ("GET", p) if p.starts_with("/vendor/3d-viewer/") => {+ // the canonical Adom 3D viewer (adom/adom-3d-viewer), embedded in the binary+ let name = p.trim_start_matches("/vendor/3d-viewer/").split('?').next().unwrap_or("");+ match crate::vendor::vendor_file(name) {+ Some((bytes, ct)) => { let _ = req.respond(tiny_http::Response::from_data(bytes.to_vec()).with_header(tiny_http::Header::from_bytes("Content-Type", ct).unwrap()).with_header(tiny_http::Header::from_bytes("Cache-Control", "public, max-age=86400").unwrap()).with_header(cors.clone())); }+ None => reply(req, 404, json!({"status": "error", "error": format!("no vendor file {name}")})),+ }+ }+ ("GET", p) if p.starts_with("/tex/") => {+ let name = p.trim_start_matches("/tex/").replace("..", "");+ let file = app.lock().unwrap().dir.join("tex").join(&name);+ match std::fs::read(&file) { Ok(bytes) => { let _ = req.respond(tiny_http::Response::from_data(bytes).with_header(tiny_http::Header::from_bytes("Content-Type", "image/png").unwrap()).with_header(tiny_http::Header::from_bytes("Cache-Control", "no-cache").unwrap())); } Err(_) => reply(req, 404, json!({"status": "error", "error": "no such texture"})) }+ }+ _ => reply(req, 404, json!({"status": "error", "error": format!("no route {method} {path}"), "hint": "GET / (the app), /state, /console, /events, /fields.json, /tex/<name>; POST /ui/view, /ui/select, /ui/issue, /ui/toast, /console"})),+ }+}++pub fn ui(what: UiCmd, port: u16) {+ let up = |p: &str, v: &Value| -> Value { post(port, p, v).unwrap_or_else(|e| crate::err(&format!("the app did not answer on {port}: {e}"), &[format!("Start it: `adom-fields serve --fields <dir> --port {port} &`")])) };+ match what {+ UiCmd::View { quantity, net, layer, camera, air } => { let r = up("/ui/view", &json!({"quantity": quantity, "net": net, "layer": layer, "camera": camera, "air": air})); crate::ok(&format!("view: {}", r["view"]), &["The app repainted; a screenshot of the window shows it. `ui select --net <net>` lights one net; `ui issue --index n` flies to an issue.".into()]); }+ UiCmd::Toast { text, kind } => { up("/ui/toast", &json!({"text": text, "kind": kind})); crate::ok("toast shown", &[]); }+ UiCmd::Select { net, chip } => { let r = up("/ui/select", &json!({"net": net, "chip": chip})); crate::ok(&format!("selected: {}", r["view"]), &[]); }+ UiCmd::Issue { index } => { let r = up("/ui/issue", &json!({"index": index})); crate::ok(&format!("issue {index} in view: {}", r["view"]), &[]); }+ UiCmd::State => { let v = get(port, "/state").unwrap_or_else(|e| crate::err(&e, &[])); println!("{}", serde_json::to_string_pretty(&v).unwrap()); }+ UiCmd::Console => { let v = get(port, "/console").unwrap_or_else(|e| crate::err(&e, &[])); for l in v["lines"].as_array().cloned().unwrap_or_default() { println!("{}", l.as_str().unwrap_or("")); } }+ }+}++/// The walkthrough: overview, each field on each outer layer, the nets by relevance, the hot chips, the issues.+pub fn tour(port: u16, hold_ms: u64) {+ let f = get(port, "/fields.json").unwrap_or_else(|e| crate::err(&format!("the app did not answer on {port}: {e}"), &[format!("Start it: `adom-fields serve --fields <dir> --port {port} &`")]));+ // a calm walkthrough: few beats, each held while the board drifts a few degrees, so the clip+ // still reads as motion when the flow speeds it up ten times+ let hold = |ms: u64| std::thread::sleep(std::time::Duration::from_millis(ms));+ let step = |p: &str, v: Value, ms: u64| { let _ = post(port, p, &v); hold(ms); };+ let toast = |t: &str| { let _ = post(port, "/ui/toast", &json!({"text": t, "kind": "info"})); };+ let pours: Vec<String> = f["pourNets"].as_array().cloned().unwrap_or_default().iter().filter_map(|p| p["net"].as_str().map(str::to_string)).take(4).collect();+ let mut steps = 0;+ let settle = hold_ms.max(4000);+ let _ = post(port, "/ui/view", &json!({"drift": true}));+ toast("Heat flow in the copper, the top layer"); step("/ui/view", json!({"quantity": "heat", "net": "", "layer": "F.Cu", "camera": "top", "issue": null, "chip": null}), settle); steps += 1;+ toast("The same board, tilted"); step("/ui/view", json!({"camera": "iso"}), settle); steps += 1;+ for net in &pours { toast(&format!("The {net} pour on its own")); step("/ui/select", json!({"net": net}), settle); steps += 1; }+ toast("Turning the board over to the bottom copper"); step("/ui/view", json!({"net": "", "layer": "B.Cu", "camera": "bottom"}), settle); steps += 1;+ for net in pours.iter().take(3) { toast(&format!("{net} on the bottom")); step("/ui/select", json!({"net": net}), settle); steps += 1; }+ toast("Temperature over the whole board, still air"); step("/ui/view", json!({"quantity": "temperature", "air": "still", "net": "", "layer": "F.Cu", "camera": "top"}), settle); steps += 1;+ toast("The same board in moving air"); step("/ui/view", json!({"air": "moving"}), settle); steps += 1;+ toast("Back to the current density"); step("/ui/view", json!({"quantity": "current", "air": "still", "camera": "iso"}), settle); steps += 1;+ for (i, is) in f["issues"].as_array().cloned().unwrap_or_default().iter().enumerate().take(2) { toast(&format!("Issue {}: {}", i + 1, is["what"].as_str().unwrap_or(""))); step("/ui/issue", json!({"index": i}), settle); steps += 1; }+ step("/ui/view", json!({"camera": "iso", "issue": null, "net": ""}), settle);+ crate::ok(&format!("tour done: {steps} beats, {} ms each, the board drifting gently through every one", settle), &["Record the window while this runs (the flow's `tour fields` does it) to make it a clip.".into()]);+}
src/sexp.rsadded+39@@ -0,0 +1,39 @@+//! A small s-expression reader for .kicad_pcb files.++#[derive(Debug, Clone)]+pub enum Sx { Atom(String), Num(f64), List(Vec<Sx>) }++impl Sx {+ pub fn list(&self) -> &[Sx] { if let Sx::List(v) = self { v } else { &[] } }+ pub fn head(&self) -> &str { match self.list().first() { Some(Sx::Atom(a)) => a, _ => "" } }+ pub fn kids(&self, key: &str) -> Vec<&Sx> { self.list().iter().filter(|k| matches!(k, Sx::List(_)) && k.head() == key).collect() }+ pub fn kid(&self, key: &str) -> Option<&Sx> { self.list().iter().find(|k| matches!(k, Sx::List(_)) && k.head() == key) }+ pub fn num(&self, i: usize) -> Option<f64> { match self.list().get(i) { Some(Sx::Num(n)) => Some(*n), Some(Sx::Atom(a)) => a.parse().ok(), _ => None } }+ pub fn atom(&self, i: usize) -> Option<&str> { match self.list().get(i) { Some(Sx::Atom(a)) => Some(a), _ => None } }+ /// the last atom of the list (a net entry is `(net 3 "+VBAT")` or `(net "+VBAT")`)+ pub fn last_atom(&self) -> Option<&str> { self.list().iter().rev().find_map(|k| if let Sx::Atom(a) = k { Some(a.as_str()) } else { None }) }+}++pub fn parse(text: &str) -> Sx {+ let b = text.as_bytes();+ let mut stack: Vec<Vec<Sx>> = vec![Vec::new()];+ let mut i = 0;+ while i < b.len() {+ let c = b[i];+ if c == b'(' { stack.push(Vec::new()); i += 1; }+ else if c == b')' { let l = stack.pop().unwrap_or_default(); if let Some(top) = stack.last_mut() { top.push(Sx::List(l)); } i += 1; }+ else if c == b'"' {+ let mut j = i + 1; let mut s = String::new();+ while j < b.len() && b[j] != b'"' { if b[j] == b'\\' && j + 1 < b.len() { j += 1; } s.push(b[j] as char); j += 1; }+ stack.last_mut().unwrap().push(Sx::Atom(s)); i = j + 1;+ }+ else if c.is_ascii_whitespace() { i += 1; }+ else {+ let mut j = i; while j < b.len() && !b[j].is_ascii_whitespace() && b[j] != b'(' && b[j] != b')' { j += 1; }+ let t = &text[i..j];+ stack.last_mut().unwrap().push(match t.parse::<f64>() { Ok(n) if !t.is_empty() && t.bytes().next().map(|c| c.is_ascii_digit() || c == b'-' || c == b'.').unwrap_or(false) => Sx::Num(n), _ => Sx::Atom(t.to_string()) });+ i = j;+ }+ }+ stack.pop().and_then(|mut v| v.pop()).unwrap_or(Sx::List(vec![]))+}
src/solve.rsadded+69@@ -0,0 +1,69 @@+//! A multi-layer grid conductance system solved by preconditioned conjugate gradients.+//! Unknowns: one value per cell per layer. Edges: right and down neighbours on a layer (gx, gy),+//! the cell below on the next layer (gz), and a leak from every cell to ground.++pub struct System { pub nl: usize, pub h: usize, pub w: usize, pub gx: Vec<Vec<f64>>, pub gy: Vec<Vec<f64>>, pub gz: Vec<Vec<f64>>, pub leak: Vec<Vec<f64>> }++impl System {+ pub fn new(nl: usize, h: usize, w: usize) -> System {+ System { nl, h, w, gx: vec![vec![0.0; h * (w - 1)]; nl], gy: vec![vec![0.0; (h - 1) * w]; nl], gz: vec![vec![0.0; h * w]; nl.saturating_sub(1)], leak: vec![vec![0.0; h * w]; nl] }+ }+ pub fn n(&self) -> usize { self.nl * self.h * self.w }+ fn matvec(&self, x: &[f64], y: &mut [f64]) {+ let (h, w) = (self.h, self.w); let n = h * w;+ for k in 0..self.nl {+ let (xo, yo) = (&x[k * n..(k + 1) * n], &mut y[k * n..(k + 1) * n]);+ for i in 0..n { yo[i] = self.leak[k][i] * xo[i]; }+ let gx = &self.gx[k];+ for r in 0..h { for c in 0..w - 1 { let g = gx[r * (w - 1) + c]; if g != 0.0 { let (a, b) = (r * w + c, r * w + c + 1); let d = g * (xo[a] - xo[b]); yo[a] += d; yo[b] -= d; } } }+ let gy = &self.gy[k];+ for r in 0..h - 1 { for c in 0..w { let g = gy[r * w + c]; if g != 0.0 { let (a, b) = (r * w + c, (r + 1) * w + c); let d = g * (xo[a] - xo[b]); yo[a] += d; yo[b] -= d; } } }+ }+ for k in 0..self.nl.saturating_sub(1) {+ let gz = &self.gz[k];+ for i in 0..n { let g = gz[i]; if g != 0.0 { let (a, b) = (k * n + i, (k + 1) * n + i); let d = g * (x[a] - x[b]); y[a] += d; y[b] -= d; } }+ }+ }+ fn diagonal(&self) -> Vec<f64> {+ let (h, w) = (self.h, self.w); let n = h * w;+ let mut d = vec![0.0; self.n()];+ for k in 0..self.nl {+ for i in 0..n { d[k * n + i] = self.leak[k][i]; }+ for r in 0..h { for c in 0..w - 1 { let g = self.gx[k][r * (w - 1) + c]; d[k * n + r * w + c] += g; d[k * n + r * w + c + 1] += g; } }+ for r in 0..h - 1 { for c in 0..w { let g = self.gy[k][r * w + c]; d[k * n + r * w + c] += g; d[k * n + (r + 1) * w + c] += g; } }+ }+ for k in 0..self.nl.saturating_sub(1) { for i in 0..n { let g = self.gz[k][i]; d[k * n + i] += g; d[(k + 1) * n + i] += g; } }+ d+ }+ /// Solve A x = b to a relative residual of `tol` (Jacobi-preconditioned CG). Returns (x, iterations, residual).+ pub fn solve(&self, b: &[f64], tol: f64, max_iter: usize) -> (Vec<f64>, usize, f64) {+ let n = self.n();+ let diag = self.diagonal();+ let minv: Vec<f64> = diag.iter().map(|d| if *d > 0.0 { 1.0 / d } else { 0.0 }).collect();+ let mut x = vec![0.0; n];+ let mut r = b.to_vec();+ let mut z: Vec<f64> = r.iter().zip(&minv).map(|(a, m)| a * m).collect();+ let mut p = z.clone();+ let mut ap = vec![0.0; n];+ let bnorm = b.iter().map(|v| v * v).sum::<f64>().sqrt().max(1e-30);+ let mut rz: f64 = r.iter().zip(&z).map(|(a, b)| a * b).sum();+ let mut it = 0; let mut res = 1.0;+ while it < max_iter {+ self.matvec(&p, &mut ap);+ let pap: f64 = p.iter().zip(&ap).map(|(a, b)| a * b).sum();+ if pap.abs() < 1e-300 { break; }+ let alpha = rz / pap;+ for i in 0..n { x[i] += alpha * p[i]; r[i] -= alpha * ap[i]; }+ res = r.iter().map(|v| v * v).sum::<f64>().sqrt() / bnorm;+ it += 1;+ if res < tol { break; }+ for i in 0..n { z[i] = r[i] * minv[i]; }+ let rz2: f64 = r.iter().zip(&z).map(|(a, b)| a * b).sum();+ let beta = rz2 / rz; rz = rz2;+ for i in 0..n { p[i] = z[i] + beta * p[i]; }+ }+ (x, it, res)+ }+}++pub fn harm(a: f64, b: f64) -> f64 { if a + b > 0.0 { 2.0 * a * b / (a + b) } else { 0.0 } }
src/tex.rsadded+53@@ -0,0 +1,53 @@+//! Field grids as PNG textures: a colour map over the copper, transparent elsewhere.+use crate::raster::Grid;++const INFERNO: [[f32; 3]; 9] = [[0.001, 0.0, 0.014], [0.16, 0.04, 0.33], [0.38, 0.09, 0.44], [0.59, 0.19, 0.38], [0.79, 0.30, 0.26], [0.93, 0.47, 0.11], [0.98, 0.68, 0.06], [0.97, 0.87, 0.30], [0.99, 1.0, 0.64]];+const MAGMA: [[f32; 3]; 9] = [[0.001, 0.0, 0.014], [0.11, 0.06, 0.28], [0.29, 0.06, 0.48], [0.47, 0.11, 0.51], [0.66, 0.18, 0.47], [0.83, 0.28, 0.40], [0.96, 0.44, 0.36], [0.99, 0.65, 0.47], [0.99, 0.99, 0.75]];++fn map(t: f32, cm: &[[f32; 3]; 9]) -> [u8; 3] {+ let t = t.clamp(0.0, 1.0) * 8.0; let i = (t.floor() as usize).min(7); let f = t - i as f32;+ let (a, b) = (cm[i], cm[i + 1]);+ [((a[0] + (b[0] - a[0]) * f) * 255.0) as u8, ((a[1] + (b[1] - a[1]) * f) * 255.0) as u8, ((a[2] + (b[2] - a[2]) * f) * 255.0) as u8]+}++pub enum Scale { Log { lo: f32, hi: f32 }, Linear { hi: f32 } }++/// Write `g` as an RGBA PNG: colour where `mask` (or the value) says copper, transparent elsewhere.+pub fn write(path: &str, g: &Grid, mask: Option<&Grid>, scale: &Scale, magma: bool) -> Result<(), String> {+ let mut px = vec![0u8; g.w * g.h * 4];+ for i in 0..g.w * g.h {+ let on = mask.map(|m| m.v[i] > 0.05).unwrap_or(true);+ if !on { continue; }+ let v = g.v[i];+ let t = match scale { Scale::Log { lo, hi } => if v <= 0.0 { 0.0 } else { ((v.max(*lo) / lo).ln() / (hi / lo).ln()).clamp(0.0, 1.0) }, Scale::Linear { hi } => (v / hi.max(1e-9)).clamp(0.0, 1.0) };+ let c = map(t, if magma { &MAGMA } else { &INFERNO });+ px[i * 4] = c[0]; px[i * 4 + 1] = c[1]; px[i * 4 + 2] = c[2]; px[i * 4 + 3] = 255;+ }+ save(path, g.w, g.h, &px)+}++/// The copper itself: a flat tint where copper is, transparent elsewhere.+pub fn write_mask(path: &str, m: &Grid, rgb: [u8; 3]) -> Result<(), String> {+ let mut px = vec![0u8; m.w * m.h * 4];+ for i in 0..m.w * m.h { if m.v[i] > 0.05 { px[i * 4] = rgb[0]; px[i * 4 + 1] = rgb[1]; px[i * 4 + 2] = rgb[2]; px[i * 4 + 3] = (60.0 + 195.0 * m.v[i]) as u8; } }+ save(path, m.w, m.h, &px)+}++/// Raw values as a 16-bit grey PNG (value / hi × 65535), so the app can read the number under the cursor.+pub fn write_raw(path: &str, g: &Grid, hi: f32) -> Result<(), String> {+ let mut px = vec![0u8; g.w * g.h * 2];+ for i in 0..g.w * g.h { let v = ((g.v[i] / hi.max(1e-9)).clamp(0.0, 1.0) * 65535.0) as u16; px[i * 2] = (v >> 8) as u8; px[i * 2 + 1] = (v & 255) as u8; }+ let file = std::fs::File::create(path).map_err(|e| format!("{path}: {e}"))?;+ let mut enc = png::Encoder::new(std::io::BufWriter::new(file), g.w as u32, g.h as u32);+ enc.set_color(png::ColorType::Grayscale); enc.set_depth(png::BitDepth::Sixteen);+ let mut wr = enc.write_header().map_err(|e| e.to_string())?;+ wr.write_image_data(&px).map_err(|e| e.to_string())+}++fn save(path: &str, w: usize, h: usize, px: &[u8]) -> Result<(), String> {+ let file = std::fs::File::create(path).map_err(|e| format!("{path}: {e}"))?;+ let mut enc = png::Encoder::new(std::io::BufWriter::new(file), w as u32, h as u32);+ enc.set_color(png::ColorType::Rgba); enc.set_depth(png::BitDepth::Eight);+ let mut wr = enc.write_header().map_err(|e| e.to_string())?;+ wr.write_image_data(px).map_err(|e| e.to_string())+}
src/ui/app.jsadded+226@@ -0,0 +1,226 @@+// Adom Fields: the board in 3D with the fields on its copper, driven by the AI over the live channel.+(async () => {+ const $ = (s) => document.querySelector(s);+ const state = { view: { quantity: 'current', net: null, layer: 'F.Cu', camera: 'iso', air: 'still', issue: null, chip: null, drift: true, focus: null }, fields: null, raw: {}, tex: {}, layerMeshes: {}, light: false };+ const LAYERS = ['F.Cu', 'In1.Cu', 'In2.Cu', 'B.Cu'];+ const Z = { 'F.Cu': 0.8, 'In1.Cu': 0.6, 'In2.Cu': -0.6, 'B.Cu': -0.8 };+ const slug = (s) => s.replace(/[^A-Za-z0-9]/g, '_');+ // console to the server, so the AI reads what the UI does+ const post = (p, body) => fetch(p, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body || {}) }).then(r => r.json()).catch(() => null);+ for (const level of ['log', 'warn', 'error']) { const orig = console[level].bind(console); console[level] = (...a) => { orig(...a); post('console', { level, text: a.map(x => typeof x === 'string' ? x : JSON.stringify(x)).join(' ') }); }; }+ window.addEventListener('error', e => post('console', { level: 'error', text: e.message }));+ window.addEventListener('unhandledrejection', e => { post('console', { level: 'error', text: 'unhandled: ' + (e.reason && e.reason.stack || String(e.reason)) }); const b = $('#boardName'); if (b && b.textContent === 'loading') b.textContent = 'failed: ' + String(e.reason); });+ // toasts+ function toast(text, kind = 'info', sticky = false) { const t = document.createElement('div'); t.className = 'toast ' + kind; t.textContent = text; $('#toasts').appendChild(t); const kill = () => t.remove(); t.addEventListener('click', kill); if (!sticky) setTimeout(kill, 4200); }+ // tooltips (fixed div, clamped to the viewport)+ const tip = $('#tip'); let tipTimer = null;+ document.addEventListener('mouseover', e => { const el = e.target.closest('[data-tip]'); if (!el) return; clearTimeout(tipTimer); tipTimer = setTimeout(() => { tip.textContent = el.dataset.tip; tip.classList.add('show'); const r = el.getBoundingClientRect(); const M = 8; let x = r.left + r.width / 2 - tip.offsetWidth / 2, y = r.bottom + 10; if (y + tip.offsetHeight > innerHeight - M) y = r.top - tip.offsetHeight - 10; x = Math.max(M, Math.min(innerWidth - tip.offsetWidth - M, x)); y = Math.max(M, y); tip.style.left = x + 'px'; tip.style.top = y + 'px'; }, 600); });+ document.addEventListener('mouseout', e => { if (e.target.closest && e.target.closest('[data-tip]')) { clearTimeout(tipTimer); tip.classList.remove('show'); } });++ // ---- the canonical Adom 3D viewer (adom/adom-3d-viewer), the board as its content ----------+ // rule 2 of 3d-viewer-design: start from the canonical viewer, extend it, never a fresh scene+ await import('./vendor/3d-viewer/adom-3d-viewer-babylon9.esm.js');+ const VLIB = window.Adom3DViewerBabylon9;+ const BAB = VLIB.BABYLON;+ const host = $('#host');+ const V = VLIB.init(host, { zUp: true, showViewCube: true, showGround: false, environment: 'studio', onMeshHover: () => null });+ await V.whenReady();+ const scene = V.getScene();+ const camera = V.getCamera();+ window.fieldsViewer = V; window.fieldsCamera = camera; window.fieldsScene = scene;+ console.log('viewer', VLIB.version, 'ready');+ const fpsTimer = setInterval(() => { const e = V.getEngine(); if (e) $('#fps').textContent = e.getFps().toFixed(0) + ' fps'; }, 1000);+ void fpsTimer;++ let board = null, root = null, viaRoot = null, labels = [];+ // z up: x to the right, y up the board (KiCad's y runs down), z out of the top copper+ function boardSpace(x, y, z = 0) { return new BAB.Vector3(x - board.x0 - board.widthMm / 2, board.y0 + board.heightMm / 2 - y, z); }+ function buildBoard(f) {+ board = f.board;+ const W = board.widthMm, H = board.heightMm, T = board.thicknessMm;+ root = new BAB.TransformNode('fields-board', scene);+ const box = BAB.MeshBuilder.CreateBox('fr4_board', { width: W, height: H, depth: T }, scene);+ const bm = new BAB.PBRMaterial('fr4', scene); bm.albedoColor = new BAB.Color3(0.05, 0.16, 0.14); bm.metallic = 0; bm.roughness = 0.9; bm.alpha = 0.94; box.material = bm; box.isPickable = false; box.parent = root;+ for (const L of LAYERS) {+ const plane = BAB.MeshBuilder.CreatePlane('L-' + L, { width: W, height: H, sideOrientation: BAB.Mesh.DOUBLESIDE }, scene);+ plane.position.z = Z[L];+ const m = new BAB.StandardMaterial('M-' + L, scene); m.disableLighting = true; m.backFaceCulling = false; m.emissiveColor = new BAB.Color3(1, 1, 1); m.useAlphaFromDiffuseTexture = true;+ plane.material = m; plane.isPickable = L === 'F.Cu' || L === 'B.Cu'; plane.parent = root;+ state.layerMeshes[L] = plane;+ }+ // the vias: one merged mesh (the viewer's shadow setup has nothing to say to a merged mesh)+ const vm = new BAB.StandardMaterial('vm', scene); vm.emissiveColor = new BAB.Color3(0.5, 0.83, 0.94); vm.disableLighting = true;+ const cyls = f.vias.map(v => { const c = BAB.MeshBuilder.CreateCylinder('via', { diameter: v.size, height: T + 0.05, tessellation: 8 }, scene); c.rotation.x = Math.PI / 2; const p = boardSpace(v.x, v.y); c.position.set(p.x, p.y, 0); return c; });+ viaRoot = cyls.length ? BAB.Mesh.MergeMeshes(cyls, true, true, undefined, false, false) : new BAB.Mesh('barrel', scene);+ viaRoot.name = 'barrel'; viaRoot.material = vm; viaRoot.isPickable = false; viaRoot.parent = root;+ // the hot parts: HTML labels that follow their tab on screen+ const lab = $('#labels'); lab.innerHTML = '';+ for (const c of f.chips) { const el = document.createElement('div'); el.className = 'lbl'; lab.appendChild(el); labels.push({ el, c, at: boardSpace(c.x, c.y, T / 2 + 0.3) }); }+ scene.onAfterRenderObservable.add(() => {+ const e = V.getEngine(); const w = e.getRenderWidth(), h = e.getRenderHeight();+ for (const { el, at } of labels) { const p = BAB.Vector3.Project(at, BAB.Matrix.Identity(), scene.getTransformMatrix(), camera.viewport.toGlobal(w, h)); el.style.left = (p.x / (w / host.clientWidth)) + 'px'; el.style.top = (p.y / (h / host.clientHeight)) + 'px'; el.style.display = (p.z > 1 || p.z < 0) ? 'none' : ''; }+ });+ V.addContentRoot(root);+ V.frameModel(0.85);+ }+ // views: the viewer's ViewCube does this by hand; the drivers do it by command. One tween, held.+ const HOLD = BAB.Animation.ANIMATIONLOOPMODE_CONSTANT;+ const EASE = new BAB.CubicEase(); EASE.setEasingMode(BAB.EasingFunction.EASINGMODE_EASEINOUT);+ let tweening = 0;+ // every camera move is a slow eased tween: a snap looks fine live and turns frenetic when the+ // flow plays the clip at ten times speed+ function tween(prop, to, seconds) {+ const frames = Math.max(2, Math.round(60 * seconds));+ const from = camera[prop] instanceof BAB.Vector3 ? camera[prop].clone() : camera[prop];+ tweening++;+ BAB.Animation.CreateAndStartAnimation('cam-' + prop, camera, prop, 60, frames, from, to, HOLD, EASE, () => { tweening = Math.max(0, tweening - 1); drift.base = null; });+ }+ function goto(alpha, beta, radius, target, seconds) {+ const s = seconds || 9.0; // slow on purpose: the flow plays this clip at ten times speed+ scene.stopAnimation(camera); tweening = 0; drift.base = null;+ if (target) tween('target', target, s);+ tween('alpha', alpha, s); tween('beta', beta, s); tween('radius', radius, s);+ }+ // the drift: about six degrees of sway and a breath of tilt, over twenty seconds, so a held view+ // is never a frozen frame and never a spin+ const drift = { t: 0, base: null, last: null };+ scene.onBeforeRenderObservable.add(() => {+ if (!state.view.drift || tweening > 0 || !board) { drift.base = null; return; }+ const dt = Math.min(0.1, (V.getEngine().getDeltaTime() || 16) / 1000);+ if (!drift.base || (drift.last !== null && Math.abs(camera.alpha - drift.last) > 1e-4)) { drift.base = { a: camera.alpha, b: camera.beta }; drift.t = 0; }+ drift.t += dt;+ const a = drift.base.a + 0.10 * Math.sin(drift.t / 3.2);+ const b = drift.base.b + 0.030 * Math.sin(drift.t / 4.7);+ camera.alpha = a; camera.beta = Math.max(0.02, Math.min(Math.PI - 0.02, b));+ drift.last = camera.alpha;+ });+ // presets for the Z-up frame, measured against KiCad's own top view: alpha +90 degrees puts the+ // board's north at the top of the screen+ const NORTH = Math.PI / 2;+ function fitCamera(which) {+ if (!board) return;+ const R = Math.max(board.widthMm, board.heightMm) * 1.15;+ const home = BAB.Vector3.Zero();+ const flipping = (which === 'bottom' && camera.beta < Math.PI / 2) || (which === 'top' && camera.beta > Math.PI / 2);+ const s = flipping ? 2.6 : 9.0; // the turn-over is quick, everything else is slow+ if (which === 'top') goto(NORTH, 0.02, R * 1.05, home, s);+ else if (which === 'bottom') goto(NORTH, Math.PI - 0.02, R * 1.05, home, s);+ else if (which === 'iso') goto(NORTH - 0.55, 0.95, R * 1.25, home, s);+ else { scene.stopAnimation(camera); V.goHome(); }+ }+ function zoomTo(x, y, fromBelow) {+ // gentle: about half the board in frame, from near where the camera already is, over four seconds+ const t = boardSpace(x, y, 0);+ const R = Math.max(board.widthMm, board.heightMm);+ const beta = fromBelow ? Math.max(Math.PI - 0.75, Math.min(Math.PI - 0.35, camera.beta)) : Math.max(0.35, Math.min(0.75, camera.beta));+ goto(NORTH - 0.35, beta, R * 0.5, t, 5.5);+ }+ // textures+ function texName(L) {+ const q = state.view.quantity, ls = slug(L);+ if (q === 'current') return state.view.net ? `J-${slug(state.view.net)}-${ls}.png` : `J-${ls}.png`;+ if (q === 'heat') { const iso = state.view.net && (state.fields.pourNets || []).some(p => p.net === state.view.net); return iso ? `q-${slug(state.view.net)}-${ls}.png` : `q-${ls}.png`; }+ return (state.view.air === 'moving' ? 'Tm-' : 'T-') + ls + '.png';+ }+ function applyTextures() {+ for (const L of LAYERS) {+ const name = texName(L); const key = name;+ if (!state.tex[key]) { const t = new BAB.Texture('tex/' + name, scene, false, true, BAB.Texture.NEAREST_SAMPLINGMODE); t.hasAlpha = true; state.tex[key] = t; }+ const m = state.layerMeshes[L].material; m.diffuseTexture = state.tex[key];+ const selected = state.view.layer || 'F.Cu';+ m.alpha = (L === selected) ? 1.0 : (state.view.quantity === 'temperature' ? 0.0 : 0.35);+ if (L === 'In1.Cu' || L === 'In2.Cu') m.alpha = (L === selected) ? 1.0 : 0.0;+ }+ if (viaRoot) viaRoot.setEnabled(state.view.quantity !== 'temperature');+ for (const { el, c } of labels) { el.hidden = state.view.quantity === 'current'; el.textContent = c.reference + ' +' + (state.view.air === 'moving' ? c.riseMovingC : c.riseC).toFixed(0) + ' °C'; }+ const sc = state.fields.scales, leg = $('#legend'); leg.classList.toggle('magma', state.view.quantity === 'temperature');+ if (state.view.quantity === 'current') { $('#legendTitle').textContent = 'Current density, A/mm² (log)'; $('#legLo').textContent = (sc.jHi / 300).toFixed(1); $('#legHi').textContent = sc.jHi.toFixed(0); }+ else if (state.view.quantity === 'heat') { $('#legendTitle').textContent = 'Heat flow in the copper, W/mm (log)'; $('#legLo').textContent = (sc.qHi / 300).toExponential(1); $('#legHi').textContent = sc.qHi.toFixed(3); }+ else { $('#legendTitle').textContent = 'Rise above ambient, °C (' + state.view.air + ' air)'; $('#legLo').textContent = '0'; $('#legHi').textContent = sc.tHi.toFixed(0); }+ $('#airSec').style.display = state.view.quantity === 'temperature' ? '' : 'none';+ loadRaw();+ }+ function rawName(L) { const q = state.view.quantity, ls = slug(L); return (q === 'current' ? 'J-' : q === 'heat' ? 'q-' : (state.view.air === 'moving' ? 'Tm-' : 'T-')) + ls + '.raw.png'; }+ function loadRaw() {+ for (const L of ['F.Cu', 'B.Cu']) {+ const name = rawName(L); if (state.raw[name]) continue; state.raw[name] = { pending: true };+ const img = new Image(); img.crossOrigin = 'anonymous'; img.src = 'tex/' + name;+ img.onload = () => { const c = document.createElement('canvas'); c.width = img.width; c.height = img.height; const ctx = c.getContext('2d'); ctx.drawImage(img, 0, 0); const d = ctx.getImageData(0, 0, c.width, c.height).data; state.raw[name] = { w: c.width, h: c.height, d }; };+ }+ }+ scene.onPointerObservable.add((pi) => {+ if (pi.type !== BAB.PointerEventTypes.POINTERMOVE || !board) return;+ const pick = scene.pick(scene.pointerX, scene.pointerY, (m) => m.name.startsWith('L-'));+ const ro = $('#readout');+ if (!pick || !pick.hit) { ro.innerHTML = '<span class="k">hover the board</span>'; return; }+ const L = pick.pickedMesh.name.slice(2); const p = pick.pickedPoint;+ const x = p.x + board.x0 + board.widthMm / 2, y = board.y0 + board.heightMm / 2 - p.y;+ const raw = state.raw[rawName(L)]; let val = '';+ if (raw && raw.d) { const c = Math.min(raw.w - 1, Math.max(0, Math.floor((x - board.x0) / board.cellMm))), r = Math.min(raw.h - 1, Math.max(0, Math.floor((y - board.y0) / board.cellMm))); const i = (r * raw.w + c) * 4; const v = raw.d[i] / 255; const sc = state.fields.scales; const hi = state.view.quantity === 'current' ? sc.jHi : state.view.quantity === 'heat' ? sc.qHi : sc.tHi; const num = v * hi; val = state.view.quantity === 'current' ? num.toFixed(1) + ' A/mm²' : state.view.quantity === 'heat' ? num.toFixed(4) + ' W/mm' : '+' + num.toFixed(1) + ' °C'; }+ ro.innerHTML = `<span class="k">${L}</span> ${val}<br><span class="k">x</span> ${x.toFixed(2)} <span class="k">y</span> ${y.toFixed(2)} mm`;+ });++ // ---- panels ----------------------------------------------------------------------------+ function renderPanels() {+ const f = state.fields, v = state.view;+ for (const b of document.querySelectorAll('#qty button')) { b.classList.toggle('is-on', b.dataset.q === v.quantity); b.setAttribute('aria-pressed', b.dataset.q === v.quantity); }+ for (const b of document.querySelectorAll('#layer button')) { const on = b.dataset.l === (v.layer || 'F.Cu'); b.classList.toggle('is-on', on); b.setAttribute('aria-pressed', on); }+ for (const b of document.querySelectorAll('#air button')) { b.classList.toggle('is-on', b.dataset.a === v.air); b.setAttribute('aria-pressed', b.dataset.a === v.air); }+ for (const b of document.querySelectorAll('.views .chip')) b.classList.toggle('is-on', b.dataset.cam === v.camera);+ const jmax = Math.max(1, ...f.nets.map(n => n.peakJ));+ $('#nets').innerHTML = f.nets.map(n => `<div class="row ${v.net === n.net ? 'is-on' : ''}" data-net="${n.net}" data-tip="${n.amps} A on ${n.layers.join(', ')}; ${n.copperMm2} mm² of copper, ${n.vias} vias, ${n.jouleW} W of Joule loss. Click to light this net alone."><div style="flex:1;min-width:0"><div class="name">${n.net}</div><div class="bar" style="width:${(n.peakJ / jmax * 100).toFixed(0)}%"></div></div><div class="num">${n.amps} A · ${n.peakJ.toFixed(0)} A/mm²</div></div>`).join('') || '<div class="empty">no loaded nets in the spec</div>';+ $('#chips').innerHTML = f.chips.map(c => `<div class="row ${v.chip === c.reference ? 'is-on' : ''}" data-chip="${c.reference}" data-tip="${c.watts} W on ${c.tabNet}: ${(c.planeW / c.watts * 100).toFixed(0)}% spreads along F.Cu, ${(c.viaW / c.watts * 100).toFixed(0)}% goes down its ${c.vias} own via(s) on ${c.tabNet} (spec asks ${c.viasWanted}), ${(c.otherViaW / c.watts * 100).toFixed(0)}% through ${c.otherVias} other nets' barrel(s) nearby, ${(c.dielW / c.watts * 100).toFixed(0)}% through the bare dielectric. +${c.riseMovingC} °C in moving air."><div class="name">${c.reference} <span class="num">${c.watts} W</span></div><div class="num" style="color:${c.riseC > f.heat.budgetC ? 'var(--red)' : 'var(--text)'}">+${c.riseC.toFixed(0)} °C</div></div>`).join('') || '<div class="empty">no hot parts in the spec</div>';+ $('#issues').innerHTML = f.issues.map((i, k) => `<div class="issue ${v.issue === k ? 'is-on' : ''}" data-issue="${k}"><span class="sev ${i.severity}">${i.severity}</span> <span class="num" style="font-family:JetBrains Mono;font-size:11px;color:var(--text-2)">${i.kind}</span><div class="what">${i.what}</div><div class="fix">${i.fix}</div></div>`).join('') || '<div class="empty">No issues found: every loaded net stays under 60 A/mm² and every hot tab within its budget.</div>';+ $('#method').textContent = f.method + ` Solved in ${f.seconds} s on a ${f.board.cellMm} mm grid (${f.board.cols} x ${f.board.rows} cells x 4 layers).`;+ }+ async function setView(patch) { const r = await post('ui/view', patch); if (!r) toast('The server did not answer', 'error', true); }+ document.addEventListener('click', e => {+ const q = e.target.closest('#qty button'); if (q) return setView({ quantity: q.dataset.q, issue: null });+ const l = e.target.closest('#layer button'); if (l) return setView({ layer: l.dataset.l });+ const a = e.target.closest('#air button'); if (a) return setView({ air: a.dataset.a });+ const cam = e.target.closest('.views .chip'); if (cam) return setView({ camera: cam.dataset.cam });+ const n = e.target.closest('[data-net]'); if (n) return post('ui/select', { net: state.view.net === n.dataset.net ? '' : n.dataset.net });+ const c = e.target.closest('[data-chip]'); if (c) return post('ui/select', { chip: c.dataset.chip });+ const is = e.target.closest('[data-issue]'); if (is) return post('ui/issue', { index: +is.dataset.issue });+ if (e.target.closest('#btnNets')) { $('#left').classList.toggle('is-open'); $('#right').classList.remove('is-open'); }+ if (e.target.closest('#btnIssues')) { $('#right').classList.toggle('is-open'); $('#left').classList.remove('is-open'); }+ if (e.target.closest('#btnLight')) { state.light = !state.light; V.setBottomLight(state.light); const b = $('#btnLight'); b.classList.toggle('is-on', state.light); b.setAttribute('aria-pressed', state.light); toast(state.light ? 'Underside light on' : 'Underside light off'); }+ });+ function applyView(v, first) {+ // the same command twice (same seq) is a no-op: a poll or a reconnect never moves the camera+ if (!first && v.seq !== undefined && v.seq === state.view.seq) return;+ const prevFocus = JSON.stringify(state.view.focus);+ state.view = Object.assign({}, state.view, v);+ renderPanels(); applyTextures();+ const changed = (v.changed || []);+ const moveCamera = first || changed.includes('camera') || changed.includes('focus');+ if (!moveCamera) return;+ if (state.view.camera === 'zoom' && state.view.focus) { zoomTo(state.view.focus[0], state.view.focus[1], (state.view.layer || 'F.Cu') === 'B.Cu'); }+ else if (state.view.camera) fitCamera(state.view.camera);+ if (changed.includes('chip') && state.view.chip) { const c = state.fields.chips.find(x => x.reference === state.view.chip); if (c) toast(`${c.reference}: ${c.watts} W, +${c.riseC} °C at its tab in still air, ${(c.viaW / c.watts * 100).toFixed(0)}% of its heat down ${c.vias} via(s)`); }+ void prevFocus;+ }+ // ---- the live channel ------------------------------------------------------------------+ function connect() {+ const es = new EventSource('events');+ es.addEventListener('view', e => applyView(JSON.parse(e.data)));+ es.addEventListener('toast', e => { const d = JSON.parse(e.data); toast(d.text, d.kind, d.sticky); });+ es.addEventListener('state', e => { const d = JSON.parse(e.data); if (d.view) applyView(d.view, false); $('#liveDot').classList.remove('off'); $('#liveText').textContent = 'live channel'; });+ es.onerror = () => { $('#liveDot').classList.add('off'); $('#liveText').textContent = 'reconnecting'; };+ }+ async function boot() {+ const f = await fetch('fields.json').then(r => r.json());+ state.fields = f;+ $('#boardName').textContent = f.board.name || 'board';+ $('#boardMeta').textContent = `${f.board.widthMm.toFixed(0)} x ${f.board.heightMm.toFixed(0)} mm · ${f.board.copperUm} µm · ${f.nets.length} nets · ${f.chips.length} hot parts · ${f.issues.length} issues`;+ $('#solveInfo').textContent = `peak ${f.scales.jMax.toFixed(0)} A/mm² · +${f.heat.peakRiseC.toFixed(0)} °C still, +${f.heat.peakRiseMovingC.toFixed(0)} °C moving · budget ${f.heat.budgetC} °C`;+ $('#ver').textContent = '';+ buildBoard(f);+ const st = await fetch('state').then(r => r.json()).catch(() => null);+ applyView(st && st.view ? st.view : {}, true);+ connect();+ // a poll as the fallback for proxies that buffer SSE+ setInterval(async () => { const s = await fetch('state').then(r => r.json()).catch(() => null); if (s && s.view && s.view.seq !== state.view.seq) applyView(s.view, false); }, 1500);+ }+ boot();+})();
src/ui/index.htmladded+120@@ -0,0 +1,120 @@+<!doctype html>+<html lang="en">+<head>+<meta charset="utf-8">+<meta name="viewport" content="width=device-width, initial-scale=1">+<title>Adom Fields</title>+<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23e6edf3' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='3' y='5' width='18' height='14' rx='2'/%3E%3Cpath d='M6 15c2-4 4-4 6 0s4 4 6 0'/%3E%3C/svg%3E">+<link rel="stylesheet" href="https://adom.inc/fonts/fonts.css">+<link rel="stylesheet" href="vendor/3d-viewer/style.css">+<script>window.process = window.process || { env: { NODE_ENV: 'production' } }; window.global = window.global || window;</script>+<style>+:root{--bg:#0d1117;--surface:#161b22;--elevated:#1c2128;--overlay:#21262d;--border:#30363d;--text:#e6edf3;--text-body:#c9d1d9;--text-2:#8b949e;--accent:#00b8b1;--accent-hover:#00d4cb;--on-accent:#05221f;--green:#3fb950;--red:#f85149;--yellow:#d29922;--blue:#64abff;--purple:#8c6bf7}+*{box-sizing:border-box}+body{margin:0;background:var(--bg);color:var(--text-body);font-family:"Satoshi",system-ui,sans-serif;font-size:14px}+.app{position:fixed;inset:0;display:flex;flex-direction:column}+.app-header{flex:0 0 44px;display:flex;align-items:center;gap:12px;padding:0 12px;background:var(--surface);border-bottom:1px solid var(--border)}+.brand{display:flex;align-items:center;gap:8px;font-family:"Familjen Grotesk",sans-serif;font-weight:600;color:var(--accent);font-size:15px;position:relative;cursor:default}+.brand svg{width:16px;height:16px;stroke:var(--text);fill:none;stroke-width:1.8}+.brand .sub{color:var(--text-2);font-weight:500}+.vsep{width:1px;height:24px;background:var(--border)}+.subject{display:flex;align-items:baseline;gap:10px;min-width:0}+.subject b{color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}+.subject .meta{font-family:"JetBrains Mono",monospace;font-size:12px;color:var(--text-2)}+@media(max-width:720px){.subject .meta{display:none}}+.actions{margin-left:auto;display:flex;gap:6px}+.ibtn{width:32px;height:32px;border-radius:8px;border:1px solid transparent;background:transparent;color:var(--text);display:grid;place-items:center;cursor:pointer;transition:background .15s ease}+.ibtn:hover{background:var(--elevated)}.ibtn:active{background:var(--overlay)}.ibtn.is-on{background:var(--elevated);border-color:var(--accent);color:var(--accent-hover);box-shadow:inset 0 0 0 1px rgba(0,184,177,.3)}+.ibtn svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round}+.popover{position:absolute;top:36px;left:0;background:var(--overlay);border:1px solid var(--border);border-radius:8px;padding:10px 12px;width:280px;font-family:"Satoshi";font-weight:400;color:var(--text-body);font-size:13px;display:none;z-index:50;pointer-events:auto;line-height:1.5}+.brand:hover .popover{display:block}+.popover a{color:var(--blue)}+.app-body{flex:1 1 auto;min-height:0;display:grid;grid-template-columns:300px minmax(0,1fr) 320px}+.side{display:flex;flex-direction:column;min-height:0;background:var(--surface);border-right:1px solid var(--border);overflow:auto}+.side.right{border-right:0;border-left:1px solid var(--border)}+.sec{padding:10px 12px;border-bottom:1px solid var(--border)}+.sec h3{margin:0 0 6px;font-family:"Familjen Grotesk";font-weight:600;font-size:13px;color:var(--text);letter-spacing:.02em}+.seg{display:flex;gap:4px;background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:3px}+.seg button{flex:1;border:0;background:transparent;color:var(--text-2);padding:6px 4px;border-radius:6px;cursor:pointer;font:inherit;font-size:13px;transition:background .15s ease,color .15s ease}+.seg button:hover{color:var(--text);background:var(--elevated)}+.seg button.is-on{background:var(--elevated);color:var(--accent-hover);box-shadow:inset 0 0 0 1px var(--accent)}+.row{display:flex;align-items:center;gap:8px;padding:7px 8px;border-radius:8px;cursor:pointer;transition:background .15s ease}+.row:hover{background:var(--elevated)}.row.is-on{background:var(--elevated);box-shadow:inset 0 0 0 1px var(--accent)}+.row .name{flex:1;color:var(--text);font-weight:500;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}+.row .num{font-family:"JetBrains Mono";font-size:12px;color:var(--text-2);white-space:nowrap}+.bar{height:4px;border-radius:2px;background:var(--accent);opacity:.8}+.issue{padding:8px;border-radius:8px;border:1px solid var(--border);margin-bottom:8px;cursor:pointer;transition:background .15s ease}+.issue:hover{background:var(--elevated)}.issue.is-on{box-shadow:inset 0 0 0 1px var(--accent)}+.issue .sev{display:inline-block;font-size:11px;padding:1px 7px;border-radius:999px;margin-bottom:4px;font-family:"JetBrains Mono"}+.sev.high{background:rgba(248,81,73,.15);color:var(--red)}.sev.low{background:rgba(139,148,158,.15);color:var(--text-2)}.sev.medium{background:rgba(210,153,34,.15);color:var(--yellow)}+.issue .what{color:var(--text);font-size:13px}.issue .fix{color:var(--text-2);font-size:12px;margin-top:4px}+.stage{position:relative;min-width:0;min-height:0}+.host{position:absolute;inset:0}+.labels{position:absolute;inset:0;pointer-events:none;overflow:hidden}+.lbl{position:absolute;transform:translate(-50%,-140%);font-family:"JetBrains Mono",monospace;font-size:12px;color:var(--text);background:rgba(13,17,23,.75);border:1px solid var(--border);border-radius:6px;padding:2px 7px;white-space:nowrap}+.legend{position:absolute;right:16px;bottom:16px;background:rgba(22,27,34,.85);border:1px solid var(--border);border-radius:8px;padding:8px 10px;font-size:12px;color:var(--text-body);backdrop-filter:blur(4px);min-width:170px}+.legend .ramp{height:10px;border-radius:5px;margin:6px 0;background:linear-gradient(90deg,#000004,#2a0a4a,#621a5f,#97305f,#c94d3f,#ee7a1c,#f9ad10,#f7de4d,#fcffa4)}+.legend.magma .ramp{background:linear-gradient(90deg,#000004,#1c1047,#4a0c7a,#781c81,#a82e78,#d44866,#f5705c,#fca678,#fcfdbf)}+.legend .ends{display:flex;justify-content:space-between;font-family:"JetBrains Mono";font-size:11px;color:var(--text-2)}+.readout{position:absolute;left:16px;bottom:16px;background:rgba(22,27,34,.85);border:1px solid var(--border);border-radius:8px;padding:8px 10px;font-family:"JetBrains Mono";font-size:12px;color:var(--text);min-width:160px;backdrop-filter:blur(4px)}+.readout .k{color:var(--text-2)}+.views{position:absolute;left:16px;top:12px;display:flex;gap:4px}+.chip{background:rgba(22,27,34,.85);border:1px solid var(--border);border-radius:8px;padding:5px 9px;font-size:12px;color:var(--text);cursor:pointer;transition:background .15s ease}+.chip:hover{background:var(--elevated)}.chip.is-on{box-shadow:inset 0 0 0 1px var(--accent);color:var(--accent-hover)}+.toasts{position:fixed;left:50%;bottom:20px;transform:translateX(-50%);display:flex;flex-direction:column-reverse;gap:8px;z-index:100;pointer-events:none}+.toast{pointer-events:auto;background:var(--overlay);border:1px solid var(--border);border-left:3px solid var(--accent);color:var(--text);padding:9px 14px;border-radius:8px;font-size:13px;box-shadow:0 8px 24px rgba(0,0,0,.4);animation:in .18s ease;max-width:560px}+.toast.success{border-left-color:var(--green)}.toast.error{border-left-color:var(--red)}+@keyframes in{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:none}}+.tip{position:fixed;z-index:99999;background:var(--overlay);border:1px solid var(--border);border-radius:6px;padding:6px 9px;font-size:12px;color:var(--text-body);max-width:280px;pointer-events:none;opacity:0;transition:opacity .18s ease,transform .18s ease;transform:translateY(3px)}+.tip.show{opacity:1;transform:none}+.empty{padding:12px;color:var(--text-2);font-size:13px}+.app-footer{flex:0 0 26px;display:flex;align-items:center;gap:14px;padding:0 12px;background:var(--surface);border-top:1px solid var(--border);font-family:"JetBrains Mono";font-size:11px;color:var(--text-2)}+.dot{width:8px;height:8px;border-radius:50%;background:var(--green);display:inline-block;margin-right:6px}+.dot.off{background:var(--red)}+/* the phone: one column, the stage full height, the side panels as sheets from the header buttons */+@media(max-width:900px){.app-body{grid-template-columns:minmax(0,1fr)}.side{display:none}.side.is-open{display:flex;position:fixed;inset:44px 0 26px 0;z-index:20}.stage{min-height:0}}+</style>+</head>+<body>+<div class="app">+ <header class="app-header">+ <div class="brand" id="brand">+ <svg viewBox="0 0 24 24"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="M6 15c2-4 4-4 6 0s4 4 6 0"/></svg>+ <span>Fields</span><span class="sub">· current and heat</span>+ <div class="popover" id="pop">Adom Fields <span id="ver"></span><br>Current density, copper heat flow and board temperature solved from the board file. Dark, Babylon, driven by the AI over its live channel.<br><a href="https://wiki.adom.inc/adom/adom-fields" target="_blank" rel="noopener">wiki.adom.inc/adom/adom-fields</a></div>+ </div>+ <div class="vsep"></div>+ <div class="subject"><b id="boardName">loading</b><span class="meta" id="boardMeta"></span></div>+ <div class="actions">+ <button class="ibtn" id="btnNets" data-tip="Nets and layers (phone)" aria-pressed="false"><svg viewBox="0 0 24 24"><path d="M4 6h16M4 12h16M4 18h16"/></svg></button>+ <button class="ibtn" id="btnIssues" data-tip="Issues (phone)" aria-pressed="false"><svg viewBox="0 0 24 24"><path d="M12 9v4M12 17h.01M10.3 3.9 2.5 17a2 2 0 0 0 1.7 3h15.6a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z"/></svg></button>+ <button class="ibtn" id="btnLight" data-tip="Light the underside (dark parts)" aria-pressed="false"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M2 12h2M20 12h2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg></button>+ </div>+ </header>+ <div class="app-body">+ <aside class="side" id="left">+ <div class="sec"><h3>Field</h3><div class="seg" id="qty"><button data-q="current" data-tip="Current density on the copper, amps per square millimetre, every loaded net at its spec current">Current</button><button data-q="heat" data-tip="Heat flow in the copper, watts per millimetre of width, from the hot tabs and the Joule loss">Heat</button><button data-q="temperature" data-tip="Temperature rise over the board, the FR4 spreading">Temperature</button></div></div>+ <div class="sec"><h3>Layer</h3><div class="seg" id="layer"><button data-l="F.Cu">F.Cu</button><button data-l="In1.Cu">In1</button><button data-l="In2.Cu">In2</button><button data-l="B.Cu">B.Cu</button></div></div>+ <div class="sec" id="airSec"><h3>Air</h3><div class="seg" id="air"><button data-a="still" data-tip="Still air, 10 W/m²K on both faces">Still</button><button data-a="moving" data-tip="Moving air, 25 W/m²K: a fan or a moving vehicle">Moving</button></div></div>+ <div class="sec"><h3>Nets, by what they carry</h3><div id="nets"></div></div>+ <div class="sec"><h3>Hot parts, hottest first</h3><div id="chips"></div></div>+ </aside>+ <main class="stage">+ <div id="host" class="host"></div>+ <div id="labels" class="labels"></div>+ <div class="views"><span class="chip" data-cam="top" data-tip="Straight down at the top copper">Top</span><span class="chip" data-cam="iso" data-tip="Three-quarter view">Iso</span><span class="chip" data-cam="bottom" data-tip="The bottom copper, from below">Bottom</span><span class="chip" data-cam="fit" data-tip="Fit the board">Fit</span></div>+ <div class="readout" id="readout"><span class="k">hover the board</span></div>+ <div class="legend" id="legend"><div id="legendTitle">Current, A/mm²</div><div class="ramp"></div><div class="ends"><span id="legLo">0</span><span id="legHi">0</span></div></div>+ </main>+ <aside class="side right" id="right">+ <div class="sec"><h3>Issues</h3><div id="issues"></div></div>+ <div class="sec"><h3>How it was solved</h3><div class="empty" id="method"></div></div>+ </aside>+ </div>+ <footer class="app-footer"><span><span class="dot" id="liveDot"></span><span id="liveText">live channel</span></span><span id="solveInfo"></span><span style="margin-left:auto" id="fps"></span></footer>+</div>+<div class="toasts" id="toasts"></div>+<div class="tip" id="tip"></div>+<script type="module" src="app.js"></script>+</body>+</html>
src/vendor.rsadded+66@@ -0,0 +1,66 @@+// generated: the canonical Adom 3D viewer bundle (adom/adom-3d-viewer), served under /vendor/3d-viewer/+pub fn vendor_file(name: &str) -> Option<(&'static [u8], &'static str)> {+ match name {+ "adom-3d-viewer-babylon9.esm.js" => Some((include_bytes!("../vendor/3d-viewer/adom-3d-viewer-babylon9.esm.js"), "application/javascript")),+ "chunk--zVGBloJ.js" => Some((include_bytes!("../vendor/3d-viewer/chunk--zVGBloJ.js"), "application/javascript")),+ "chunk-2Bu7A683.js" => Some((include_bytes!("../vendor/3d-viewer/chunk-2Bu7A683.js"), "application/javascript")),+ "chunk-95gtpT26.js" => Some((include_bytes!("../vendor/3d-viewer/chunk-95gtpT26.js"), "application/javascript")),+ "chunk-B3LIAFbR.js" => Some((include_bytes!("../vendor/3d-viewer/chunk-B3LIAFbR.js"), "application/javascript")),+ "chunk-B58rTJxt.js" => Some((include_bytes!("../vendor/3d-viewer/chunk-B58rTJxt.js"), "application/javascript")),+ "chunk-B8lFS_lF.js" => Some((include_bytes!("../vendor/3d-viewer/chunk-B8lFS_lF.js"), "application/javascript")),+ "chunk-BAPNRay7.js" => Some((include_bytes!("../vendor/3d-viewer/chunk-BAPNRay7.js"), "application/javascript")),+ "chunk-BD0ToZih.js" => Some((include_bytes!("../vendor/3d-viewer/chunk-BD0ToZih.js"), "application/javascript")),+ "chunk-BDtDPSsQ.js" => Some((include_bytes!("../vendor/3d-viewer/chunk-BDtDPSsQ.js"), "application/javascript")),+ "chunk-BL3ZXnwJ.js" => Some((include_bytes!("../vendor/3d-viewer/chunk-BL3ZXnwJ.js"), "application/javascript")),+ "chunk-BPVajWoy.js" => Some((include_bytes!("../vendor/3d-viewer/chunk-BPVajWoy.js"), "application/javascript")),+ "chunk-BSpVM8RT.js" => Some((include_bytes!("../vendor/3d-viewer/chunk-BSpVM8RT.js"), "application/javascript")),+ "chunk-BT__9Y_c.js" => Some((include_bytes!("../vendor/3d-viewer/chunk-BT__9Y_c.js"), "application/javascript")),+ "chunk-BZqF2SE7.js" => Some((include_bytes!("../vendor/3d-viewer/chunk-BZqF2SE7.js"), "application/javascript")),+ "chunk-BeJ6MiKR.js" => Some((include_bytes!("../vendor/3d-viewer/chunk-BeJ6MiKR.js"), "application/javascript")),+ "chunk-BehQJ8LU.js" => Some((include_bytes!("../vendor/3d-viewer/chunk-BehQJ8LU.js"), "application/javascript")),+ "chunk-BnR4s4NS.js" => Some((include_bytes!("../vendor/3d-viewer/chunk-BnR4s4NS.js"), "application/javascript")),+ "chunk-BtcNOITX.js" => Some((include_bytes!("../vendor/3d-viewer/chunk-BtcNOITX.js"), "application/javascript")),+ "chunk-Byz74nJx.js" => Some((include_bytes!("../vendor/3d-viewer/chunk-Byz74nJx.js"), "application/javascript")),+ "chunk-BzZAVx39.js" => Some((include_bytes!("../vendor/3d-viewer/chunk-BzZAVx39.js"), "application/javascript")),+ "chunk-CABL5SH3.js" => Some((include_bytes!("../vendor/3d-viewer/chunk-CABL5SH3.js"), "application/javascript")),+ "chunk-CFv9RCb1.js" => Some((include_bytes!("../vendor/3d-viewer/chunk-CFv9RCb1.js"), "application/javascript")),+ "chunk-CMduw_JL.js" => Some((include_bytes!("../vendor/3d-viewer/chunk-CMduw_JL.js"), "application/javascript")),+ "chunk-CNFNZwoB.js" => Some((include_bytes!("../vendor/3d-viewer/chunk-CNFNZwoB.js"), "application/javascript")),+ "chunk-CNGGFOuW.js" => Some((include_bytes!("../vendor/3d-viewer/chunk-CNGGFOuW.js"), "application/javascript")),+ "chunk-CPfOhNKz.js" => Some((include_bytes!("../vendor/3d-viewer/chunk-CPfOhNKz.js"), "application/javascript")),+ "chunk-CTYaVcZa.js" => Some((include_bytes!("../vendor/3d-viewer/chunk-CTYaVcZa.js"), "application/javascript")),+ "chunk-CXQGPeMI.js" => Some((include_bytes!("../vendor/3d-viewer/chunk-CXQGPeMI.js"), "application/javascript")),+ "chunk-CZkmtv97.js" => Some((include_bytes!("../vendor/3d-viewer/chunk-CZkmtv97.js"), "application/javascript")),+ "chunk-CbHokeyF.js" => Some((include_bytes!("../vendor/3d-viewer/chunk-CbHokeyF.js"), "application/javascript")),+ "chunk-CbtlRJeg.js" => Some((include_bytes!("../vendor/3d-viewer/chunk-CbtlRJeg.js"), "application/javascript")),+ "chunk-CfwaHoJi.js" => Some((include_bytes!("../vendor/3d-viewer/chunk-CfwaHoJi.js"), "application/javascript")),+ "chunk-Cg2pepQI.js" => Some((include_bytes!("../vendor/3d-viewer/chunk-Cg2pepQI.js"), "application/javascript")),+ "chunk-CoZk51t7.js" => Some((include_bytes!("../vendor/3d-viewer/chunk-CoZk51t7.js"), "application/javascript")),+ "chunk-Cs5ZTYBA.js" => Some((include_bytes!("../vendor/3d-viewer/chunk-Cs5ZTYBA.js"), "application/javascript")),+ "chunk-CsxGQrWe.js" => Some((include_bytes!("../vendor/3d-viewer/chunk-CsxGQrWe.js"), "application/javascript")),+ "chunk-CwI7Y85l.js" => Some((include_bytes!("../vendor/3d-viewer/chunk-CwI7Y85l.js"), "application/javascript")),+ "chunk-D0TCgiF9.js" => Some((include_bytes!("../vendor/3d-viewer/chunk-D0TCgiF9.js"), "application/javascript")),+ "chunk-DAX2gfnH.js" => Some((include_bytes!("../vendor/3d-viewer/chunk-DAX2gfnH.js"), "application/javascript")),+ "chunk-DGdtbGMZ.js" => Some((include_bytes!("../vendor/3d-viewer/chunk-DGdtbGMZ.js"), "application/javascript")),+ "chunk-DH08q4DA.js" => Some((include_bytes!("../vendor/3d-viewer/chunk-DH08q4DA.js"), "application/javascript")),+ "chunk-DLI71DQk.js" => Some((include_bytes!("../vendor/3d-viewer/chunk-DLI71DQk.js"), "application/javascript")),+ "chunk-DNtwJqMb.js" => Some((include_bytes!("../vendor/3d-viewer/chunk-DNtwJqMb.js"), "application/javascript")),+ "chunk-DQwCK2fE.js" => Some((include_bytes!("../vendor/3d-viewer/chunk-DQwCK2fE.js"), "application/javascript")),+ "chunk-DS2cpRyz.js" => Some((include_bytes!("../vendor/3d-viewer/chunk-DS2cpRyz.js"), "application/javascript")),+ "chunk-DWpO2uaT.js" => Some((include_bytes!("../vendor/3d-viewer/chunk-DWpO2uaT.js"), "application/javascript")),+ "chunk-DYEPDroP.js" => Some((include_bytes!("../vendor/3d-viewer/chunk-DYEPDroP.js"), "application/javascript")),+ "chunk-DqGDxq8q.js" => Some((include_bytes!("../vendor/3d-viewer/chunk-DqGDxq8q.js"), "application/javascript")),+ "chunk-DwxqugCu.js" => Some((include_bytes!("../vendor/3d-viewer/chunk-DwxqugCu.js"), "application/javascript")),+ "chunk-Kw3zXcCd.js" => Some((include_bytes!("../vendor/3d-viewer/chunk-Kw3zXcCd.js"), "application/javascript")),+ "chunk-OPfAWzis.js" => Some((include_bytes!("../vendor/3d-viewer/chunk-OPfAWzis.js"), "application/javascript")),+ "chunk-SbdENO2g.js" => Some((include_bytes!("../vendor/3d-viewer/chunk-SbdENO2g.js"), "application/javascript")),+ "chunk-YOUcWQXB.js" => Some((include_bytes!("../vendor/3d-viewer/chunk-YOUcWQXB.js"), "application/javascript")),+ "chunk-ZIZN2npy.js" => Some((include_bytes!("../vendor/3d-viewer/chunk-ZIZN2npy.js"), "application/javascript")),+ "chunk-eLLEdD1j.js" => Some((include_bytes!("../vendor/3d-viewer/chunk-eLLEdD1j.js"), "application/javascript")),+ "chunk-oUT14Vjx.js" => Some((include_bytes!("../vendor/3d-viewer/chunk-oUT14Vjx.js"), "application/javascript")),+ "chunk-rZDdddZu.js" => Some((include_bytes!("../vendor/3d-viewer/chunk-rZDdddZu.js"), "application/javascript")),+ "chunk-tBqaCeUh.js" => Some((include_bytes!("../vendor/3d-viewer/chunk-tBqaCeUh.js"), "application/javascript")),+ "style.css" => Some((include_bytes!("../vendor/3d-viewer/style.css"), "text/css")),+ _ => None,+ }+}
tools/fetch-viewer.shadded+26@@ -0,0 +1,26 @@+#!/bin/bash+# The canonical Adom 3D viewer is vendored, not copied into git: this fetches the pinned release+# into vendor/3d-viewer/ and regenerates src/vendor.rs, which embeds every file in the binary.+set -e+VER="${1:-0.7.1}"+HERE="$(cd "$(dirname "$0")/.." && pwd)"+cd "$HERE"+mkdir -p vendor/3d-viewer+TMP="$(mktemp -d)"+( cd "$TMP" && adom-wiki release download adom/adom-3d-viewer "$VER" >/dev/null )+tar -xzf "$TMP"/adom-3d-viewer-babylon9-"$VER".tar.gz -C vendor/3d-viewer+rm -rf "$TMP"+python3 - "$HERE" <<'PY'+import os, sys+here = sys.argv[1]+files = sorted(os.listdir(os.path.join(here, 'vendor/3d-viewer')))+ct = lambda f: ('application/javascript' if f.endswith('.js') else 'text/css' if f.endswith('.css')+ else 'application/json' if f.endswith('.json') else 'application/wasm' if f.endswith('.wasm')+ else 'application/octet-stream' if f.endswith('.env') else 'text/plain')+lines = ["// generated by tools/fetch-viewer.sh: the canonical Adom 3D viewer (adom/adom-3d-viewer), served under /vendor/3d-viewer/",+ "pub fn vendor_file(name: &str) -> Option<(&'static [u8], &'static str)> {", " match name {"]+lines += [f' "{f}" => Some((include_bytes!("../vendor/3d-viewer/{f}"), "{ct(f)}")),' for f in files]+lines += [" _ => None,", " }", "}"]+open(os.path.join(here, 'src/vendor.rs'), 'w').write("\n".join(lines) + "\n")+print(f"OK: {len(files)} viewer files vendored and embedded")+PY
uninstall.sh+1−3@@ -1,4 +1,2 @@ #!/bin/bash-rm -f "$HOME/.local/bin/adom-aiflow"-rm -rf "$HOME/.claude/skills/adom-aiflow" "$HOME/.claude/skills/aiflow-measurement"-echo "OK: adom-aiflow removed."+rm -f "$HOME/.local/bin/adom-fields"; rm -rf "$HOME/.claude/skills/adom-fields"; echo "OK: adom-fields removed"