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 #273: 0.2.9: the Altium lane on 0.2.x (courtyards, parts and project page, verified list)
16 files changed
+324−39
Cargo.lock+10−10Cargo.toml+1−1crates/adom-aiflow/src/main.rs+18−4crates/adom-aiflow/src/mechanical.rs+22crates/adom-aiflow/src/sequence.rs+12crates/adom-aiflow/src/verified.rs+41−1crates/aiflow-board/src/altium.rs+111crates/aiflow-bridge/src/altium.rs+5−1docs/altium-lane.md+27docs/release-0.2.9.md+11flows/board.json+3−1package.json+1−1page.json+1−1skills/adom-aiflow/SKILL.md+17−12tools/altium-project.py+18−3tools/project-page.py+26−4Cargo.lock+10−10@@ -4,7 +4,7 @@ version = 4 [[package]] name = "adom-aiflow"-version = "0.2.8"+version = "0.2.9" dependencies = [ "aiflow-analyze", "aiflow-board",@@ -25,7 +25,7 @@ dependencies = [ [[package]] name = "aiflow-analyze"-version = "0.2.8"+version = "0.2.9" dependencies = [ "aiflow-board", "serde",@@ -35,7 +35,7 @@ dependencies = [ [[package]] name = "aiflow-board"-version = "0.2.8"+version = "0.2.9" dependencies = [ "roxmltree", "serde",@@ -44,7 +44,7 @@ dependencies = [ [[package]] name = "aiflow-bridge"-version = "0.2.8"+version = "0.2.9" dependencies = [ "aiflow-board", "serde",@@ -53,7 +53,7 @@ dependencies = [ [[package]] name = "aiflow-copper"-version = "0.2.8"+version = "0.2.9" dependencies = [ "aiflow-board", "aiflow-grid",@@ -63,7 +63,7 @@ dependencies = [ [[package]] name = "aiflow-grid"-version = "0.2.8"+version = "0.2.9" dependencies = [ "aiflow-board", "serde",@@ -72,7 +72,7 @@ dependencies = [ [[package]] name = "aiflow-place"-version = "0.2.8"+version = "0.2.9" dependencies = [ "aiflow-board", "serde",@@ -81,7 +81,7 @@ dependencies = [ [[package]] name = "aiflow-pours"-version = "0.2.8"+version = "0.2.9" dependencies = [ "aiflow-board", "aiflow-copper",@@ -91,7 +91,7 @@ dependencies = [ [[package]] name = "aiflow-router"-version = "0.2.8"+version = "0.2.9" dependencies = [ "aiflow-board", "aiflow-grid",@@ -101,7 +101,7 @@ dependencies = [ [[package]] name = "aiflow-run"-version = "0.2.8"+version = "0.2.9" dependencies = [ "serde", "serde_json",
Cargo.toml+1−1@@ -14,7 +14,7 @@ members = [ ] [workspace.package]-version = "0.2.8"+version = "0.2.9" edition = "2021" license = "MIT" repository = "https://wiki.adom.inc/adom/adom-aiflow"
crates/adom-aiflow/src/main.rs+18−4@@ -131,7 +131,8 @@ enum Cmd { #[arg(long)] brief: Option<String>, /// requirements.json (aiflow-intake): fab.target picks the sourcing profile #[arg(long)] requirements: Option<String>, #[arg(long)] engine: Option<String>, /// the desktop for pre-board clips (a monitor recording while no editor is open) #[arg(long)] target: Option<String>, /// the run's wiki page (owner/slug)- #[arg(long)] page: Option<String> },+ #[arg(long)] page: Option<String>, /// the EDA tool the board is built in (default: the brief's own words, else KiCad); fixes the run's step sequence+ #[arg(long, value_parser = ["kicad", "fusion", "altium"])] eda: Option<String> }, /// `sourcing check --bom <csv>`: every electrical board reference in the BOM with an MPN, sources that fit the profile (fab: Mouser or Adom stock, no LCSC/JLCPCB parts; jlcpcb: LCSC numbers), a dated stock check, thin stock warned. finish requires a pass when the spec or requirements name a profile. Sourcing { what: String, #[arg(long)] bom: String, #[arg(long, value_parser = ["fab", "jlcpcb"])] profile: Option<String>, /// boards to build (default: requirements build.quantity, else 10) #[arg(long)] qty: Option<u64>, /// the board to diff against (default: the run's current board)@@ -2700,7 +2701,7 @@ fn main() { ok(&format!("run started for engine {engine} at {}; the clock started at the prompt, {pt}", dir.display()), &start_hints); print_next(&cli); }- Cmd::Intake { prompt_time, brief, requirements, engine, target, page } => {+ Cmd::Intake { prompt_time, brief, requirements, engine, target, page, eda } => { let t = thread(&cli); if aiflow_run::parse(prompt_time).is_none() { err(&format!("--prompt-time {prompt_time} is not a time (RFC 3339 UTC, e.g. 2026-09-29T00:39:00Z)"), &["Use the human message's own timestamp, not the moment you got to it.".into()]); } if let Some(r) = Run::open(&dir) {@@ -2712,6 +2713,9 @@ fn main() { let req = requirements.as_ref().map(canon).or_else(|| brief.as_ref().filter(|b| std::fs::read_to_string(b).ok().and_then(|t| serde_json::from_str::<Value>(&t).ok()).map(|v| v.get("fab").is_some() || v.get("sourcing").is_some() || v.get("outputs").is_some()).unwrap_or(false)).map(canon)); let mut r = Run::create_intake(&dir, prompt_time, &t, engine.as_deref(), brief.as_ref().map(canon).as_deref(), req.as_deref(), target.as_deref()).unwrap_or_else(|e| err(&e, &[])); if let Some(p) = page { r.data["page"] = json!(p); }+ // the EDA tool, named: the step sequence is fixed at the first step after intake, before any+ // backend project exists, so a brief that never says "Altium" must not fall into kicad-new+ if let Some(e) = eda { r.data["eda"] = json!(e); } let idx = r.turn_begin(&command_line()); record_usage(&mut r, "start"); r.save().unwrap();@@ -2936,7 +2940,8 @@ fn main() { let (spec, req) = (spec_soft(&r), requirements_of(&r)); let title = title.clone().or_else(|| req["title"].as_str().map(str::to_string)).or_else(|| spec["title"].as_str().map(str::to_string)).unwrap_or_else(|| sequence::title(&r.data)); let brief = brief.as_deref().map(preboard::brief_text).or_else(|| req["summary"].as_str().or(req["description"].as_str()).map(str::to_string)).unwrap_or_else(|| title.clone());- let cfg = json!({"run": dir.display().to_string(), "page": page, "title": title, "brief": brief, "summary": req["description"].as_str().unwrap_or(""), "parts": parts["parts"], "create": create, "push": !no_push, "private": private, "replaceReadme": replace_readme, "existing": existing, "version": "1.0.0",+ let lane = sequence::lane(&dir, &r.data);+ let cfg = json!({"run": dir.display().to_string(), "eda": lane, "page": page, "title": title, "brief": brief, "summary": req["description"].as_str().unwrap_or(""), "parts": parts["parts"], "create": create, "push": !no_push, "private": private, "replaceReadme": replace_readme, "existing": existing, "version": "1.0.0", "libRef": r.data["cloudLibrary"].as_str().or(r.data["outcomes"]["project-page"]["cloudLibrary"].as_str())}); let cfgp = dir.join("project-page").join("config.json"); let _ = std::fs::create_dir_all(cfgp.parent().unwrap());@@ -2954,6 +2959,11 @@ fn main() { if !no_push { let _ = std::process::Command::new("adom-cli").args(["hydrogen", "webview", "open-or-refresh", "--name", "Project page", "--url", v["url"].as_str().unwrap_or(""), "--ai-thread", &t, "--reason", "adom-aiflow: the board's project page, revision 1"]).stdin(std::process::Stdio::null()).output(); }+ if lane == "altium" {+ ok(&format!("project page {} ({}): README revision 1 with {} parts and the component table (Altium Designer: the library step builds the SchLib and PcbLib from these same wiki pages)", v["url"].as_str().unwrap_or(""), if v["created"] == true { "created" } else { "updated" }, v["parts"]),+ &["Next: `step library`, then `adom-aiflow altium library`: one SchLib and one PcbLib from the parts' wiki pages. Every step you leave is pushed to this page as steps/<nn>-<step>/README.md.".into()]);+ return;+ } let c = &v["counts"]; let mut hints: Vec<String> = v["adds"]["notes"].as_array().into_iter().flatten().filter_map(|n| n.as_str().map(str::to_string)).collect(); if let Some(pl) = v["projectLibParts"].as_array().filter(|a| !a.is_empty()) { hints.push(format!("from the board's project library: {}", pl.iter().filter_map(|x| x.as_str()).collect::<Vec<_>>().join(", "))); }@@ -2963,6 +2973,7 @@ fn main() { Cmd::Pcm { what: _ } => { thread(&cli); let mut r = load_run(&cli);+ if sequence::lane(&dir, &r.data) != "kicad" { err("pcm check is KiCad's library check (the project page's PCM package installed in KiCad); this run builds in another tool", &["Altium: `adom-aiflow altium library` builds and records the library.".into()]); } let pp = r.data["outcomes"]["project-page"].clone(); let Some(id) = pp["identifier"].as_str().map(str::to_string) else { err("no project page yet: `project-page --page <owner/slug> --create` first", &[]) }; let br = bridge_of(&r, &cli);@@ -2977,6 +2988,7 @@ fn main() { Cmd::Schematic { what: _, sch } => { thread(&cli); let mut r = load_run(&cli);+ if sequence::lane(&dir, &r.data) != "kicad" { err("schematic check reads a KiCad schematic (kicad-cli ERC); this run builds in another tool", &["Altium: `adom-aiflow altium schematic` draws, compiles and checks the schematic pin for pin.".into()]); } if !Path::new(sch).is_file() { err(&format!("no such schematic: {sch}"), &[]); } let kc = std::env::var("ADOM_AIFLOW_KICAD_CLI").unwrap_or_else(|_| "kicad-cli".into()); let sdir = dir.join("schematic"); let _ = std::fs::create_dir_all(&sdir);@@ -3898,7 +3910,9 @@ fn main() { if !f.errors.is_empty() { missing.push(format!("molecule check ({})", f.errors.join("; "))); } } // issue #39: courtyards, part bodies and the pours in the saved board (the board that ships)- let facts = (live_board.is_none() && mechanical::applies(&solved_board)).then(|| verified::board_facts(&board_now(), &solved_board, &project_dir_of(&r), &spec_soft(&r)));+ // Altium: the live board is a fresh render of the board of record (its courtyards and fab+ // outlines from the parts' source footprints, Altium's own fills), so the facts hold for it+ let facts = ((live_board.is_none() || aiflow_bridge::eda::active().id() == "altium") && mechanical::applies(&solved_board)).then(|| verified::board_facts(&board_now(), &solved_board, &project_dir_of(&r), &spec_soft(&r))); match &facts { Some(f) => { for n in &f.mech.severity_notes { eprintln!("Warning: {n}"); }
crates/adom-aiflow/src/mechanical.rs+22@@ -592,3 +592,25 @@ pub(crate) mod tests { let _ = std::fs::remove_dir_all(&dir); } }++#[cfg(test)]+mod altium_render_probe {+ /// AIFLOW_PROBE_READ=<board.altium.json> AIFLOW_PROBE_PROJECT=<run>/altium cargo test -p adom-aiflow altium_render_probe -- --ignored --nocapture+ #[test]+ #[ignore]+ fn probe() {+ let read = std::env::var("AIFLOW_PROBE_READ").unwrap();+ let mut v: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&read).unwrap()).unwrap();+ if let Ok(p) = std::env::var("AIFLOW_PROBE_PROJECT") { v["projectDir"] = serde_json::json!(p); }+ let text = aiflow_board::altium::render_plan_board(&v.to_string()).unwrap();+ let out = std::env::temp_dir().join("probe.kicad_pcb");+ std::fs::write(&out, &text).unwrap();+ let b = aiflow_board::Board::from_text(&out.display().to_string(), text).unwrap();+ let rep = super::check(&b, &out.display().to_string(), &|_| None);+ println!("footprints {} with courtyard {} missing {:?}", rep.footprints, rep.with_courtyard, rep.missing_courtyards);+ println!("courtyard overlaps {:?}", rep.courtyard_overlaps);+ println!("bodies checked {} no body {:?} overlaps {:?} size errors {:?}", rep.bodies_checked, rep.no_body, rep.body_overlaps, rep.body_size_errors);+ println!("courtyards_ok {} bodies_ok {}", rep.courtyards_ok(), rep.bodies_ok());+ println!("silk polarity: {:?}", crate::silk_polarity::warnings(&b));+ }+}
crates/adom-aiflow/src/sequence.rs+12@@ -379,4 +379,16 @@ mod sequence_key_tests { let pos = |n: &str| s.iter().position(|x| x == n).unwrap(); assert!(pos("parts") < pos("project-page") && pos("project-page") < pos("library") && pos("library") < pos("schematic") && pos("schematic") < pos("placement")); }++ #[test]+ fn an_altium_run_named_at_intake_puts_its_parts_and_project_page_before_its_library() {+ let d = std::env::temp_dir();+ // `intake --eda altium`: the brief need not say "Altium", and the run never falls into kicad-new+ assert_eq!(sequence_key(&d, &serde_json::json!({"eda": "altium"})), "altium");+ let flow: Value = serde_json::from_str(include_str!("../../../flows/board.json")).unwrap();+ let s = sequence_of(&flow, "altium");+ let pos = |n: &str| s.iter().position(|x| x == n).unwrap();+ assert!(pos("sourcing") < pos("parts") && pos("parts") < pos("project-page") && pos("project-page") < pos("library") && pos("library") < pos("schematic"));+ assert!(!s.iter().any(|x| x == "components" || x == "models"), "Altium's library step makes the parts; no KiCad components/models steps");+ } }
crates/adom-aiflow/src/verified.rs+41−1@@ -91,7 +91,25 @@ pub fn board_facts(b: &aiflow_board::Board, board_path: &str, project_dir: &std: /// The verified list for a run, with the board facts when the caller has them. pub fn run_items(r: &aiflow_run::Run, facts: Option<&BoardFacts>) -> Vec<Item> {- from_run(r.outcomes(), &r.data, facts.map(|f| (f.mech.courtyards_ok(), f.mech.bodies_ok())), facts.map(|f| f.fill_problems.is_empty()))+ let outcomes = with_tool_proofs(r.outcomes(), &r.dir);+ from_run(&outcomes, &r.data, facts.map(|f| (f.mech.courtyards_ok(), f.mech.bodies_ok())), facts.map(|f| f.fill_problems.is_empty()))+}++/// A backend that records its checks as proof files (`<run>/proofs/<key>.json`, the Altium lane's+/// Python) instead of outcomes: its export (fills in the Gerbers), its schematic compile (pin-for-pin+/// parity with the netlist, the compiler's ERC) count here too. A recorded outcome always wins.+pub fn with_tool_proofs(outcomes: &Map<String, Value>, dir: &std::path::Path) -> Map<String, Value> {+ let mut o = outcomes.clone();+ let proof = |k: &str| std::fs::read_to_string(dir.join("proofs").join(format!("{k}.json"))).ok()+ .and_then(|t| serde_json::from_str::<Value>(&t).ok()).map(|v| v["data"].clone()).filter(|d| d.is_object());+ if !o.contains_key("export") {+ if let Some(e) = proof("export").filter(|e| e.get("fillsGerbers").is_some()) { o.insert("export".into(), e); }+ }+ if let Some(s) = proof("schematic") {+ if !o.contains_key("netlist") { if let Some(p) = s["parity"].as_bool() { o.insert("netlist".into(), json!({"ok": p, "source": "altium schematic (compiled to the netlist pin for pin)"})); } }+ if !o.contains_key("erc") { if let Some(p) = s["erc"]["pass"].as_bool() { o.insert("erc".into(), json!({"pass": p, "errors": s["erc"]["errors"], "source": "altium schematic (compiler)"})); } }+ }+ o } /// "verified: ..." and the verdict, as one message tail.@@ -99,6 +117,28 @@ pub fn message(items: &[Item]) -> String { format!("{}\n{}", line(items), verdic #[cfg(test)] mod tests {++ /// The Altium lane's proof files count: its schematic's pin-for-pin parity and compiler ERC, its+ /// export's Gerber regions. A recorded outcome wins over a proof.+ #[test]+ fn tool_proofs_fill_in_parity_erc_and_gerber_fills() {+ let dir = std::env::temp_dir().join(format!("verified-proofs-{}", std::process::id()));+ std::fs::create_dir_all(dir.join("proofs")).unwrap();+ std::fs::write(dir.join("proofs/schematic.json"), r#"{"at":"t","data":{"parity":true,"erc":{"pass":true,"errors":[]}}}"#).unwrap();+ std::fs::write(dir.join("proofs/export.json"), r#"{"at":"t","data":{"tool":"altium","fillsGerbers":{"pass":true,"regions":{"F.Cu":42}}}}"#).unwrap();+ let o = super::with_tool_proofs(&serde_json::Map::new(), &dir);+ let items = super::from_run(&o, &serde_json::json!({}), Some((true, true)), Some(true));+ let st = |n: &str| items.iter().find(|i| i.name == n).unwrap().state.clone();+ assert_eq!(st("schematic parity"), super::State::Yes);+ assert_eq!(st("ERC"), super::State::Yes);+ assert_eq!(st("fills in Gerbers"), super::State::Yes);+ let mut rec = serde_json::Map::new();+ rec.insert("erc".into(), serde_json::json!({"pass": false}));+ let o = super::with_tool_proofs(&rec, &dir);+ assert_eq!(o["erc"]["pass"], false, "a recorded outcome wins");+ let _ = std::fs::remove_dir_all(&dir);+ }+ use super::*; #[test]
crates/aiflow-board/src/altium.rs+111@@ -7,8 +7,67 @@ //! with its pads local to it and their angles absolute; a bottom component's pads are written in board //! position on B.Cu (footprint angle 0); only copper layers come along; the outline's arcs stay arcs; //! a pour's fill is Altium's own, each island with its holes slit into it.+//!+//! Courtyards, fab outlines and polarity marks: a read that names its Altium project (`"projectDir"`,+//! the run's `altium/` folder) gives each part the courtyard, fab and silkscreen graphics of the KiCad+//! footprint its Altium+//! footprint was converted from (`pages/<MPN>/*.kicad_mod`, mapped through `project.json`), once that+//! footprint's pads are proven to sit where the Altium part's pads do. Any other part (no source+//! footprint, pads that differ, a bottom part) gets a courtyard drawn around its pads. use serde_json::Value;+use std::collections::HashMap; use std::fmt::Write;+use std::path::Path;++/// Pads closer than this (mm) to their KiCad source footprint's prove the two frames are the same.+const PAD_MATCH_MM: f64 = 0.02;+/// IPC-7351 nominal courtyard excess, for a courtyard drawn around the pads.+const COURTYARD_MM: f64 = 0.25;+/// The source footprint's own graphics that come along: courtyard, fab outline, and its silkscreen+/// lines, arcs, circles and polygons (pin-1 dots, cathode bars: the polarity marks). Never its texts:+/// Altium's designators are rendered from the board itself.+const MECH_LAYERS: [&str; 6] = ["F.CrtYd", "F.Fab", "B.CrtYd", "B.Fab", "F.SilkS", "B.SilkS"];++/// One KiCad footprint's pads (name, local x, local y, KiCad's y-down frame) and its raw courtyard+/// and fab items.+struct Mech { pads: Vec<(String, f64, f64)>, items: Vec<String> }++fn mech_of_footprint(text: &str) -> Option<Mech> {+ let root = crate::sx::parse(text).ok()?;+ let mut pads = Vec::new();+ for p in root.find_all("pad") {+ let (Some(name), Some(at)) = (p.atom(1), p.find("at")) else { continue };+ let (Some(x), Some(y)) = (at.number(1), at.number(2)) else { continue };+ pads.push((name.to_string(), x, y));+ }+ let items: Vec<String> = root.nodes()+ .filter(|n| matches!(n.tag.as_str(), "fp_line" | "fp_rect" | "fp_poly" | "fp_circle" | "fp_arc"))+ .filter(|n| n.find("layer").and_then(|l| l.value()).map(|l| MECH_LAYERS.contains(&l)).unwrap_or(false))+ .map(|n| text[n.start..n.end].to_string()).collect();+ Some(Mech { pads, items })+}++/// Reference -> the KiCad source footprint of its part, from `<project>/project.json`+/// (`parts.<MPN>.refs`) and the first `<project>/pages/<MPN>/*.kicad_mod`.+fn mech_by_ref(project: &Path) -> HashMap<String, std::rc::Rc<Mech>> {+ let mut out = HashMap::new();+ let Some(pj) = std::fs::read_to_string(project.join("project.json")).ok().and_then(|t| serde_json::from_str::<Value>(&t).ok()) else { return out };+ for (key, part) in pj["parts"].as_object().into_iter().flatten() {+ let Ok(rd) = std::fs::read_dir(project.join("pages").join(key)) else { continue };+ let mut mods: Vec<_> = rd.flatten().map(|e| e.path()).filter(|p| p.extension().map(|x| x == "kicad_mod").unwrap_or(false)).collect();+ mods.sort();+ let Some(m) = mods.first().and_then(|f| std::fs::read_to_string(f).ok()).and_then(|t| mech_of_footprint(&t)) else { continue };+ let m = std::rc::Rc::new(m);+ for r in part["refs"].as_array().into_iter().flatten().filter_map(|r| r.as_str()) { out.insert(r.to_string(), m.clone()); }+ }+ out+}++/// Every rendered pad (name, local x, local y) has a source pad of the same name within PAD_MATCH_MM.+fn pads_match(rendered: &[(String, f64, f64)], src: &[(String, f64, f64)]) -> bool {+ !rendered.is_empty() && rendered.len() == src.len()+ && rendered.iter().all(|(n, x, y)| src.iter().any(|(sn, sx, sy)| sn == n && (sx - x).abs() < PAD_MATCH_MM && (sy - y).abs() < PAD_MATCH_MM))+} fn f(v: &Value) -> f64 { v.as_f64().unwrap_or(0.0) } fn is_copper(layer: &str) -> bool { layer == "F.Cu" || layer == "B.Cu" || (layer.starts_with("In") && layer.ends_with(".Cu")) }@@ -49,6 +108,7 @@ pub fn render_plan_board(src: &str) -> Result<String, String> { if st["success"].as_bool() != Some(true) { return Err(format!("the Altium read failed: {}", st["error"])); } let yy = |v: f64| -v; let arr = |k: &str| st[k].as_array().cloned().unwrap_or_default();+ let mech = d["projectDir"].as_str().map(|p| mech_by_ref(Path::new(p))).unwrap_or_default(); // nets: numbered from 1 in name order let mut names: Vec<String> = st["nets"].as_array().cloned().unwrap_or_default().iter().filter_map(|v| v.as_str().map(str::to_string)).collect();@@ -102,6 +162,8 @@ pub fn render_plan_board(src: &str) -> Result<String, String> { q(&format!("Altium:{}", c["pattern"].as_str().unwrap_or(""))), n(fx), n(fy), n(fa), prop("Reference", r, sc.as_ref().map(|s| &s["name"]), "F.SilkS"), prop("Value", sc.as_ref().and_then(|s| s["comment"]["text"].as_str()).unwrap_or(""), sc.as_ref().map(|s| &s["comment"]), "F.Fab"));+ let mut drawn: Vec<(String, f64, f64)> = Vec::new();+ let mut reach: Option<(f64, f64, f64, f64)> = None; for p in pads.iter().filter(|p| p["ref"].as_str() == Some(r)) { let (dx, dy) = (f(&p["x"]) - cx, f(&p["y"]) - cy); let (lx, ly) = if bottom { (dx, -dy) } else {@@ -112,6 +174,9 @@ pub fn render_plan_board(src: &str) -> Result<String, String> { let ang = if bottom { -f(&p["rotation"]) } else { f(&p["rotation"]) }; let (sx, sy) = (f(&p["sizeX"]), f(&p["sizeY"])); let drill = f(&p["drill"]);+ drawn.push((p["name"].as_str().unwrap_or("").to_string(), lx, ly));+ let h = sx.max(sy) / 2.0;+ reach = Some(match reach { None => (lx - h, ly - h, lx + h, ly + h), Some((a, b, c, e)) => (a.min(lx - h), b.min(ly - h), c.max(lx + h), e.max(ly + h)) }); let layer = p["layer"].as_str().unwrap_or("F.Cu"); let pn = net_pad(p["net"].as_str().unwrap_or("")); let name = p["name"].as_str().unwrap_or("");@@ -128,6 +193,16 @@ pub fn render_plan_board(src: &str) -> Result<String, String> { let _ = writeln!(o, " (pad {} smd {shape} (at {} {} {}) (size {} {}) (layers \"{l}.Cu\" \"{l}.Paste\" \"{l}.Mask\"){extra} {pn})", q(name), n(lx), n(ly), n(ang), n(sx), n(sy)); } }+ // courtyard and fab: the source footprint's own, when its pads prove the frame; else a+ // courtyard around the pads (the mechanical checks need one on every part with pads)+ match mech.get(r).filter(|m| !bottom && pads_match(&drawn, &m.pads)) {+ Some(m) => for it in &m.items { let _ = writeln!(o, " {it}"); },+ None => if let Some((a, b, c, e)) = reach {+ let crt = if bottom { "B.CrtYd" } else { "F.CrtYd" };+ let _ = writeln!(o, " (fp_rect (start {} {}) (end {} {}) (stroke (width 0.05) (type solid)) (layer {}))",+ n(a - COURTYARD_MM), n(b - COURTYARD_MM), n(c + COURTYARD_MM), n(e + COURTYARD_MM), q(crt));+ },+ } o.push_str(" )\n"); } @@ -238,6 +313,42 @@ mod tests { assert!(got.is_some(), "pad {key} not at ({}, {})", p["x"], -p["y"].as_f64().unwrap()); } }+ /// A two-pad part: its source footprint's courtyard, fab and silk come along when the pads prove the+ /// frame; a part whose source pads differ gets a courtyard around its pads instead.+ #[test]+ fn courtyards_come_from_the_source_footprint_or_around_the_pads() {+ let dir = std::env::temp_dir().join(format!("altium-mech-{}", std::process::id()));+ let _ = std::fs::remove_dir_all(&dir);+ std::fs::create_dir_all(dir.join("pages/R1MPN")).unwrap();+ std::fs::create_dir_all(dir.join("pages/C1MPN")).unwrap();+ std::fs::write(dir.join("project.json"), r#"{"parts": {"R1MPN": {"refs": ["R1"]}, "C1MPN": {"refs": ["C1"]}}}"#).unwrap();+ let fp = |pad2x: f64| format!(r#"(footprint "X" (layer "F.Cu")+ (fp_rect (start -1 -0.5) (end 1 0.5) (stroke (width 0.05) (type solid)) (layer "F.CrtYd"))+ (fp_rect (start -0.5 -0.25) (end 0.5 0.25) (stroke (width 0.1) (type solid)) (layer "F.Fab"))+ (fp_circle (center -1.2 0) (end -1.1 0) (stroke (width 0.1) (type solid)) (layer "F.SilkS"))+ (fp_text user "keep out of the render" (at 0 0) (layer "F.SilkS"))+ (pad "1" smd rect (at -0.5 0) (size 0.5 0.5) (layers "F.Cu"))+ (pad "2" smd rect (at {pad2x} 0) (size 0.5 0.5) (layers "F.Cu")))"#);+ std::fs::write(dir.join("pages/R1MPN/r.kicad_mod"), fp(0.5)).unwrap();+ std::fs::write(dir.join("pages/C1MPN/c.kicad_mod"), fp(0.9)).unwrap(); // pads differ from the board's+ let comp = |r: &str, x: f64| serde_json::json!({"ref": r, "x": x, "y": 0.0, "rotation": 0.0, "side": "top"});+ let pad = |r: &str, n: &str, x: f64| serde_json::json!({"ref": r, "name": n, "x": x, "y": 0.0, "rotation": 0.0, "sizeX": 0.5, "sizeY": 0.5, "shape": "rect", "layer": "F.Cu", "net": ""});+ let st = serde_json::json!({"success": true, "nets": [], "components": [comp("R1", 0.0), comp("C1", 5.0)],+ "pads": [pad("R1", "1", -0.5), pad("R1", "2", 0.5), pad("C1", "1", 4.5), pad("C1", "2", 5.5)], "outlineEdges": [+ {"kind": "line", "start": [-3.0, -3.0], "end": [8.0, -3.0]}, {"kind": "line", "start": [8.0, -3.0], "end": [8.0, 3.0]},+ {"kind": "line", "start": [8.0, 3.0], "end": [-3.0, 3.0]}, {"kind": "line", "start": [-3.0, 3.0], "end": [-3.0, -3.0]}]});+ let src = serde_json::json!({"source": "altium-bridge", "state": st, "pours": null, "projectDir": dir.display().to_string()}).to_string();+ let text = render_plan_board(&src).unwrap();+ let b = crate::Board::from_text("t.kicad_pcb", text.clone()).unwrap();+ let fp_of = |r: &str| b.root.find_all("footprint").find(|f| crate::reference_of(f).as_deref() == Some(r)).unwrap().clone();+ let layers = |r: &str| { let f = fp_of(r); f.nodes().filter(|n| n.tag.starts_with("fp_")).filter_map(|n| n.find("layer").and_then(|l| l.value()).map(str::to_string)).collect::<Vec<_>>() };+ assert_eq!(layers("R1"), vec!["F.CrtYd", "F.Fab", "F.SilkS"], "R1's source graphics, no source text");+ assert_eq!(layers("C1"), vec!["F.CrtYd"], "C1: only a courtyard around its pads");+ assert!(text.contains("(fp_rect (start -1 -0.5) (end 1 0.5) (stroke (width 0.05)"), "R1's courtyard verbatim");+ assert!(text.contains("(fp_rect (start -1 -0.5) (end 1 0.5) (stroke (width 0.05) (type solid)) (layer \"F.CrtYd\"))") || text.matches("F.CrtYd").count() == 2);+ assert!(!text.contains("keep out of the render"));+ let _ = std::fs::remove_dir_all(&dir);+ } #[test] fn a_hole_is_slit_into_its_outline() { let p = fracture(&[(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)], &[vec![(4.0, 4.0), (6.0, 4.0), (6.0, 6.0), (4.0, 6.0)]]);
crates/aiflow-bridge/src/altium.rs+5−1@@ -331,7 +331,11 @@ impl Eda for AltiumEda { let pours = t.send("altium_pour_state", &json!({})); // the silkscreen too: finish counts the labels and the field app draws them let silk = t.send("altium_silk_state", &json!({}));- let src = json!({"source": "altium-bridge", "state": st, "pours": if ok(&pours) { pours } else { Value::Null }, "silk": if ok(&silk) { silk } else { Value::Null }});+ // projectDir: the run's Altium project, where each part's source KiCad footprint gives the+ // plan board its courtyard and fab outline (the mechanical checks need them)+ let project = dir.join("altium");+ let project = if project.join("project.json").is_file() { json!(std::fs::canonicalize(&project).unwrap_or(project).display().to_string()) } else { Value::Null };+ let src = json!({"source": "altium-bridge", "state": st, "pours": if ok(&pours) { pours } else { Value::Null }, "silk": if ok(&silk) { silk } else { Value::Null }, "projectDir": project}); let native = dir.join("board-live.altium.json"); let kicad = dir.join("board-live.kicad_pcb"); let text = src.to_string();
docs/altium-lane.md+27@@ -7,6 +7,24 @@ planner, the KiCad DRC gate, the field solve); every live step lands in Altium t the live board before the next one goes. Altium Designer must be installed, licensed and open on the desktop (`altium_status` says `licensed: true`). +## Before the library: parts and the project page++Name the tool at intake: `adom-aiflow intake ... --eda altium`. The run's step list is fixed at its first+step after intake, before any Altium project exists, so a brief that never says "Altium" would otherwise+fall into KiCad's from-scratch list.++After `sourcing`, the Altium list has the same two steps as a KiCad board from scratch:++- `parts --bom design/bom.csv`: every BOM MPN matched to its Adom wiki component page (one Hydrogen tab+ walks through them); a part with no page fails the step.+- `project-page --page <owner>/<slug> --create`: the board's own project page, revision 1, before any EDA+ work: the README with the component table, each part linked to its page. No KiCad PCM package: the+ `library` step builds the Altium libraries from the same pages. Every step the run leaves after this is+ pushed to the page as `steps/<nn>-<step>/README.md`.++`pcm check` and `schematic check` are KiCad's and refuse an Altium run; `altium library` and+`altium schematic` record those proofs here.+ ## The whole project, by command `adom-aiflow altium <step>` does the Altium side of a project the same way every time (`--help` on each):@@ -53,6 +71,15 @@ adom-aiflow start --board <run>/altium/<Design>.altium.json --spec <run>/altium/ | `tour nets` | `altium_select_net`: each net selected as a whole and framed | | live validate in `finish` | `altium_routing_drc`: Altium's own DRC; unconnected is computed from the copper | +Courtyards, part bodies and polarity marks: the board read carries `projectDir` (the run's `altium/`+folder), and the render gives each part the courtyard (F.CrtYd), fab outline (F.Fab) and silkscreen marks+of the KiCad footprint its Altium footprint was converted from (`pages/<MPN>/*.kicad_mod`), once that+footprint's pads are proven to sit where the Altium part's pads do (same names, within 0.02 mm). A part+with no source footprint, or whose pads differ, gets a courtyard drawn 0.25 mm around its pads. `place+check`, `gate`, `land route` and `finish` then judge courtyard and body overlaps on Altium boards as on+KiCad ones. The verified list reads `altium schematic`'s pin-for-pin parity and compiler ERC and `altium+export`'s Gerber regions (every poured layer's Gerber has G36 regions) from their proofs.+ A failing field screen names the neck and its fix. The solve runs on the snapshot `analyze` just wrote: `analyze current` (it writes `board-live.kicad_pcb`), then `adom-aiflow exec -- adom-fields analyze --board <run>/board-live.kicad_pcb --spec <spec> --out <run>/fields`, then `analyze current` again.
docs/release-0.2.9.mdadded+11@@ -0,0 +1,11 @@+# adom-aiflow 0.2.9++The Altium lane on 0.2.x: John's 0.2.0 to 0.2.8 features checked against an Altium Designer board, and the gaps closed.++- **Courtyards and part bodies on Altium boards.** The mechanical check (0.2.x) refused every Altium board: its plan board (rendered from the bridge's read) had pads but no courtyard. The read now names the run's Altium project (`projectDir`), and the render gives each part the courtyard, fab outline and silkscreen marks (lines, arcs, circles, polygons; no texts) of the KiCad footprint its Altium footprint was converted from (`altium/pages/<MPN>/*.kicad_mod`), once that footprint's pads are proven to sit where the Altium part's pads do (same names, within 0.02 mm). A part with no source footprint or with different pads gets a courtyard 0.25 mm around its pads. On the TPS54202 buck built in Altium (2026-10-16): 40 of 40 parts with a courtyard, 33 with a body outline, and one real finding (TP1 inside L1's courtyard by 0.15 mm); the silk polarity check no longer false-flags D1.+- **`intake --eda kicad|fusion|altium`.** The step list is fixed at the first step after intake, before any Altium project exists; a brief that never said "Altium" fell into `kicad-new`.+- **Parts and the project page on the Altium lane.** `parts` and `project-page` now come after `sourcing` in the Altium sequence. `project-page` on Altium makes the page (`--source altium`) and README revision 1 with the component table and a "Built in Altium Designer" section; no KiCad PCM package (the `library` step builds the SchLib and PcbLib from the same pages). Step sub-readmes then follow as on KiCad.+- **`pcm check` and `schematic check` refuse a non-KiCad run** instead of recording a failed `library` or `schematic` proof.+- **The verified list on Altium.** `altium schematic`'s proof records pin-for-pin parity and the compiler's ERC; `altium export` checks that every layer Altium poured has filled regions (G36) in its Gerber and records it; the verified list reads both (a recorded outcome still wins). `finish` checks courtyards, bodies and saved fills on the live Altium board (a fresh render of the board of record).+- Tests: `courtyards_come_from_the_source_footprint_or_around_the_pads`, `an_altium_run_named_at_intake_puts_its_parts_and_project_page_before_its_library`, `tool_proofs_fill_in_parity_erc_and_gerber_fills`. Live: `finish` on a copy of the 2026-10-16 Altium run against the open board in Altium Designer 26.11.1.+- Not yet on Altium: an equivalent of the schematic symbol / pin-type check (the verified list says "not known"), and parts from a project library with no public wiki page.
flows/board.json+3−1@@ -341,13 +341,15 @@ "clips": "every `step <name>` stops the previous step's clip and starts a new window recording tagged with the step, when the board is open on a test box; run.json captures[] carries one entry per clip with its step, start, stop and file, and deliver lists them; the final video is cut from these clips, one segment per step, so two engines' videos line up step for step; a return (step <name> --back --why) is a new visit and gets its own clip, tagged <step>-<visit> with the reason, so the rework is on camera and the final cut can show the loop", "screenshots": "every step visit gets two background screenshots of the editor window, at its start and at its end (shot-<step>-<visit>-start.png, shot-<step>-<visit>-end.png), logged as artifacts, so a run's own README has a picture for every step without anyone taking one", "sequence": {- "_": "The required steps in order, per lane. `step <x>` refuses to skip a required step before x; a return (`step <k> --back`) makes every later required step a redo; finish refuses until the chain is in order and each step's proof is recorded during its last visit. kicad-new is a KiCad board from scratch (the run starts with no board): the parts go on the wiki and on the board's project page, KiCad installs them as one PCM library, and the schematic is drawn in KiCad before the board exists.",+ "_": "The required steps in order, per lane. `step <x>` refuses to skip a required step before x; a return (`step <k> --back`) makes every later required step a redo; finish refuses until the chain is in order and each step's proof is recorded during its last visit. An Altium run also puts the parts on the wiki and the board on its project page before its library step (the library is built from those pages). kicad-new is a KiCad board from scratch (the run starts with no board): the parts go on the wiki and on the board's project page, KiCad installs them as one PCM library, and the schematic is drawn in KiCad before the board exists.", "altium": [ "intake", "rules", "design", "test-plan", "sourcing",+ "parts",+ "project-page", "library", "schematic", "simulation",
package.json+1−1@@ -1,7 +1,7 @@ { "slug": "adom-aiflow", "type": "app",- "version": "0.2.8",+ "version": "0.2.9", "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, Fusion 360 and Altium Designer today (the same steps and gates through each tool's bridge); 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.",
page.json+1−1@@ -1,7 +1,7 @@ { "slug": "adom-aiflow", "type": "app",- "version": "0.2.8",+ "version": "0.2.9", "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, Fusion 360 and Altium Designer today (the same steps and gates through each tool's bridge); 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.",
skills/adom-aiflow/SKILL.md+17−12@@ -173,40 +173,45 @@ Altium windowed beside Hydrogen the same way as Fusion (above), and bring Altium everything drawn on the design. The lane's steps are the Fusion lane's, with `altium` in place of `fusion`: -1. **Intake and the timeline.** `intake --target <box> ...`, `timeline open`.+1. **Intake and the timeline.** `intake --target <box> --eda altium ...`, `timeline open`. `--eda altium`+ fixes the run's step list (the brief need not say "Altium"). 2. **Design rules.** Ask which fab builds the board; `rules use --profile <pack> [--layers 2]`. Before the board exists the choice is recorded; the board `altium schematic` builds carries it. 3. **The circuit** (aiflow-circuit-design), **the test plan** (aiflow-test-plan, `testplan check`), **the parts** from their wiki component pages (aiflow-sourcing). The netlist is `netlist.json`: `components` (ref, mpn, value, block, page or fp) and `nets` (`{"VIN": ["U1.3", "C1.1"]}`).-4. **One project library in Altium.** `altium library --parts <netlist.json> --footprints <folder of+4. **The parts on the wiki and the project page**, as for a KiCad board from scratch: `parts --bom+ design/bom.csv` (every MPN on its wiki component page), then `project-page --page <owner>/<slug>+ --create` (README revision 1 with the component table; no KiCad PCM package on the Altium lane). Every+ step after it is pushed to the page as it is left.+5. **One project library in Altium.** `altium library --parts <netlist.json> --footprints <folder of .kicad_mod for board-only pads> --name <Project>`.-5. **A reference board**, when the design has one: `altium reference --kicad <board.kicad_pcb> --spec+6. **A reference board**, when the design has one: `altium reference --kicad <board.kicad_pcb> --spec <spec.json>` (placement and spec in Altium's frame; use its `spec.json` and `moves.json` from here on).-6. **The schematic, then the board.** `altium schematic --netlist <netlist.json> --name <Design>`: the+7. **The schematic, then the board.** `altium schematic --netlist <netlist.json> --name <Design>`: the sheet drawn and compiled pin for pin, the board built in the same project with the fab's rules. Show the sheet (`altium_show_schematic`).-7. **SPICE**, as on the Fusion lane (`step simulation`, the Adom SPICE dashboard, `evidence add --kind+8. **SPICE**, as on the Fusion lane (`step simulation`, the Adom SPICE dashboard, `evidence add --kind simulation`).-8. **Placement, then routing.** `start --board <run>/altium/<Design>.altium.json --spec <spec.json>+9. **Placement, then routing.** `start --board <run>/altium/<Design>.altium.json --spec <spec.json> --engine <you> --target <box> --remote-board altium:<the .PcbDoc path>`, then `rules use` again (it now loads into the board), `place check --moves` / `place land --moves`, `route`, `gate`, `land route`.-9. **Heatsink vias, pours, rounded corners**: `thermal-vias plan` / `land`, `pour`, `land vias`, `land+10. **Heatsink vias, pours, rounded corners**: `thermal-vias plan` / `land`, `pour`, `land vias`, `land pours`, `outline round`.-10. **Current, heat and fields.** `measure`, `analyze current`, `analyze thermal`; the field solve on the+11. **Current, heat and fields.** `measure`, `analyze current`, `analyze thermal`; the field solve on the snapshot analyze wrote (`adom-fields analyze --board <run>/board-live.kicad_pcb ...`), then analyze again; adom-fields in the right pane and `tour fields`. A neck is a return to the step that causes it (placement, or a wider net in `wideNets`), on camera.-11. **The pour walkthrough.** `step nets`, `tour nets`.-12. **Silkscreen.** `altium silkscreen --title "<board>"`.-13. **3D, finish, export.** `altium view3d` (read the still), `finish`, then `altium export`; answer+12. **The pour walkthrough.** `step nets`, `tour nets`.+13. **Silkscreen.** `altium silkscreen --title "<board>"`.+14. **3D, finish, export.** `altium view3d` (read the still), `finish`, then `altium export`; answer with each file's path. That is the finish line. ## Before the board: intake, sourcing, evidence The clock starts at the prompt even when there is no board yet. -- **`intake --prompt-time <UTC of the prompt> [--brief <file>] [--requirements requirements.json] [--target <box>] [--page <owner/slug>]`** opens the run with the `intake` step and no board. Pre-board steps are steps like any other: `step sourcing`, `step design`, `step simulation`, `step schematic`; every command is a turn charged to the step, and with `--target` each films the tool's own window when the step works in it, else Hydrogen with the run's dashboard, else the AI's terminal window, or records a skip when nothing meaningful is on screen. `start --board ... --spec ...` on the same `--run` attaches the board: same clock, same ledger, the steps before the board stay on the page.+- **`intake --prompt-time <UTC of the prompt> [--brief <file>] [--requirements requirements.json] [--target <box>] [--page <owner/slug>] [--eda kicad|fusion|altium]`** opens the run with the `intake` step and no board. Pre-board steps are steps like any other: `step sourcing`, `step design`, `step simulation`, `step schematic`; every command is a turn charged to the step, and with `--target` each films the tool's own window when the step works in it, else Hydrogen with the run's dashboard, else the AI's terminal window, or records a skip when nothing meaningful is on screen. `start --board ... --spec ...` on the same `--run` attaches the board: same clock, same ledger, the steps before the board stay on the page. - **`sourcing check --bom design/bom.csv [--profile fab|jlcpcb] [--qty N] [--requirements requirements.json]`** gates the BOM. The profile comes from the spec or the requirements (`sourcing.profile` or `fab.target`), `fab` by default. Every electrical reference on the board needs a row with an MPN (contacts and pads the footprint does not exclude from the BOM take a `no part` row; the diff lists missing and extra references). On `fab` every row is from Mouser or Adom stock and no row is an LCSC/JLCPCB part; on `jlcpcb` every row carries its LCSC number. The stock column carries its check date (`stock_checked_2026-09-29`, or a `stock_date` column); under twice the build quantity is a thin-stock warning. Before the board it checks the BOM alone; run it again after `start --board`, because `finish` wants the diff. - **`evidence add --kind calcs|simulation|vendor-model --file <json|md> [--note ...]`** records the design record in the run and the ledger with its sha256 (a copy lands in `<run>/evidence/`). A simulation result that says it fails (a top-level `"pass": false`, or a `"checks"` row `{"name","value","limit","pass"}` that fails) is refused. Vendor PSpice/LTspice runs belong to the separately published adom/adom-spice-skillpack; `--kind vendor-model` records their result. - **`finish`** also refuses without a passed sourcing check against the board when the spec or the requirements name a sourcing profile, and without the evidence kinds the spec lists in `"requireEvidence": ["calcs", "simulation"]`.
tools/altium-project.py+18−3@@ -235,7 +235,8 @@ def build_board(a): # the board read, for `adom-aiflow start --board <this> --remote-board altium:<pcb>` ps = ab('altium_pour_state', {}, timeout=300) out = STATE / (a.name + '.altium.json')- out.write_text(json.dumps({'source': 'altium-bridge', 'state': rs, 'pours': ps if ps.get('success') else None}))+ # projectDir: the renderer takes each part's courtyard and fab outline from its source KiCad footprint (pages/)+ out.write_text(json.dumps({'source': 'altium-bridge', 'state': rs, 'pours': ps if ps.get('success') else None, 'projectDir': str(STATE.resolve())})) rules = Path(a.fixed).with_name('board.kicad_dru') if a.fixed else STATE / 'board.kicad_dru' # the fab's rules beside the board read, where `start` looks for them (<board file>.kicad_dru) if rules.is_file(): shutil.copy(rules, Path(str(out)[:-len('.json')] + '.kicad_dru'))@@ -285,7 +286,10 @@ def cmd_schematic(a): ab('altium_open_project', {'projectPath': r['project'], 'filePath': r['project']}) ab('altium_show_schematic', {'filePath': sch}) ab('altium_run_command', {'command': 'Sch:Zoom', 'params': {'Action': 'All'}})- prove('schematic', {'sch': sch, 'project': r.get('project'), 'parts': r.get('placed'), 'nets': r.get('compiledNets'), 'boardOnly': board_only, 'pcb': b['pcb']})+ # parity: altium_build_schematic succeeds only when the sheet compiles to the netlist pin for pin; ERC:+ # the compiler's messages (Altium's connection matrix checks pin types)+ prove('schematic', {'sch': sch, 'project': r.get('project'), 'parts': r.get('placed'), 'nets': r.get('compiledNets'), 'boardOnly': board_only, 'pcb': b['pcb'],+ 'parity': True, 'erc': {'pass': not r.get('compileErrors'), 'errors': r.get('compileErrors') or []}}) ok('schematic %s: %d part(s) drawn, %d labelled pin(s), compiled to the netlist pin for pin (%d nets); the board followed with %d part(s) (%d parked for placement) in project %s' % ( sch, r.get('placed') or 0, r.get('labels') or 0, r.get('compiledNets') or 0, b['parts'], b['parked'], r.get('project')), ['Board-only parts (no symbol, on the board only): %s.' % ', '.join(board_only) if board_only else '',@@ -498,6 +502,17 @@ def cmd_export(a): dnp = sorted(r_ for r_, k in roles.items() if k == 'machine') if set(dnp) - bom_all: problems.append('machine parts missing from the BOM as DNP: %s' % ', '.join(sorted(set(dnp) - bom_all))) jlc_files(out, Path(pcb).stem, bom, cpl)+ # fills in the Gerbers: every copper layer Altium has poured carries filled regions (G36) in its Gerber+ ps = ab('altium_pour_state', {}, timeout=300)+ poured = sorted(l for l, mm2 in ((ps.get('filledAreaByLayerMm2') or {}) if ps.get('success') else {}).items() if (mm2 or 0) > 0)+ gext = lambda l: '.gtl' if l == 'F.Cu' else '.gbl' if l == 'B.Cu' else ('.g%s' % l[2:-3] if l.startswith('In') and l.endswith('.Cu') else None)+ regions = {}+ for l in poured:+ f = next((x for x in out.iterdir() if gext(l) and x.suffix.lower() == gext(l)), None)+ regions[l] = f.read_text(errors='ignore').count('G36') if f else 0+ fills_gerbers = {'pass': bool(poured) and all(n > 0 for n in regions.values()), 'regions': regions} if ps.get('success') else {'pass': None, 'why': 'altium_pour_state failed'}+ for l, n in regions.items():+ if n == 0: problems.append('the %s Gerber has no filled regions (G36) though Altium poured that layer' % l) if problems: err('manufacturing files written to %s, but they do not check out: %s' % (out, '; '.join(problems)), ['Fix the board or the parts\' parameters and run `altium export` again; never hand the human files that do not match the board.']) import zipfile zp = out / (Path(pcb.replace('\\', '/')).stem + '-gerbers.zip')@@ -506,7 +521,7 @@ def cmd_export(a): if x.suffix.lower() in ('.gtl', '.gbl', '.gts', '.gbs', '.gto', '.gbo', '.gtp', '.gbp', '.gko', '.gm1', '.txt') or re.fullmatch(r'\.g\d+|\.gm\d+', x.suffix.lower()): z.write(x, x.name) missing_numbers = sorted({r_[0] for r_ in [(row[0],) for row in table(out / 'bom-jlcpcb.csv')[1] if len(row) > 3 and not row[3].strip()]}) if (out / 'bom-jlcpcb.csv').is_file() else []- prove('export', {'dir': str(out.resolve()), 'gerberZip': zp.name, 'drillHits': hits, 'cplRefs': len(refs_in(cpl)), 'bomLines': len(table(bom)[1]), 'tool': 'altium'})+ prove('export', {'dir': str(out.resolve()), 'gerberZip': zp.name, 'drillHits': hits, 'cplRefs': len(refs_in(cpl)), 'bomLines': len(table(bom)[1]), 'tool': 'altium', 'fillsGerbers': fills_gerbers}) if os.environ.get('AIFLOW_EXE'): run([os.environ['AIFLOW_EXE'], '--run', str(RUN), 'timeline', 'write'], timeout=60) tps = sorted(r_ for r_, k in roles.items() if k == 'tp'); dnp = sorted(r_ for r_, k in roles.items() if k == 'machine') ok('manufacturing files in %s, checked against the board: Gerbers (%s), NC drill (%d hits = vias + drilled pads), pick and place (%d parts), BOM (%d lines), the JLCPCB BOM and CPL' % (
tools/project-page.py+26−4@@ -539,6 +539,12 @@ def short(brief): return brief or "" +ALTIUM_SECTION = """## Built in Altium Designer++AI Flow builds this board's Altium libraries from the parts' wiki pages above: one schematic library (SchLib) and one PCB library (PcbLib) with every symbol, footprint and 3D body, the part numbers and every distributor number on each symbol. The schematic and the board are drawn with them in Altium Designer through the Adom Altium Bridge.+"""++ def readme(cfg, lib, pkgid, repo_url): rows, merged = [], {} for p in cfg["parts"]: # one row per MPN, its references together@@ -552,7 +558,7 @@ def readme(cfg, lib, pkgid, repo_url): {cfg.get('summary') or re.sub(r"\A\s*#[^\n]*\n+", "", cfg['brief'])} -**Status: revision 1.** The parts are chosen and every one has its Adom wiki page{" or comes from the board's own project library" if any(p.get("projectLib") and not p.get("wiki") for p in cfg["parts"]) else ""}; the schematic and the board come next, built with [adom-aiflow](https://wiki.adom.inc/adom/adom-aiflow).+**Status: revision 1.** The parts are chosen and every one has its Adom wiki page{" or comes from the board's own project library" if any(p.get("projectLib") and not p.get("wiki") for p in cfg["parts"]) else ""}; the schematic and the board come next, built with [adom-aiflow](https://wiki.adom.inc/adom/adom-aiflow){" in Altium Designer" if cfg.get("eda") == "altium" else ""}. ## Components @@ -560,7 +566,7 @@ def readme(cfg, lib, pkgid, repo_url): |---|---|---|---|---|---| {chr(10).join(rows)} -## Install every part in KiCad+{ALTIUM_SECTION if cfg.get("eda") == "altium" else ""}{"" if cfg.get("eda") == "altium" else f"""## Install every part in KiCad This page is a KiCad Plugin and Content Manager repository. In KiCad, open **Plugin and Content Manager**, **Manage...**, add this URL, then install **{cfg['title']}** from the Libraries tab: @@ -569,7 +575,7 @@ This page is a KiCad Plugin and Content Manager repository. In KiCad, open **Plu ``` The library appears as `PCM_{lib}` (package `{pkgid}`): every symbol, footprint and 3D model of this board, from the parts' wiki pages.-"""+"""}""" def main():@@ -587,13 +593,29 @@ def main(): raise SystemExit(json.dumps({"ok": False, "error": f"{cfg['page']} does not exist; pass --create to make it"})) args = ["adom-wiki", "page", "create", slug, "--type", "hardware", "--kind", "board", "--title", cfg["title"], "--brief", cfg["brief"][:200], "--because", "a new board designed by adom-aiflow: its own project page, one page per board",- "--headline", cfg["title"][:60], "--subhead", cfg["brief"][:100], "--source", "kicad"]+ "--headline", cfg["title"][:60], "--subhead", cfg["brief"][:100], "--source", "altium" if cfg.get("eda") == "altium" else "kicad"] me = (wjson(["adom-wiki", "whoami"]).get("data") or {}).get("user", {}).get("username") or "" if owner != me: args += ["--org", owner] if cfg.get("private"): args.append("--private") sh(args); created = True lib, pkgid = lib_name(slug), pkg_id(owner, slug) version = cfg.get("version") or "1.0.0"+ if cfg.get("eda") == "altium":+ # Altium: the library step builds the SchLib and PcbLib from the same wiki pages; no KiCad PCM package+ open(os.path.join(work, "README.md"), "w").write(readme(cfg, lib, pkgid, None))+ pushed = None+ if cfg.get("push") and (created or cfg.get("replaceReadme")):+ p = subprocess.run(["adom-wiki", "repo", "push", cfg["page"], "--files", "README.md",+ "-m", "Revision 1: the chosen parts and the component table (adom-aiflow, Altium Designer)"], capture_output=True, text=True, cwd=work, stdin=subprocess.DEVNULL)+ m = re.search(r'"commit":\s*"([0-9a-f]{7,})"', p.stdout)+ pushed = m.group(1) if m else None+ if p.returncode != 0 or not pushed:+ raise SystemExit(json.dumps({"ok": False, "error": "push failed: " + clip(p.stderr or p.stdout, 600)}))+ print(json.dumps({"ok": True, "eda": "altium", "page": cfg["page"], "url": page_url, "created": created, "cloudLibrary": None, "libName": lib,+ "identifier": None, "repositoryUrl": None, "parts": len(cfg["parts"]), "counts": {}, "libraryCheck": {},+ "adds": {"addedPages": 0, "rateLimitWaits": [], "notes": []}, "projectLibParts": [p["mpn"] for p in cfg["parts"] if p.get("projectLib") and not p.get("wiki")],+ "readme": os.path.join(work, "README.md"), "commit": pushed}))+ return if any(p.get("wiki") for p in cfg["parts"]): lib_ref, bundle, report, adds = cloud_library(f"{owner}/{slug}", cfg["parts"], work, cfg.get("libRef")) else: # every part from the project library: no Cloud Library needed