app
AI Flow
Public Made by Adomby adom
Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board.
← Commit history
Merge PR #191: adom-aiflow fusion: the whole Fusion project as commands (library, reference, schematic, reset, export) and refine hints
6 files changed
+653−7
SKILL.md+26−1@@ -33,7 +33,32 @@ The flow is a file, `flows/board.json`: the steps in order (intake, models, plac ## A board in Autodesk Fusion -Start with the board's `.brd` and `--remote-board fusion:<board document name>`: the flow converts it for its offline steps and lands every live step in Fusion through the Fusion bridge (1.11.30 or later), read back each time. `unroute --net <net>` takes a net up to route it again after an analysis. Read `docs/fusion-lane.md` before starting one.+When the human asks for a board in Fusion, the whole flow below is yours: they should never have to+spell out a step. Read `docs/fusion-lane.md` once; the Fusion bridge must be 1.11.33 or later.++1. **One project library.** `adom-aiflow fusion library --parts <netlist.json> --footprints <kicad lib folder> --name <Project>`+ builds ONE library for the project in Fusion: every part from its wiki component page, the Mouser,+ DigiKey and LCSC numbers looked up and written onto the part, the chip outline as the symbol for the+ parts a reader recognises (regulators, inductors, big capacitors) and standard symbols for passives,+ the laser-etched or coloured 3D model bound and rendering. A part added later (the human asks for+ one more LED) goes into the same library: run `fusion library` again with it. Never a second library.+2. **A reference design** (a KiCad board you are reproducing): `adom-aiflow fusion reference --kicad <board.kicad_pcb> --spec <spec.json>`+ moves its placement and every spec coordinate into the Fusion board's frame (`moves.json`,+ `fixed.json`, `spec.json`).+3. **The schematic, live.** `adom-aiflow fusion schematic --netlist <netlist.json> --spec <spec.json> --fixed <fixed.json> --name <Design>`+ starts the design with the fab's rules and the fixed parts, draws the schematic in labelled groups+ (one per block of the netlist), checks it pin by pin, and saves the design.+4. **The board.** `start --board board.brd --remote-board fusion:<Design>`, then placement, routing,+ pours and `measure` as for any board.+5. **Current and heat, and refine until it passes.** `analyze current` / `analyze thermal` with+ adom-fields on the live board. A failing screen prints `why:` lines naming the cause and the spec+ change that fixes it (a via ring: `viaPower`; another net crossing a patch: `planeUnder`). Change the+ spec, `adom-aiflow fusion reset` (the parts stay, the copper goes), then route, gate, land, pour and+ analyze again. Repeat until both screens pass, then `finish`.+6. **Ask before exporting.** The human may not want manufacturing files yet. Ask with the+ AskUserQuestion tool ("Export the Gerbers, pick and place and the BOM now?", options "Export them" and+ "Not now"); on yes, `adom-aiflow fusion export` writes the Gerbers (the JLCPCB CAM job), the pick and+ place and the BOM with every distributor number to `<run>/manufacturing/`. ## Before the board: intake, sourcing, evidence
crates/adom-aiflow/src/main.rs+61−1@@ -101,6 +101,8 @@ enum Cmd { Route { #[arg(long, default_value_t = 5)] passes: usize, #[arg(long, default_value_t = 8)] rip_per_net: usize, #[arg(long, default_value_t = 400)] rip_global: usize }, /// Pours, keepouts and vias from the spec (and the routing plan for Kelvin keepouts) Pour { #[arg(long)] plan: Option<String> },+ /// The Fusion side of a project: `library` (one project library with real 3D and part numbers), `reference` (placement and spec from a reference KiCad board, in the Fusion board's frame), `schematic` (the design started with the fab's rules, the schematic drawn live in groups), `export` (Gerbers, pick and place, BOM). `adom-aiflow fusion <step> --help` for each.+ Fusion { #[arg(trailing_var_arg = true, allow_hyphen_values = true)] args: Vec<String>, #[arg(long)] target: Option<String> }, /// Take nets' tracks and vias back up on the live board (to route them again, wider or another way); pours stay Unroute { #[arg(long, required = true)] net: Vec<String> }, /// KiCad DRC offline on the routing plan@@ -205,7 +207,12 @@ fn run_dir(cli: &Cli) -> PathBuf { fn load_run(cli: &Cli) -> Run { let d = run_dir(cli);- if let Some(r) = Run::open(&d) { if let Some(b) = r.data["remoteBoard"].as_str() { aiflow_bridge::fusion::set_run_board(b); } return r; }+ if let Some(r) = Run::open(&d) {+ if let Some(b) = r.data["remoteBoard"].as_str() { aiflow_bridge::fusion::set_run_board(b); }+ // the run's desktop is where a remote KiCad runs, unless the human named another+ if let (Some(t), Err(_)) = (r.data["target"].as_str(), std::env::var("KICAD_REMOTE_TARGET")) { std::env::set_var("KICAD_REMOTE_TARGET", t); }+ return r;+ } Run::open(&d).unwrap_or_else(|| err(&format!("no run at {}", d.display()), &["Start one: adom-aiflow start --board BOARD.kicad_pcb --spec spec.json --engine <name> --ai-thread <name>".into()])) } @@ -1756,6 +1763,43 @@ fn fusion_live(r: &Run, cli: &Cli, dir: &std::path::Path) -> Option<(String, Str Some((brd.display().to_string(), kicad.display().to_string())) } +/// Why a field-screen neck happens, from the board the solve ran on: a via whose copper ring is the+/// narrowest copper of its net, or another net's track cutting a net's patch pour off from its pin.+/// Each hint names the spec change that fixes it, so a refine pass is a decision, not a search.+fn refine_hints(fields: &Value, board_text: &str, spec: &Value) -> Vec<String> {+ let num = |s: &str| s.parse::<f64>().unwrap_or(0.0);+ let via_re = regex_lite::Regex::new(r#"\(via \(at ([-\d.]+) ([-\d.]+)\) \(size ([\d.]+)\) \(drill ([\d.]+)\)[^\n]*?\(net \d+ "([^"]+)"\)"#).unwrap();+ let seg_re = regex_lite::Regex::new(r#"\(segment \(start ([-\d.]+) ([-\d.]+)\) \(end ([-\d.]+) ([-\d.]+)\) \(width ([\d.]+)\) \(layer "?([^")]+)"?\)[^\n]*?\(net \d+ "([^"]+)"\)"#).unwrap();+ let vias: Vec<(f64, f64, f64, f64, String)> = via_re.captures_iter(board_text).map(|c| (num(&c[1]), num(&c[2]), num(&c[3]), num(&c[4]), c[5].to_string())).collect();+ let segs: Vec<((f64, f64), (f64, f64), String, String)> = seg_re.captures_iter(board_text).map(|c| ((num(&c[1]), num(&c[2])), (num(&c[3]), num(&c[4])), c[6].to_string(), c[7].to_string())).collect();+ let inside = |p: (f64, f64), poly: &[(f64, f64)]| { let mut c = false; let n = poly.len(); for i in 0..n { let (a, b) = (poly[i], poly[(i + n - 1) % n]); if (a.1 > p.1) != (b.1 > p.1) && p.0 < (b.0 - a.0) * (p.1 - a.1) / (b.1 - a.1) + a.0 { c = !c; } } c };+ let cross = |a: (f64, f64), b: (f64, f64), poly: &[(f64, f64)]| { if inside(a, poly) || inside(b, poly) { return true; } let n = poly.len(); let o = |p: (f64, f64), q: (f64, f64), r: (f64, f64)| (q.0 - p.0) * (r.1 - p.1) - (q.1 - p.1) * (r.0 - p.0); (0..n).any(|i| { let (c, d) = (poly[i], poly[(i + 1) % n]); o(a, b, c) * o(a, b, d) < 0.0 && o(c, d, a) * o(c, d, b) < 0.0 }) };+ let mut out = Vec::new();+ for is in fields["issues"].as_array().cloned().unwrap_or_default() {+ if is["kind"].as_str() != Some("current-neck") { continue; }+ let net = is["net"].as_str().unwrap_or("").to_string();+ let at = (is["at"][0].as_f64().unwrap_or(0.0), is["at"][1].as_f64().unwrap_or(0.0));+ if let Some(v) = vias.iter().filter(|v| v.4 == net).find(|v| ((v.0 - at.0).powi(2) + (v.1 - at.1).powi(2)).sqrt() <= v.2 / 2.0 + 0.4) {+ out.push(format!("{net}: the neck at ({:.1}, {:.1}) is the copper ring around the via at ({:.2}, {:.2}) ({:.2} mm, {:.2} mm drill). Raise `viaPower` in the spec (1.0 mm with a 0.4 mm drill gives the ring 0.3 mm a side), then route, gate and land again.", at.0, at.1, v.0, v.1, v.2, v.3));+ continue;+ }+ let mut said = false;+ for z in spec["pours"].as_array().cloned().unwrap_or_default() {+ if z["net"].as_str() != Some(net.as_str()) { continue; }+ let poly: Vec<(f64, f64)> = z["polygon"].as_array().cloned().unwrap_or_default().iter().filter_map(|p| Some((p[0].as_f64()?, p[1].as_f64()?))).collect();+ if poly.len() < 3 { continue; }+ let layers: Vec<String> = z["layers"].as_array().cloned().unwrap_or_default().iter().filter_map(|l| l.as_str().map(str::to_string)).collect();+ if let Some(sg) = segs.iter().find(|sg| sg.3 != net && layers.contains(&sg.2) && cross(sg.0, sg.1, &poly)) {+ let covered = spec["planeUnder"].as_array().map(|a| a.iter().any(|p| p["net"].as_str() == Some(net.as_str()))).unwrap_or(false);+ out.push(format!("{net}: a {} track on {} crosses the pour \"{}\", cutting it off from its pin, so the current takes a narrow track instead. {}", sg.3, sg.2, z["name"].as_str().unwrap_or(""), if covered { "A planeUnder entry for it exists already: check that its polygon covers the whole pour." } else { "Add a `planeUnder` entry {layer, net, polygon: that pour's polygon} so the router keeps other nets out of it, then route, gate and land again." }));+ said = true; break;+ }+ }+ if !said { out.push(format!("{net}: neck at ({:.1}, {:.1}) on {}: widen {net} in `wideNets`, or give it a patch pour there.", at.0, at.1, is["layer"].as_str().unwrap_or("?"))); }+ }+ out+}+ fn bridge_of(r: &Run, cli: &Cli) -> Bridge { let target = r.data["target"].as_str().unwrap_or_else(|| err("the run has no desktop target", &["Start the run with --target <box> --remote-board <path on the box>.".into()])).to_string(); Bridge { ai_thread: thread(cli), target }@@ -2021,6 +2065,10 @@ fn main() { } let text = aiflow_board::eagle::to_kicad(&src_text).unwrap_or_else(|e| err(&e, &[])); std::fs::write(©, text).unwrap_or_else(|e| err(&format!("write: {e}"), &[]));+ // the fab's KiCad rules beside the .brd (`fusion schematic` puts them there) go with the+ // plan board, so the gate checks the fab, not KiCad's defaults+ let dru = std::path::Path::new(board).with_extension("kicad_dru");+ if dru.is_file() { let _ = std::fs::copy(&dru, copy.with_extension("kicad_dru")); } aiflow_bridge::fusion::set_run_board(remote_board.as_deref().unwrap_or("")); } else { std::fs::copy(board, ©).unwrap_or_else(|e| err(&format!("copy: {e}"), &[]));@@ -2500,6 +2548,16 @@ fn main() { let lines: Vec<String> = zones.iter().map(aiflow_pours::describe).collect(); ok(&format!("pours planned: {} zones ({keepouts} keepouts), {} vias\n{}", zones.len(), vias.len(), lines.join("\n")), &["Pours are filled and DRC-checked by kicad_add_zone's preflight when landed (the headless DRC does not refill zones). `land vias` then `land pours`.".into()]); }+ Cmd::Fusion { args, target } => {+ // the Fusion steps run in Python beside the bridge calls; the run gives them their folder,+ // the desktop and the thread+ let run = Run::open(&dir);+ let target = target.clone().or_else(|| run.as_ref().and_then(|r| r.data["target"].as_str().map(str::to_string))).or_else(|| std::env::var("AIFLOW_TARGET").ok()).unwrap_or_default();+ let status = std::process::Command::new("python3").arg("-c").arg(include_str!("../../../tools/fusion-project.py")).args(args)+ .env("AIFLOW_RUN", &dir).env("AIFLOW_TARGET", &target).env("AIFLOW_THREAD", thread(&cli))+ .status().unwrap_or_else(|e| err(&format!("fusion steps need python3: {e}"), &[]));+ std::process::exit(status.code().unwrap_or(1));+ } Cmd::Unroute { net } => { thread(&cli); let mut r = load_run(&cli);@@ -2831,6 +2889,8 @@ fn main() { let fields_hint = "Adom Fields 0.4.0 also supports specified conducting heatsinks, per-layer copper thickness, and an optional Signals view for overlap capacitance and differential-pair screening. Read its heatsinks-and-signals guide; use real assembly/material data, never invent a heatsink to pass this gate. Unsupported signal geometry is not a passing impedance result. adom-fields does this solve for you and shows it on the board in 3D: `adom-fields analyze --board <current board> --spec <spec> --out fields` (about two minutes), `adom-fields serve --fields fields &`, then under the fields step `tour fields` films the walkthrough; its screenshots are drawings you can register with `artifact`.".to_string(); let walk = if pour_nets.is_empty() { String::new() } else { format!("Then walk the pours this analysis judged on camera, under this same step: `tour nets --nets {}` (each net lit as a whole, pours and traces together, framed).", pour_nets.join(",")) }; if !passed {+ let why = if fields_file.is_file() { let bt = std::fs::read_to_string(live.as_ref().map(|l| l.1.clone()).unwrap_or_else(|| r.current_board())).unwrap_or_default(); refine_hints(&read_json(&fields_file.display().to_string()), &bt, &spec) } else { Vec::new() };+ if !why.is_empty() { eprintln!("why:\n {}", why.join("\n ")); } err(&format!("{what} analysis not passed:\n{}\n(field screen: {field_note})", lines.join("\n")), &["Fix what the failing lines say (pours, vias, widths, or placement), land it, measure again, then analyze again.".into(), draw, walk]); } ok(&format!("{what} analysis passed:\n{}", lines.join("\n")), &[draw, fields_hint, walk, "The heuristic and same-input Fields solve are engineering screens with documented assumptions, not manufacturing or thermal qualification.".into()]);
crates/aiflow-copper/src/lib.rs+8−4@@ -458,10 +458,14 @@ pub fn copy_project_rules(source: &str, destination: &str) -> Result<(), String> pub fn native_kicad_cli() -> Option<String> { static NATIVE: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new(); NATIVE.get_or_init(|| {- let cli = std::env::var("ADOM_AIFLOW_KICAD_CLI").unwrap_or_else(|_| "kicad-cli".into());- let help = Command::new(&cli).args(["pcb", "drc", "--help"]).output().ok()?;- let text = format!("{}{}", String::from_utf8_lossy(&help.stdout), String::from_utf8_lossy(&help.stderr));- text.contains("--refill-zones").then_some(cli)+ let probe = |cli: &str| -> bool {+ Command::new(cli).args(["pcb", "drc", "--help"]).output().ok()+ .map(|h| format!("{}{}", String::from_utf8_lossy(&h.stdout), String::from_utf8_lossy(&h.stderr)).contains("--refill-zones")).unwrap_or(false)+ };+ if let Ok(cli) = std::env::var("ADOM_AIFLOW_KICAD_CLI") { return probe(&cli).then_some(cli); }+ if probe("kicad-cli") { return Some("kicad-cli".into()); }+ // no KiCad here (a container): the run's own desktop runs it through Adom Bridge+ (std::env::var("KICAD_REMOTE_TARGET").is_ok() && probe("adom-aiflow-kicad-cli-remote")).then(|| "adom-aiflow-kicad-cli-remote".to_string()) }).clone() }
docs/fusion-lane.md+16@@ -39,6 +39,22 @@ adom-aiflow start --board lanetest.brd --spec spec.json --engine <you> \ Every mutating call carries the board id and the revision the flow last read. A board changed behind the flow's back is refused (`stale_board`), never edited blind. +## The whole project, by command++`adom-aiflow fusion` does the Fusion side of a project the same way every time (`--help` on each step):++| step | what it does |+|---|---|+| `fusion library --parts <netlist.json> --footprints <folder> --name <Project>` | ONE library for the project: each part from its wiki component page, Mouser, DigiKey and LCSC numbers (the page's own, then the distributor lookups, each checked against the exact MPN), the chip outline as symbol for ICs, inductors and big capacitors, standard symbols for passives, the etched or coloured 3D bound. Run again with new parts and they join the same library; parts already bound are kept. |+| `fusion reference --kicad <board.kicad_pcb> --spec <spec.json>` | A reference design's placement and spec coordinates in the Fusion board's frame: `moves.json`, `fixed.json`, `spec.json`. |+| `fusion schematic --netlist <netlist.json> --spec <spec.json> --fixed <fixed.json> --name <Design>` | The design started with the fab's rules and the fixed parts (`fusion_board_seed`), the schematic drawn live in labelled groups (`fusion_schematic_plan`, `fusion_schematic_run`), checked pin by pin, saved, and the board written for `start`. |+| `fusion reset` | A refine pass: every track, via and pour cleared from the live board; the parts stay. |+| `fusion export` | Gerbers (the JLCPCB CAM job), pick and place, and the BOM with every distributor number, in `<run>/manufacturing/`. Ask the human first. |++A failing `analyze` prints `why:` lines with the cause of each field-screen neck and the spec change that+fixes it: a via whose copper ring is the narrowest copper (`viaPower`), or another net's track cutting a+patch pour off from its pin (`planeUnder`).+ ## The board's design rules come from the fab, not from Fusion's defaults A new Fusion board carries Fusion's default rules: 0.35 mm minimum drill, 1 mm (40 mil) from copper to the
skills/adom-aiflow/SKILL.md+30−1@@ -1,7 +1,7 @@ --- name: adom-aiflow description: >-- The board loop as one command for KiCad boards on the Adom KiCad Bridge: start a measured run from the human's prompt, plan the stages and say which ones you take yourself (placement, routing) and which the binary runs (router, pours, gate, landing, measurement, current and thermal analysis), then finish only when the board is 100 percent routed, DRC-clean, poured, analysed and measured. One clock from the prompt to the finish line and one manifest per run, so Claude, Codex and any other engine compare on the same flow. Every command answers OK or ERROR with Hint lines that depend on this board and this run. Trigger words: aiflow, adom-aiflow, place and route the board, route the board to 100%, copper pours, current density, thermal analysis, finish the board, compare engines on a board, board loop, from placement to fab, kelvin keepouts, ablation copper, measured run, prompt-to-board time.+ The board loop as one command for KiCad boards on the Adom KiCad Bridge and for Autodesk Fusion boards on the Fusion bridge (one project library with real 3D and part numbers, the schematic drawn live, the board, current and heat refined until it passes, the Gerbers and BOM): start a measured run from the human's prompt, plan the stages and say which ones you take yourself (placement, routing) and which the binary runs (router, pours, gate, landing, measurement, current and thermal analysis), then finish only when the board is 100 percent routed, DRC-clean, poured, analysed and measured. One clock from the prompt to the finish line and one manifest per run, so Claude, Codex and any other engine compare on the same flow. Every command answers OK or ERROR with Hint lines that depend on this board and this run. Trigger words: board in Fusion, build it in Fusion, Fusion electronics, Fusion library for my project, Fusion schematic, Fusion PCB, aiflow, adom-aiflow, place and route the board, route the board to 100%, copper pours, current density, thermal analysis, finish the board, compare engines on a board, board loop, from placement to fab, kelvin keepouts, ablation copper, measured run, prompt-to-board time. --- # adom-aiflow: the loop, measured@@ -31,6 +31,35 @@ The flow is a file, `flows/board.json`: the steps in order (intake, models, plac 7. **`measure`**: KiCad's filled copper per layer, the ablation metric. **`analyze current`** and **`analyze thermal`**: IPC-2221 on the narrowest conductor of every loaded net, pour and via capacity, copper and vias at every hot tab. A loaded net whose pads all sit on one poured outer layer is carried by that layer's pour; its vias only spread heat. Hot parts take a theta ceiling (`maxThetaCPerW`) or a rise budget (`hotMaxRiseC` spec-wide, `maxRiseC` per part) against the stated watts, so write the dissipation physically (I2R at the hot Rds(on) over the conduction duty, plus switching) and say so in the spec. A FAIL names the net or part and the fix; fix it (pours, vias, widths, or placement), land, measure and analyze again. 8. **`finish`**, then **`deliver --video <mp4> --message "..."`**: `finish` refuses until the gate passed, routing and pours landed, copper measured, both analyses passed, and the live board validates at 0 unconnected with no new errors; and, when they apply, the board still equals the recorded schematic, still conforms to the molecule, still carries the spec's fab rules, and every 3D orientation warning is fixed or acknowledged. Then it stamps the end and writes the summary: minutes from the prompt, minutes per stage, decisions, copper, live DRC. Add your token and dollar accounting to the manifest under `tokens` and `usd`. +## A board in Autodesk Fusion++When the human asks for a board in Fusion, the whole flow below is yours: they should never have to+spell out a step. Read `docs/fusion-lane.md` once; the Fusion bridge must be 1.11.33 or later.++1. **One project library.** `adom-aiflow fusion library --parts <netlist.json> --footprints <kicad lib folder> --name <Project>`+ builds ONE library for the project in Fusion: every part from its wiki component page, the Mouser,+ DigiKey and LCSC numbers looked up and written onto the part, the chip outline as the symbol for the+ parts a reader recognises (regulators, inductors, big capacitors) and standard symbols for passives,+ the laser-etched or coloured 3D model bound and rendering. A part added later (the human asks for+ one more LED) goes into the same library: run `fusion library` again with it. Never a second library.+2. **A reference design** (a KiCad board you are reproducing): `adom-aiflow fusion reference --kicad <board.kicad_pcb> --spec <spec.json>`+ moves its placement and every spec coordinate into the Fusion board's frame (`moves.json`,+ `fixed.json`, `spec.json`).+3. **The schematic, live.** `adom-aiflow fusion schematic --netlist <netlist.json> --spec <spec.json> --fixed <fixed.json> --name <Design>`+ starts the design with the fab's rules and the fixed parts, draws the schematic in labelled groups+ (one per block of the netlist), checks it pin by pin, and saves the design.+4. **The board.** `start --board board.brd --remote-board fusion:<Design>`, then placement, routing,+ pours and `measure` as for any board.+5. **Current and heat, and refine until it passes.** `analyze current` / `analyze thermal` with+ adom-fields on the live board. A failing screen prints `why:` lines naming the cause and the spec+ change that fixes it (a via ring: `viaPower`; another net crossing a patch: `planeUnder`). Change the+ spec, `adom-aiflow fusion reset` (the parts stay, the copper goes), then route, gate, land, pour and+ analyze again. Repeat until both screens pass, then `finish`.+6. **Ask before exporting.** The human may not want manufacturing files yet. Ask with the+ AskUserQuestion tool ("Export the Gerbers, pick and place and the BOM now?", options "Export them" and+ "Not now"); on yes, `adom-aiflow fusion export` writes the Gerbers (the JLCPCB CAM job), the pick and+ place and the BOM with every distributor number to `<run>/manufacturing/`.+ ## Before the board: intake, sourcing, evidence The clock starts at the prompt even when there is no board yet.
tools/fusion-project.pyadded+512@@ -0,0 +1,512 @@+#!/usr/bin/env python3+"""adom-aiflow fusion: the Fusion side of a project, done the same way every time.++ library one project library in Fusion: parts from their wiki component pages, the Mouser, DigiKey+ and LCSC numbers looked up and checked, a symbol per part (the chip outline for the parts a+ reader should recognise, standard symbols for passives), real 3D bound, and later parts+ added to the SAME library, never a new one+ reference placement and spec coordinates from a reference KiCad board, moved into the Fusion board's+ frame (the board's lower-left corner at 0,0)+ schematic the design started with the fab's rules and the fixed parts, then the schematic drawn live+ in labelled groups from the netlist, checked pin by pin+ reset clear the live board's copper (tracks, vias, pours) for a refine pass; the parts stay+ export Gerbers (the JLCPCB CAM job), pick and place and the BOM with every distributor number++Every step prints OK or ERROR with Hint lines and keeps its state in <run>/fusion/.+Called by `adom-aiflow fusion <step> ...`; AIFLOW_RUN, AIFLOW_TARGET and AIFLOW_THREAD come from it.+"""+import argparse, base64, csv, json, math, os, re, shutil, subprocess, sys, time+import xml.etree.ElementTree as ET+from pathlib import Path++RUN = Path(os.environ.get('AIFLOW_RUN', 'aiflow-run'))+TARGET = os.environ.get('AIFLOW_TARGET', '')+THREAD = os.environ.get('AIFLOW_THREAD', 'adom-aiflow')+HOME_WIN = os.environ.get('AIFLOW_BOX_DIR', '') # folder on the box; default under the user's Documents+STATE = RUN / 'fusion'++# KiCad's standard schematic symbols (KiCad library, CC-BY-SA 4.0 with the design exception), used for+# passives so a schematic reads the EE way. Pin 1 at the top for R/C/L, anode at the top for an LED.+DEVICE_SYMBOLS = {+ 'R': '''(kicad_symbol_lib (version 20231120) (generator "adom-aiflow")+ (symbol "R" (pin_numbers hide) (pin_names (offset 0)) (exclude_from_sim no) (in_bom yes) (on_board yes)+ (property "Reference" "R" (at 2.032 0 90) (effects (font (size 1.27 1.27))))+ (property "Value" "R" (at 0 0 90) (effects (font (size 1.27 1.27))))+ (symbol "R_0_1" (rectangle (start -1.016 -2.54) (end 1.016 2.54) (stroke (width 0.254) (type default)) (fill (type none))))+ (symbol "R_1_1"+ (pin passive line (at 0 3.81 270) (length 1.27) (name "~" (effects (font (size 1.27 1.27)))) (number "1" (effects (font (size 1.27 1.27)))))+ (pin passive line (at 0 -3.81 90) (length 1.27) (name "~" (effects (font (size 1.27 1.27)))) (number "2" (effects (font (size 1.27 1.27))))))))''',+ 'C': '''(kicad_symbol_lib (version 20231120) (generator "adom-aiflow")+ (symbol "C" (pin_numbers hide) (pin_names (offset 0.254)) (exclude_from_sim no) (in_bom yes) (on_board yes)+ (property "Reference" "C" (at 0.635 2.54 0) (effects (font (size 1.27 1.27)) (justify left)))+ (property "Value" "C" (at 0.635 -2.54 0) (effects (font (size 1.27 1.27)) (justify left)))+ (symbol "C_0_1"+ (polyline (pts (xy -2.032 -0.762) (xy 2.032 -0.762)) (stroke (width 0.508) (type default)) (fill (type none)))+ (polyline (pts (xy -2.032 0.762) (xy 2.032 0.762)) (stroke (width 0.508) (type default)) (fill (type none))))+ (symbol "C_1_1"+ (pin passive line (at 0 3.81 270) (length 2.794) (name "~" (effects (font (size 1.27 1.27)))) (number "1" (effects (font (size 1.27 1.27)))))+ (pin passive line (at 0 -3.81 90) (length 2.794) (name "~" (effects (font (size 1.27 1.27)))) (number "2" (effects (font (size 1.27 1.27))))))))''',+ 'L': '''(kicad_symbol_lib (version 20231120) (generator "adom-aiflow")+ (symbol "L" (pin_numbers hide) (pin_names (offset 1.016) hide) (exclude_from_sim no) (in_bom yes) (on_board yes)+ (property "Reference" "L" (at -1.27 0 90) (effects (font (size 1.27 1.27))))+ (property "Value" "L" (at 1.905 0 90) (effects (font (size 1.27 1.27))))+ (symbol "L_0_1"+ (arc (start 0 -2.54) (mid 0.6323 -1.905) (end 0 -1.27) (stroke (width 0) (type default)) (fill (type none)))+ (arc (start 0 -1.27) (mid 0.6323 -0.635) (end 0 0) (stroke (width 0) (type default)) (fill (type none)))+ (arc (start 0 0) (mid 0.6323 0.635) (end 0 1.27) (stroke (width 0) (type default)) (fill (type none)))+ (arc (start 0 1.27) (mid 0.6323 1.905) (end 0 2.54) (stroke (width 0) (type default)) (fill (type none))))+ (symbol "L_1_1"+ (pin passive line (at 0 3.81 270) (length 1.27) (name "1" (effects (font (size 1.27 1.27)))) (number "1" (effects (font (size 1.27 1.27)))))+ (pin passive line (at 0 -3.81 90) (length 1.27) (name "2" (effects (font (size 1.27 1.27)))) (number "2" (effects (font (size 1.27 1.27))))))))''',+ 'LED': '''(kicad_symbol_lib (version 20231120) (generator "adom-aiflow")+ (symbol "LED" (pin_numbers hide) (pin_names (offset 1.016) hide) (exclude_from_sim no) (in_bom yes) (on_board yes)+ (property "Reference" "D" (at 0 2.54 0) (effects (font (size 1.27 1.27))))+ (property "Value" "LED" (at 0 -2.54 0) (effects (font (size 1.27 1.27))))+ (symbol "LED_0_1"+ (polyline (pts (xy -1.27 -1.27) (xy -1.27 1.27)) (stroke (width 0.254) (type default)) (fill (type none)))+ (polyline (pts (xy -1.27 0) (xy 1.27 0)) (stroke (width 0) (type default)) (fill (type none)))+ (polyline (pts (xy 1.27 -1.27) (xy 1.27 1.27) (xy -1.27 0) (xy 1.27 -1.27)) (stroke (width 0.254) (type default)) (fill (type none))))+ (symbol "LED_1_1"+ (pin passive line (at -3.81 0 0) (length 2.54) (name "K" (effects (font (size 1.27 1.27)))) (number "1" (effects (font (size 1.27 1.27)))))+ (pin passive line (at 3.81 0 180) (length 2.54) (name "A" (effects (font (size 1.27 1.27)))) (number "2" (effects (font (size 1.27 1.27))))))))''',+}+++def ok(msg, hints=()):+ print('OK: ' + msg)+ for h in hints:+ if h: print('Hint: ' + h)+ sys.exit(0)+++def err(msg, hints=()):+ print('ERROR: ' + msg)+ for h in hints:+ if h: print('Hint: ' + h)+ sys.exit(1)+++def say(msg):+ print(msg, flush=True)+++def run(cmd, timeout=600, check=False):+ p = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)+ if check and p.returncode != 0:+ err('%s failed: %s' % (cmd[0], (p.stdout + p.stderr).strip()[-400:]))+ return p+++def ab(verb, args, timeout=600):+ """One adom-bridge verb on the run's desktop; the reply with a JSON-string `output` unwrapped."""+ if not TARGET: err('the run has no desktop target', ['Start the run with --target <box> (adom-aiflow start ... --target), or set AIFLOW_TARGET.'])+ a = dict(args); a.setdefault('reason', 'adom-aiflow fusion')+ p = run(['adom-bridge', '--target', TARGET, '--ai-thread', THREAD, verb, json.dumps(a)], timeout=timeout)+ try: d = json.loads(p.stdout)+ except Exception: return {'success': False, 'error': (p.stdout + p.stderr)[-400:]}+ o = d.get('output')+ if isinstance(o, str) and o.startswith('{'):+ try: return json.loads(o)+ except Exception: pass+ return d+++def box_dir():+ """The project's folder on the desktop, under the user's Documents (one per run)."""+ if HOME_WIN: return HOME_WIN.rstrip('/')+ st = load_state()+ if st.get('boxDir'): return st['boxDir']+ r = ab('shell_execute', {'command': 'echo %USERPROFILE%'})+ out = [l.strip() for l in str(r.get('stdout') or r.get('output') or '').splitlines() if l.strip() and '%' not in l]+ home = out[-1] if out else 'C:/Users/Public'+ d = home.replace('\\', '/') + '/Documents/AdomAiFlow/' + RUN.resolve().name+ st['boxDir'] = d; save_state(st)+ return d+++def send(paths, dest):+ r = ab('send_files', {'filePaths': [str(Path(p).resolve()) for p in paths], 'dest': dest, 'destIsFolder': True}, timeout=1800)+ if r.get('success') is False: err('could not copy files to the desktop: %s' % str(r.get('error'))[:300], ['Check `adom-bridge --target %s ping` and retry.' % TARGET])+++def load_state():+ f = STATE / 'project.json'+ return json.loads(f.read_text()) if f.is_file() else {'parts': {}}+++def save_state(st):+ STATE.mkdir(parents=True, exist_ok=True)+ (STATE / 'project.json').write_text(json.dumps(st, indent=1))+++# ---------------------------------------------------------------- library++def lookup_numbers(mpn, page_links, cache):+ """Mouser, DigiKey and LCSC numbers for one MPN, each checked to belong to that exact MPN.+ The page's own links count; the distributor CLIs fill the gaps; a dead backend is said, never guessed."""+ got = {k: v for k, v in (page_links or {}).items() if k in ('mouser', 'digikey', 'lcsc') and v}+ notes = []+ if mpn in cache:+ for k, v in cache[mpn].items():+ if k in ('mouser', 'digikey', 'lcsc') and v and not got.get(k): got[k] = v+ def want(k): return not got.get(k)+ norm = lambda s: re.sub(r'[^A-Z0-9]', '', str(s).upper())+ for k, tool in (('mouser', 'adom-mouser'), ('digikey', 'adom-digikey')):+ if not want(k) or not shutil.which(tool): continue+ p = run([tool, 'part', mpn], timeout=90)+ if p.returncode != 0 or not p.stdout.strip().startswith(('{', '[')):+ txt = (p.stdout + p.stderr)+ notes.append('%s service %s' % (k, 'down (HTTP %s)' % m.group(1) if (m := re.search(r'HTTP (\d{3})', txt)) else 'unreachable')); continue+ try: d = json.loads(p.stdout)+ except Exception: continue+ items = d if isinstance(d, list) else (d.get('parts') or d.get('results') or [d])+ for it in items:+ if not isinstance(it, dict): continue+ m = it.get('mpn') or it.get('ManufacturerPartNumber') or it.get('manufacturerPartNumber')+ pn = it.get('mouserPartNumber') or it.get('MouserPartNumber') or it.get('digikeyPartNumber') or it.get('DigiKeyPartNumber') or it.get('partNumber')+ if m and pn and norm(m) == norm(mpn): got[k] = pn; break+ if want('lcsc') and shutil.which('adom-jlcpcb'):+ p = run(['adom-jlcpcb', 'search', mpn], timeout=90)+ try:+ d = json.loads(p.stdout); items = d if isinstance(d, list) else (d.get('results') or d.get('parts') or [])+ for it in items:+ m = it.get('mpn') or it.get('mfrPart') or it.get('manufacturerPartNumber')+ c = it.get('lcsc') or it.get('lcscPart') or it.get('code')+ if m and c and norm(m) == norm(mpn): got['lcsc'] = c; break+ except Exception:+ notes.append('lcsc service unreachable')+ return got, notes+++def pick_files(d, comp):+ parts = comp.get('parts', {})+ def first(*names):+ for n in names:+ if n and (d / n).is_file(): return d / n+ return None+ sym = first(parts.get('symbol')) or next(iter(sorted(x for x in d.glob('*.kicad_sym') if 'outline' not in x.name)), None)+ outline = next(iter(sorted(d.glob('*-outline.kicad_sym'))), None)+ fp = first(parts.get('footprint')) or next(iter(sorted(d.glob('*.kicad_mod'))), None)+ step = first(parts.get('model_3d_step'), parts.get('step')) or next(iter(sorted(x for x in d.glob('*.step') if 'unetched' not in x.name and 'uncoloured' not in x.name)), None)+ return sym, outline, fp, step+++def symbol_choice(comp, fp_text, sym_path):+ """The chip outline for the parts a reader should recognise at a glance (ICs, inductors, big and+ polarised capacitors); KiCad's standard symbol for small passives and LEDs."""+ cat = (comp.get('category') or '').lower(); sub = (comp.get('subcategory') or '').lower()+ pads = len(set(re.findall(r'\(pad "([^"]+)"', fp_text or '')))+ if pads > 2: return 'outline'+ if 'inductor' in cat: return 'outline'+ if 'capacitor' in cat and any(w in (cat + sub + (comp.get('package') or '').lower()) for w in ('alumin', 'electrolytic', 'polar', 'tantal', 'smd alu')): return 'outline'+ if 'resistor' in cat: return 'R'+ if 'capacitor' in cat: return 'C'+ if 'inductor' in cat or 'ferrite' in cat: return 'L'+ if 'led' in cat or 'diode' in cat and 'light' in sub: return 'LED'+ return 'own'+++def cmd_library(a):+ st = load_state(); name = st.get('library') or a.name or 'Project'+ if not re.fullmatch(r'[A-Za-z0-9_-]{1,40}', name): err('library name: letters, digits, _ and - only')+ st['library'] = name+ want = []+ src = json.loads(Path(a.parts).read_text()) if a.parts.endswith('.json') else []+ if isinstance(src, dict) and 'components' in src:+ # a netlist: each component's MPN; a component with none is a board-only pad, its footprint found+ # by name under --footprints (a KiCad library folder)+ refs = st.setdefault('refs', {})+ for c in src['components']:+ if c.get('mpn'):+ want.append({'mpn': c['mpn'], 'manufacturer': c.get('mfr')}); refs[c['ref']] = c['mpn']+ else:+ fpname = (c.get('fp') or '').split(':')[-1]+ hit = next(iter(sorted(Path(a.footprints).rglob(fpname + '.kicad_mod'))), None) if a.footprints and fpname else None+ if not hit: err('%s has no MPN and its footprint %s was not found' % (c['ref'], fpname or '?'), ['Give --footprints <folder with %s.kicad_mod>, or an MPN in the netlist.' % fpname])+ pad = re.sub(r'[^A-Za-z0-9_-]', '-', fpname).upper()+ want.append({'mpn': pad, 'footprint': str(hit)}); refs[c['ref']] = pad+ save_state(st)+ else:+ want.extend(src)+ if a.parts.endswith('.csv'):+ for row in csv.DictReader(open(a.parts, newline='')):+ mpn = row.get('mpn') or row.get('MPN')+ if mpn and mpn.strip().lower() not in ('', 'no part'): want.append({'mpn': mpn.strip(), 'ref': row.get('ref') or row.get('Designator')})+ if not want: err('no parts: give --parts parts.json ([{mpn, page?, footprint?}]) or a BOM .csv with an mpn column')+ cache = json.loads(Path(a.numbers).read_text()) if a.numbers else {}+ lib_dir = STATE / 'library'; (lib_dir / 'parts').mkdir(parents=True, exist_ok=True)+ added, kept, unverified = [], [], {}+ seen = set()+ for w in want:+ mpn = w['mpn']+ if mpn in seen: continue+ seen.add(mpn)+ if mpn in st['parts'] and (st['parts'][mpn].get('urn') or st['parts'][mpn].get('boardOnly')):+ kept.append(mpn); continue+ pdir = STATE / 'pages' / re.sub(r'[^A-Za-z0-9._-]', '_', mpn)+ out_lbr = lib_dir / 'parts' / (mpn + '.lbr')+ if w.get('footprint'): # a board-only part (test pad) from a footprint file+ r = run(['adom-lbr', 'generate', '--fp', w['footprint'], '--name', mpn, '--package-name', mpn, '-o', str(out_lbr)])+ if 'Lint PASSED' not in r.stdout + r.stderr: err('%s: adom-lbr generate failed: %s' % (mpn, (r.stdout + r.stderr)[-300:]))+ st['parts'][mpn] = {'lbr': str(out_lbr), 'step': None, 'boardOnly': True, 'numbers': {}}+ added.append(mpn); say('part %s: board only (footprint %s)' % (mpn, Path(w['footprint']).name)); continue+ slug = w.get('page') or mpn.lower()+ shutil.rmtree(pdir, ignore_errors=True) # always the page as it is now (its numbers may have been added since)+ if True:+ r = run(['adom-wiki', 'repo', 'clone', slug, '--dir', str(pdir)], timeout=900)+ if r.returncode != 0 or not (pdir / 'page.json').is_file():+ err('%s: no wiki component page at %s' % (mpn, slug), ['Fetch the part first (chip-fetcher publishes a component page), or give its page slug in parts.json: {"mpn": "%s", "page": "<owner>/<slug>"}.' % mpn])+ page = json.loads((pdir / 'page.json').read_text()); comp = page.get('component', {})+ sym, outline, fp, step = pick_files(pdir, comp)+ if not fp: err('%s: the page has no .kicad_mod footprint' % mpn)+ numbers, notes = lookup_numbers(mpn, comp.get('distributor_links'), cache)+ meta = dict(page); meta['component'] = dict(comp); meta['component']['distributor_links'] = numbers+ if not meta['component'].get('manufacturer') and w.get('manufacturer'): meta['component']['manufacturer'] = w['manufacturer']+ (pdir / 'page.meta.json').write_text(json.dumps(meta))+ choice = w.get('symbol') or symbol_choice(comp, fp.read_text(errors='ignore'), sym)+ if choice == 'outline' and outline: use = outline+ elif choice in DEVICE_SYMBOLS:+ use = STATE / 'symbols' / (choice + '.kicad_sym'); use.parent.mkdir(parents=True, exist_ok=True); use.write_text(DEVICE_SYMBOLS[choice])+ else: use = sym+ args = ['adom-lbr', 'generate', '--fp', str(fp), '--part-metadata', str(pdir / 'page.meta.json'), '--name', mpn, '--package-name', mpn, '-o', str(out_lbr)]+ if use: args[2:2] = ['--sym', str(use)]+ if choice in DEVICE_SYMBOLS: args += ['--symbol-layout', 'keep']+ r = run(args)+ if 'Lint PASSED' not in r.stdout + r.stderr: err('%s: adom-lbr generate failed: %s' % (mpn, (r.stdout + r.stderr)[-300:]))+ missing = [k for k in ('mouser', 'digikey', 'lcsc') if not numbers.get(k)]+ stock = (meta['component'].get('manufacturer') or '').strip().lower() == 'adom'+ if missing and not stock: unverified[mpn] = missing + (['(' + '; '.join(sorted(set(notes))) + ')'] if notes else [])+ st['parts'][mpn] = {**st['parts'].get(mpn, {}), 'lbr': str(out_lbr), 'step': str(step) if step else None, 'numbers': numbers, 'symbol': choice}+ added.append(mpn)+ say('part %s: %s symbol, model %s, %s' % (mpn, choice, step.name if step else 'none', 'Adom stock' if stock else 'Mouser %s, DigiKey %s, LCSC %s' % (numbers.get('mouser', '-'), numbers.get('digikey', '-'), numbers.get('lcsc', '-'))))+ save_state(st)+ # the one library: every part, user values allowed (VALUE never asks), assembled into <name>.lbr+ combined = lib_dir / (name + '.lbr')+ lbrs = sorted(str(Path(p['lbr'])) for p in st['parts'].values())+ r = run(['adom-lbr', 'assemble', *lbrs, '-o', str(combined), '--name', name], check=True)+ t = combined.read_text()+ t = re.sub(r'<deviceset ((?:(?!uservalue)[^>])*)>', lambda m: '<deviceset %s uservalue="yes">' % m.group(1), t)+ combined.write_text(t)+ # 3D: build only the parts that have no binding yet, then carry every binding into the library+ todo = [m for m, p in st['parts'].items() if p.get('step') and not p.get('urn')]+ bdir = box_dir() + '/library'+ if todo:+ stage = STATE / 'stage'; shutil.rmtree(stage, ignore_errors=True); (stage / 'lib').mkdir(parents=True); (stage / 'models').mkdir()+ for m in todo:+ shutil.copy(st['parts'][m]['lbr'], stage / 'lib' / (m + '.lbr')); shutil.copy(st['parts'][m]['step'], stage / 'models' / (m + '.step'))+ send(sorted(stage.glob('lib/*')), bdir + '/lib'); send(sorted(stage.glob('models/*')), bdir + '/models'); send([combined], bdir)+ say('binding real 3D to %d part(s) in Fusion (about a minute each)' % len(todo))+ r = ab('fusion_build_library_3d', {'lbrPath': bdir + '/' + combined.name, 'outLbrPath': bdir + '/' + name + '_3d.lbr', 'capture': True,+ 'parts': [{'package': m, 'lbrPath': bdir + '/lib/' + m + '.lbr', 'modelPath': bdir + '/models/' + m + '.step'} for m in todo]}, timeout=300)+ t0 = time.time(); res = {}+ while time.time() - t0 < 60 * (5 + 3 * len(todo)):+ time.sleep(20)+ pr = ab('fusion_prove_result', {}).get('result') or {}+ if pr.get('boundLbr', '').endswith(name + '_3d.lbr') and pr.get('partsDone') == len(todo) and all(x.get('package') in todo for x in pr.get('parts', [])):+ res = pr; break+ if pr.get('stage'): say(' 3D: %s' % pr.get('stage'))+ if not res: err('the 3D build did not finish in time', ['Read fusion_prove_result; run `adom-aiflow fusion library` again: parts already bound are kept and only the rest are built.'])+ for x in res.get('parts', []):+ if x.get('success') and x.get('wip_urn'): st['parts'][x['package']]['urn'] = x['wip_urn']+ save_state(st)+ # inject every binding (this build's and earlier ones) into the library, then put it on the box+ urns = {m: p['urn'] for m, p in st['parts'].items() if p.get('urn')}+ t = combined.read_text()+ t = re.sub(r'(?s)<packages3d>.*?</packages3d>', '', t); t = re.sub(r'(?s)<package3dinstances>.*?</package3dinstances>', '', t)+ p3 = ''.join('<package3d name="%s" urn="" wip_urn="%s" locally_modified="yes" type="model"><description>%s</description><packageinstances><packageinstance name="%s"/></packageinstances></package3d>' % (m, u, m, m) for m, u in urns.items())+ if p3: t = t.replace('</packages>', '</packages><packages3d>' + p3 + '</packages3d>', 1)+ def dev(mt):+ d = mt.group(0); pk = re.search(r'package="([^"]+)"', d.split('>', 1)[0])+ u = urns.get(pk.group(1)) if pk else None+ return d.replace('</connects>', '</connects><package3dinstances><package3dinstance package3d_urn="%s"/></package3dinstances>' % u, 1) if u else d+ t = re.sub(r'(?s)<device [^>]*package="[^"]+"[^>]*>.*?</device>', dev, t)+ combined.write_text(t); send([combined], bdir)+ st['libraryPath'] = bdir + '/' + combined.name; save_state(st)+ unbound = [m for m, p in st['parts'].items() if p.get('step') and not p.get('urn')]+ ok('library %s: %d part(s) (%d added, %d kept), %d with real 3D, one library at %s' % (name, len(st['parts']), len(added), len(kept), len(urns), st['libraryPath']),+ ['No distributor number yet: ' + '; '.join('%s %s' % (m, ' '.join(v)) for m, v in unverified.items()) + '. Run `fusion library` again when the services are back, or add the numbers to the part\'s wiki page (component.distributor_links).' if unverified else '',+ '3D not bound yet: %s; run this again to retry them' % ', '.join(unbound) if unbound else '',+ 'Next: adom-aiflow fusion schematic --netlist <netlist.json> (the design starts from this library). New parts later: run `fusion library` again with them; they join this same library.'])+++# ---------------------------------------------------------------- reference++def kicad_footprints(text):+ out = {}+ for m in re.finditer(r'\(footprint "([^"]+)".*?\(at ([-\d.]+) ([-\d.]+)(?: ([-\d.]+))?\).*?\(property "Reference" "([^"]+)"', text, re.S):+ out[m.group(5)] = (float(m.group(2)), float(m.group(3)), float(m.group(4) or 0) % 360)+ return out+++def kicad_outline(text):+ xs, ys = [], []+ for m in re.finditer(r'\(gr_(?:line|rect)[^()]*\(start ([-\d.]+) ([-\d.]+)\)\s*\(end ([-\d.]+) ([-\d.]+)\).*?\(layer "Edge.Cuts"\)', text, re.S):+ xs += [float(m.group(1)), float(m.group(3))]; ys += [float(m.group(2)), float(m.group(4))]+ if not xs: err('the reference board has no Edge.Cuts outline')+ return min(xs), min(ys), max(xs), max(ys)+++def cmd_reference(a):+ text = Path(a.kicad).read_text(); x0, y0, x1, y1 = kicad_outline(text)+ fps = kicad_footprints(text); spec = json.loads(Path(a.spec).read_text())+ # the Fusion board's frame: lower-left corner at 0,0, y up; the run's KiCad frame is (x, -y) of it+ def to_run(x, y): return round(x - x0, 3), round(y - y1, 3)+ fixed = set(spec.get('fixedRefs') or [])+ moves = {r: {'x': to_run(x, y)[0], 'y': to_run(x, y)[1], 'rotation': rot} for r, (x, y, rot) in fps.items() if r not in fixed}+ out = Path(a.out_dir); out.mkdir(parents=True, exist_ok=True)+ (out / 'moves.json').write_text(json.dumps(moves, indent=1))+ fixed_pos = {r: {'x': round(x - x0, 3), 'y': round(y1 - y, 3), 'rot': rot} for r, (x, y, rot) in fps.items() if r in fixed}+ (out / 'fixed.json').write_text(json.dumps({'outlineMm': [round(x1 - x0, 3), round(y1 - y0, 3)], 'fixed': fixed_pos}, indent=1))+ moved = 0+ def tr(p): return [round(p[0] - x0, 3), round(p[1] - y1, 3)]+ for key in ('pours', 'planeUnder'):+ for z in spec.get(key, []) or []:+ if 'polygon' in z and z['polygon'] and any(abs(v) > 40 for pt in z['polygon'] for v in pt): z['polygon'] = [tr(p) for p in z['polygon']]; moved += 1+ for v in spec.get('stitchVias', []) or []:+ if abs(v.get('x', 0)) > 40 or abs(v.get('y', 0)) > 40: v['x'], v['y'] = tr([v['x'], v['y']]); moved += 1+ spec['frameNote'] = "Coordinates are in the Fusion board's frame (reference board shifted by (%g, %g))." % (-x0, -y1)+ (out / 'spec.json').write_text(json.dumps(spec, indent=1))+ dru = Path(a.kicad).with_suffix('.kicad_dru')+ if dru.is_file(): shutil.copy(dru, out / 'board.kicad_dru') # the fab's rules travel with the design+ ok('reference %s: %d part placements, %d fixed parts, outline %.2f x %.2f mm, %d spec coordinate(s) moved into the board frame' % (Path(a.kicad).name, len(moves), len(fixed_pos), x1 - x0, y1 - y0, moved),+ ['Wrote %s/moves.json (for place check/land), %s/fixed.json (for fusion schematic) and %s/spec.json (use it for start).' % (out, out, out)])+++# ---------------------------------------------------------------- schematic++def cmd_schematic(a):+ st = load_state()+ if not st.get('libraryPath'): err('no project library yet', ['Run `adom-aiflow fusion library --parts <parts>` first.'])+ nl = json.loads(Path(a.netlist).read_text()); spec = json.loads(Path(a.spec).read_text()) if a.spec else {}+ fx = json.loads(Path(a.fixed).read_text()) if a.fixed else {'outlineMm': a.outline, 'fixed': {}}+ by_ref = {}+ mpn_of = {c['ref']: c.get('mpn') for c in nl.get('components', [])}+ mpn_of.update(st.get('refs') or {})+ for r, m in (json.loads(Path(a.mpns).read_text()) if a.mpns else {}).items(): mpn_of[r] = m+ lib = ET.parse(STATE / 'library' / (st['library'] + '.lbr')).getroot()+ dsets = {d.get('name').upper(): d.get('name') for d in lib.iter('deviceset')}+ for c in nl['components']:+ m = mpn_of.get(c['ref'])+ if not m or m.upper() not in dsets: err('%s: no part %s in the project library' % (c['ref'], m), ['Add it with `adom-aiflow fusion library` (a board-only pad takes {"mpn": "<name>", "footprint": "<file.kicad_mod>"}), or map the reference with --mpns refs.json.'])+ by_ref[c['ref']] = dsets[m.upper()]+ name = a.name+ rules = {'clearance': spec.get('clearance', 0.15), 'edge': spec.get('edgeClearance', 0.3), 'minWidth': 0.1, 'minDrill': 0.15, 'holeToHole': 0.2,+ 'padRestringRatio': 0.15, 'padRestring': 0.2, 'viaRestring': 0.1, 'copperUm': spec.get('copperUm', 35)}+ rules.update(spec.get('fusionRules') or {})+ nets = nl.get('nets', {})+ pin_net = {}+ for n, ms in nets.items():+ for m in ms: pin_net[m] = n+ fixed = []+ for r, p in fx['fixed'].items():+ fixed.append({'ref': r, 'deviceset': by_ref[r], 'value': next((n for k, n in pin_net.items() if k.split('.')[0] == r), ''),+ 'x': p['x'], 'y': p['y'], 'rot': ('R%d' % int(p.get('rot', 0))) if p.get('rot') else None,+ 'nets': {k.split('.', 1)[1]: n for k, n in pin_net.items() if k.split('.')[0] == r}})+ bdir = box_dir() + '/design'+ say('starting the design with the fab rules (clearance %.2f mm, edge %.2f mm, 0.15 mm drill) and %d fixed part(s)' % (rules['clearance'], rules['edge'], len(fixed)))+ r = ab('fusion_board_seed', {'libraryPath': st['libraryPath'], 'outDir': bdir, 'name': 'Seed', 'title': nl.get('title', name), 'outlineMm': fx['outlineMm'], 'fixed': fixed, 'rules': rules})+ if not r.get('success'): err('seed: %s' % r.get('error'), [r.get('_hint')])+ ab('fusion_execute_text_command', {'command': 'Commands.Start NewElectronDesignDocumentCommand'})+ for _ in range(20): # the import starts from an electronics design that has settled+ time.sleep(2)+ st_ = ab('fusion_get_app_state', {}); d_ = st_.get('data') or st_+ if d_.get('productType') == 'EcadDesignProductType' and not d_.get('busyCommand'): break+ time.sleep(3)+ r = ab('fusion_new_electronics_from_eagle', {'schPath': r['schPath'], 'brdPath': r['brdPath']})+ rid = r.get('runId')+ for _ in range(60):+ time.sleep(6)+ s = ab('fusion_eagle_import_status', {'runId': rid})+ if s.get('stage') in ('done', 'failed'): break+ if s.get('stage') != 'done': err('the design import did not finish: %s' % str((s.get('result') or {}).get('error'))[:300], ['Start from a clean Fusion session (save and close half-finished designs) and run this again.'])+ ab('fusion_schematic', {'action': 'show'}); time.sleep(3)+ fixed_refs = set(fx['fixed'])+ parts = [{'ref': c['ref'], 'deviceset': by_ref[c['ref']], 'value': c.get('value'), 'block': c.get('block')} for c in nl['components'] if c['ref'] not in fixed_refs]+ plan = ab('fusion_schematic_plan', {'libraryPath': st['libraryPath'], 'parts': parts, 'nets': {n: [m for m in ms if m.split('.')[0] not in fixed_refs] for n, ms in nets.items()}})+ if not plan.get('groups'): err('schematic plan: %s' % plan.get('error'))+ sheet = {}+ for g in plan['groups']:+ say('drawing %s: %s' % (g['group'], ', '.join(g['parts'])))+ r = ab('fusion_schematic_run', {'libraryPath': st['libraryPath'], 'commands': g['commands'], 'pauseMs': a.pause_ms}, timeout=180)+ if not r.get('success'): err('schematic group %s: %s' % (g['group'], r.get('error')), ['Read fusion_check_dialogs; nothing after this group was drawn.'])+ sheet = r.get('nets') or sheet+ # pin by pin against the netlist (pads and pin names both count)+ pad2pin = {}+ for d in lib.iter('deviceset'):+ for dv in d.iter('device'):+ for c in dv.iter('connect'):+ for pad in c.get('pad', '').split(): pad2pin[(d.get('name').upper(), pad)] = c.get('pin')+ want = {n: sorted('%s.%s' % (m.split('.')[0], pad2pin.get((by_ref[m.split('.')[0]].upper(), m.split('.', 1)[1]), m.split('.', 1)[1])) for m in ms) for n, ms in nets.items()}+ got = {n: sorted(v) for n, v in sheet.items()}+ bad = [n for n in want if want[n] != got.get(n)]+ if bad: err('the sheet differs from the netlist on %s' % ', '.join(bad), ['Compare with fusion_schematic_run {"commands":["WINDOW FIT;"]} (its nets) and fix the netlist or the library pins.'])+ ab('fusion_show_2d_board', {}); time.sleep(3)+ bi = ab('fusion_board_info', {}); n = ((bi.get('data') or bi).get('summary') or (bi.get('data') or bi)).get('componentCount')+ if n != len(nl['components']): err('the board has %s of %d parts: schematic/board sync is off' % (n, len(nl['components'])), ['Start from a clean Fusion session (close half-finished imported designs) and run this again.'])+ s = ab('fusion_save_to_cloud', {'name': name, 'projectName': a.project, 'folderPath': a.folder})+ if not s.get('success'): ab('fusion_create_cloud_folder', {'folderName': a.folder, 'projectName': a.project}); s = ab('fusion_save_to_cloud', {'name': name, 'projectName': a.project, 'folderPath': a.folder})+ src = ab('fusion_board_source', {'expectDocument': name})+ if not src.get('source'): err('could not read the board back: %s' % src.get('error'))+ out = Path(a.board_out); out.write_text(src['source'])+ dru = Path(a.fixed).parent / 'board.kicad_dru' if a.fixed else None+ if dru and dru.is_file(): shutil.copy(dru, out.with_suffix('.kicad_dru'))+ st['design'] = name; save_state(st)+ ok('schematic drawn live in %d group(s), every pin on its net; the board followed with %d parts; saved as %s; board written to %s' % (len(plan['groups']), n, name, out),+ ['Next: adom-aiflow start --board %s --spec <spec.json> --target %s --remote-board fusion:%s --engine <you> --prompt-time <paste time>' % (out, TARGET or '<box>', name)])+++# ---------------------------------------------------------------- export++def cmd_export(a):+ st = load_state(); name = a.design or st.get('design')+ if not name: err('which design? pass --design <name>')+ out = Path(a.out_dir); out.mkdir(parents=True, exist_ok=True)+ bdir = box_dir() + '/manufacturing'+ g = ab('fusion_export_gerbers', {'outputPath': bdir + '/' + name + '-gerbers.zip'}, timeout=600)+ gd = g.get('data') or g+ if not gd.get('zipPath'): err('Gerbers: %s' % g.get('error'))+ ab('fusion_export_cpl', {'outputPath': bdir + '/' + name + '-cpl.csv'})+ b = ab('fusion_board_bom', {'expectDocument': name, 'outputDir': bdir})+ if not b.get('success'): err('BOM: %s' % b.get('error'))+ files = [gd['zipPath'], bdir + '/' + name + '-cpl.csv', bdir + '/bom.csv', bdir + '/bom-jlcpcb.csv']+ r = ab('pull_file', {'filePaths': files, 'saveTo': str(out.resolve())}, timeout=600)+ missing = {k: v for k, v in (b.get('missingPartNumbers') or {}).items() if v}+ ok('manufacturing files in %s: Gerbers (%d files, %d layers), pick and place, BOM (%d lines) and the JLCPCB BOM' % (out, gd.get('fileCount') or 0, gd.get('layerCount') or 0, len(b.get('lines') or [])),+ ['Parts without a distributor number: ' + '; '.join('%s: %s' % (k, ', '.join(v)) for k, v in missing.items()) if missing else '',+ 'Board-only parts (nothing to buy): ' + ', '.join(b.get('boardOnly') or []) if b.get('boardOnly') else ''])+++def cmd_reset(a):+ """Clear the live board's copper for a refine pass: every track and via (ripped up net by net, pours+ kept filled), then every pour. The parts stay where they are; route, gate, land and pour again after."""+ st = load_state(); name = a.design or st.get('design')+ if not name: err('which design? pass --design <name>')+ s0 = ab('fusion_routing_state', {'expectDocument': name})+ if not s0.get('success'): err('could not read the board: %s' % s0.get('error'))+ nets = sorted({w['net'] for w in s0.get('segments', []) if w.get('net')} | {v['net'] for v in s0.get('vias', []) if v.get('net')})+ if nets:+ r = ab('fusion_ripup_nets', {'expectDocument': name, 'boardId': s0['boardId'], 'expectedRevision': s0['revision'], 'nets': nets})+ if not r.get('success'): err('rip-up: %s' % r.get('error'), [r.get('_hint')])+ s1 = ab('fusion_routing_state', {'expectDocument': name})+ r = ab('fusion_remove_pours', {'expectDocument': name, 'boardId': s1['boardId'], 'expectedRevision': s1['revision'], 'pours': 'all'})+ if not r.get('success'): err('pours: %s' % r.get('error'), [r.get('_hint')])+ ok('copper cleared on %s: %d net(s) ripped up, %d pour(s) removed; the parts did not move' % (name, len(nets), r.get('removed') or 0),+ ['Next: adom-aiflow route (it plans on the live board), gate, land route, pour, land vias, land pours, measure, then analyze again.'])+++def main():+ ap = argparse.ArgumentParser(prog='adom-aiflow fusion')+ sub = ap.add_subparsers(dest='cmd', required=True)+ p = sub.add_parser('library'); p.add_argument('--parts', required=True, help='netlist.json, parts.json [{mpn, page?, footprint?}] or a BOM .csv'); p.add_argument('--footprints', help='folder of .kicad_mod for board-only parts'); p.add_argument('--name'); p.add_argument('--numbers', help='JSON {mpn: {mouser, digikey, lcsc}} of numbers already checked')+ p = sub.add_parser('reference'); p.add_argument('--kicad', required=True); p.add_argument('--spec', required=True); p.add_argument('--out-dir', default=str(RUN / 'fusion'))+ p = sub.add_parser('schematic'); p.add_argument('--netlist', required=True); p.add_argument('--spec'); p.add_argument('--fixed'); p.add_argument('--outline', nargs=2, type=float)+ p.add_argument('--mpns'); p.add_argument('--name', required=True); p.add_argument('--project', default='Main'); p.add_argument('--folder', default='Adom AI Flow')+ p.add_argument('--board-out', default='board.brd'); p.add_argument('--pause-ms', type=int, default=60)+ p = sub.add_parser('reset'); p.add_argument('--design')+ p = sub.add_parser('export'); p.add_argument('--design'); p.add_argument('--out-dir', default=str(RUN / 'manufacturing'))+ a = ap.parse_args()+ {'library': cmd_library, 'reference': cmd_reference, 'schematic': cmd_schematic, 'reset': cmd_reset, 'export': cmd_export}[a.cmd](a)+++if __name__ == '__main__':+ main()