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 #275: 0.2.10: Altium symbol and pin-type check, project-library parts, one desktop folder per run
18 files changed
+1341−86
Cargo.lock+10−10Cargo.toml+1−1crates/adom-aiflow/src/main.rs+8−5crates/adom-aiflow/src/schematic_symbols.rs+32−1crates/adom-aiflow/src/verified.rs+30−1docs/altium-lane.md+58−8docs/release-0.2.10.md+80package.json+1−1page.json+1−1skills/adom-aiflow/SKILL.md+10−4skills/aiflow-sourcing/SKILL.md+1−1tests/altium-lane.py+372tests/project-page-library.py+20−1tools/altium-lane.py+499tools/altium-project.py+173−46tools/fusion-project.py+18−1tools/project-common.py+18−1tools/project-page.py+9−4Cargo.lock+10−10@@ -4,7 +4,7 @@ version = 4 [[package]] name = "adom-aiflow"-version = "0.2.9"+version = "0.2.10" dependencies = [ "aiflow-analyze", "aiflow-board",@@ -25,7 +25,7 @@ dependencies = [ [[package]] name = "aiflow-analyze"-version = "0.2.9"+version = "0.2.10" dependencies = [ "aiflow-board", "serde",@@ -35,7 +35,7 @@ dependencies = [ [[package]] name = "aiflow-board"-version = "0.2.9"+version = "0.2.10" dependencies = [ "roxmltree", "serde",@@ -44,7 +44,7 @@ dependencies = [ [[package]] name = "aiflow-bridge"-version = "0.2.9"+version = "0.2.10" dependencies = [ "aiflow-board", "serde",@@ -53,7 +53,7 @@ dependencies = [ [[package]] name = "aiflow-copper"-version = "0.2.9"+version = "0.2.10" dependencies = [ "aiflow-board", "aiflow-grid",@@ -63,7 +63,7 @@ dependencies = [ [[package]] name = "aiflow-grid"-version = "0.2.9"+version = "0.2.10" dependencies = [ "aiflow-board", "serde",@@ -72,7 +72,7 @@ dependencies = [ [[package]] name = "aiflow-place"-version = "0.2.9"+version = "0.2.10" dependencies = [ "aiflow-board", "serde",@@ -81,7 +81,7 @@ dependencies = [ [[package]] name = "aiflow-pours"-version = "0.2.9"+version = "0.2.10" dependencies = [ "aiflow-board", "aiflow-copper",@@ -91,7 +91,7 @@ dependencies = [ [[package]] name = "aiflow-router"-version = "0.2.9"+version = "0.2.10" dependencies = [ "aiflow-board", "aiflow-grid",@@ -101,7 +101,7 @@ dependencies = [ [[package]] name = "aiflow-run"-version = "0.2.9"+version = "0.2.10" dependencies = [ "serde", "serde_json",
Cargo.toml+1−1@@ -14,7 +14,7 @@ members = [ ] [workspace.package]-version = "0.2.9"+version = "0.2.10" edition = "2021" license = "MIT" repository = "https://wiki.adom.inc/adom/adom-aiflow"
crates/adom-aiflow/src/main.rs+8−5@@ -176,7 +176,7 @@ enum Cmd { 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> },- /// The Altium side of a project: `library` (one .SchLib/.PcbLib from the parts' wiki pages, 3D bound, Mouser/DigiKey/LCSC numbers checked), `reference` (placement and spec from a reference KiCad board, in the Altium board's frame), `schematic` (the schematic drawn from the netlist in a project, compiled and checked pin by pin, then the board in the same project), `board` (the board alone, built from the netlist, every pin on its net), `reset`, `view3d`. `adom-aiflow altium <step> --help` for each.+ /// The Altium side of a project: `library` (one .SchLib/.PcbLib from the parts' wiki pages or the board's project library, 3D bound, Mouser/DigiKey/LCSC numbers checked), `reference` (placement and spec from a reference KiCad board, in the Altium board's frame), `schematic` (the schematic drawn from the netlist in a project, compiled and checked pin by pin, its symbols and pin types checked, then the board in the same project), `symbols` (that symbol check again on the drawn sheet), `board` (the board alone, built from the netlist, every pin on its net), `reset`, `view3d`. `adom-aiflow altium <step> --help` for each. Altium { #[arg(trailing_var_arg = true, allow_hyphen_values = true)] args: Vec<String>, #[arg(long)] target: Option<String> }, /// The KiCad lane's export step: Gerbers, drill, pick and place (CPL) and BOM written from the board with the native kicad-cli (ADOM_AIFLOW_KICAD_CLI, else kicad-cli; adom-aiflow-kicad-cli-remote in a container), then re-read against the board (copper layer count, drill hits against vias and drilled pads, CPL and BOM references, sides and top rotations). Records the `export` proof only when every file matches. Fusion: `fusion export`. Export { /// the board (default: the run's current board)@@ -2960,8 +2960,10 @@ fn main() { 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()]);+ let from_lib: Vec<&str> = v["projectLibParts"].as_array().into_iter().flatten().filter_map(|x| x.as_str()).collect();+ 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"], if from_lib.is_empty() { String::new() } else { format!(" and, for {}, from the board's project library", from_lib.join(", ")) }),+ &[if from_lib.is_empty() { String::new() } else { format!("from the board's project library (no public page yet): {}", from_lib.join(", ")) },+ "Next: `step library`, then `adom-aiflow altium library`: one SchLib and one PcbLib from the parts' wiki pages (project-library parts from their own KiCad files, through the same converters). Every step you leave is pushed to this page as steps/<nn>-<step>/README.md.".into()]); return; } let c = &v["counts"];@@ -3382,10 +3384,11 @@ fn main() { std::process::exit(status.code().unwrap_or(1)); } Cmd::Altium { args, target } => {- // the Altium steps: the shared project half (tools/project-common.py) with the Altium half after it+ // the Altium steps: the shared project half (tools/project-common.py), the shared silkscreen planner,+ // the lane's pure helpers (tools/altium-lane.py: the symbol check, project-library parts), then the Altium half 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 script = format!("{}\n{}\n{}", include_str!("../../../tools/project-common.py"), include_str!("../../../tools/silkscreen-plan.py"), include_str!("../../../tools/altium-project.py"));+ let script = format!("{}\n{}\n{}\n{}", include_str!("../../../tools/project-common.py"), include_str!("../../../tools/silkscreen-plan.py"), include_str!("../../../tools/altium-lane.py"), include_str!("../../../tools/altium-project.py")); let status = std::process::Command::new("python3").arg(lane_script("altium", &script)).args(args) .env("AIFLOW_RUN", &dir).env("AIFLOW_TARGET", &target).env("AIFLOW_THREAD", thread(&cli)).env("AIFLOW_EXE", std::env::current_exe().map(|p| p.display().to_string()).unwrap_or_default()) .status().unwrap_or_else(|e| err(&format!("altium steps need python3: {e}"), &[]));
crates/adom-aiflow/src/schematic_symbols.rs+32−1@@ -545,8 +545,9 @@ pub fn record(r: &mut Run, sch: &str, rep: &Value) { } /// `finish`: a run with a schematic needs a passing schematic check on the hierarchy as it is now.+/// A run with no KiCad schematic is judged on its tool's proof instead (`tool_gate`). pub fn gate(r: &Run) -> Result<(), String> {- let Some(sch) = r.data["schematic"].as_str() else { return Ok(()) };+ let Some(sch) = r.data["schematic"].as_str() else { return tool_gate(&r.dir) }; let Some(o) = r.outcomes().get(OUTCOME) else { return Err("schematic check (the run has a schematic; run `adom-aiflow schematic check --sch <root>.kicad_sch`, or `netlist check`, which runs it too)".into()) }; let now = crate::schematic_identity::capture(Path::new(sch)).map_err(|e| format!("schematic check (schematic hierarchy: {e})"))?; if o["schematicFiles"] != json!(now) { return Err("schematic check (a sheet changed since the last check; run it again)".into()); }@@ -554,6 +555,21 @@ pub fn gate(r: &Run) -> Result<(), String> { Ok(()) } +/// The Altium lane's symbol check (`altium schematic` / `altium symbols` record it in+/// `<run>/proofs/schematic.json` as `symbols`): `finish` refuses while it has errors. A proof without+/// it (no Altium schematic, or one from before 0.2.10) leaves the verified list's "not known".+pub fn tool_gate(dir: &Path) -> Result<(), String> {+ let proof = std::fs::read_to_string(dir.join("proofs").join("schematic.json")).ok().and_then(|t| serde_json::from_str::<Value>(&t).ok());+ let s = proof.as_ref().map(|p| &p["data"]["symbols"]);+ match s.and_then(|s| s["ok"].as_bool()) {+ Some(false) => {+ let errs: Vec<&str> = s.and_then(|s| s["errors"].as_array()).into_iter().flatten().filter_map(|e| e.as_str()).collect();+ Err(format!("schematic check ({} symbol error(s) on the Altium sheet: {}{}; fix the symbols, then `adom-aiflow altium symbols` or `altium schematic` again)", errs.len(), errs.iter().take(3).cloned().collect::<Vec<_>>().join("; "), if errs.len() > 3 { "; ..." } else { "" }))+ }+ _ => Ok(()),+ }+}+ /// The reply: (message, hints). pub fn render(rep: &Value, in_run: bool) -> (String, Vec<String>) { let list = |k: &str| rep[k].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(|s| format!(" {s}"))).collect::<Vec<_>>()).unwrap_or_default();@@ -761,4 +777,19 @@ mod tests { record(&mut run, root.to_str().unwrap(), &rep); assert!(gate(&run).is_ok()); }++ /// An Altium run (no KiCad schematic) is judged on the symbol check its `altium schematic` proof carries.+ #[test]+ fn an_altium_symbol_check_with_errors_stops_finish() {+ let f = Fixture::new();+ assert!(tool_gate(&f.0).is_ok(), "no proof: nothing to judge (the verified list says not known)");+ std::fs::create_dir_all(f.0.join("proofs")).unwrap();+ f.write("proofs/schematic.json", r#"{"at":"t","data":{"parity":true,"erc":{"pass":true,"errors":[]}}}"#);+ assert!(tool_gate(&f.0).is_ok(), "a proof from before 0.2.10 has no symbol check");+ f.write("proofs/schematic.json", r#"{"at":"t","data":{"parity":true,"symbols":{"ok":false,"errors":["pins on the body: U1 (TPS54202DDCR) mode 0 (std): 6 of 6 pin(s) not on the body edge"]}}}"#);+ let e = tool_gate(&f.0).unwrap_err();+ assert!(e.contains("1 symbol error(s)") && e.contains("TPS54202DDCR") && e.contains("altium symbols"), "{e}");+ f.write("proofs/schematic.json", r#"{"at":"t","data":{"parity":true,"symbols":{"ok":true,"errors":[],"warnings":["x"]}}}"#);+ assert!(tool_gate(&f.0).is_ok());+ } }
crates/adom-aiflow/src/verified.rs+30−1@@ -97,7 +97,8 @@ pub fn run_items(r: &aiflow_run::Run, facts: Option<&BoardFacts>) -> Vec<Item> { /// 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.+/// parity with the netlist, the compiler's ERC) and its symbol check (`symbols`, read as the+/// `schematic-check` outcome) 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()@@ -108,6 +109,14 @@ pub fn with_tool_proofs(outcomes: &Map<String, Value>, dir: &std::path::Path) -> 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)"})); } }+ // the symbol check on the drawn sheet (tools/altium-lane.py check_symbols): pins on their bodies,+ // pin types on active parts, polarity drawings; the same item KiCad's `schematic check` records+ if !o.contains_key(crate::schematic_symbols::OUTCOME) {+ if let Some(ok) = s["symbols"]["ok"].as_bool() {+ let n = |k: &str| s["symbols"][k].as_array().map(|a| a.len()).unwrap_or(0);+ o.insert(crate::schematic_symbols::OUTCOME.into(), json!({"ok": ok, "errors": n("errors"), "warnings": n("warnings"), "report": s["symbols"]["report"], "source": "altium schematic (symbols and pin types read back from the drawn sheet)"}));+ }+ } } o }@@ -136,6 +145,26 @@ mod tests { 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");+ // no symbol check in the proof (an Altium run from before 0.2.10): the item stays not known+ assert!(matches!(st("schematic symbols and pin types"), super::State::Unknown(_)));+ // the Altium symbol check maps into the outcome KiCad's `schematic check` records+ std::fs::write(dir.join("proofs/schematic.json"), r#"{"at":"t","data":{"parity":true,"erc":{"pass":true,"errors":[]},"symbols":{"ok":true,"errors":[],"warnings":["pin type: U1 pin 2 (TPS54202DDCR): power pin `GND` is passive; declare it Power"],"report":"/r/schematic-check.json"}}}"#).unwrap();+ let mut gate = serde_json::Map::new();+ gate.insert("gate".into(), serde_json::json!({"newErrors": 0, "unconnected": 0}));+ let o = super::with_tool_proofs(&gate, &dir);+ assert_eq!(o[crate::schematic_symbols::OUTCOME]["ok"], true);+ assert_eq!(o[crate::schematic_symbols::OUTCOME]["warnings"], 1);+ 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 symbols and pin types"), super::State::Yes);+ assert!(super::all_passed(&items), "an Altium board can be fully cleared: {}", super::line(&items));+ std::fs::write(dir.join("proofs/schematic.json"), r#"{"at":"t","data":{"parity":true,"erc":{"pass":true,"errors":[]},"symbols":{"ok":false,"errors":["pin type: U2 pin 5 (AP2112K-3.3TRG1): output `VOUT` is passive"],"warnings":[]}}}"#).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));+ assert!(matches!(items.iter().find(|i| i.name == "schematic symbols and pin types").unwrap().state, super::State::No(_)));+ let mut rec = serde_json::Map::new();+ rec.insert(crate::schematic_symbols::OUTCOME.into(), serde_json::json!({"ok": true}));+ assert_eq!(super::with_tool_proofs(&rec, &dir)[crate::schematic_symbols::OUTCOME]["ok"], true, "a recorded outcome wins"); let _ = std::fs::remove_dir_all(&dir); }
docs/altium-lane.md+58−8@@ -16,14 +16,19 @@ 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.+ walks through them); a part with no page fails the step, unless the board's project library gives it+ (`parts --project-lib <dir>`, or `"projectLib"` in the spec or requirements: its KiCad symbol, footprint,+ STEP model and provenance.json entry, checked complete there). `altium library` then builds that part+ from those local files (below). - `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+ work: the README with the component table, each part linked to its page (a project-library part is+ listed as "project library (no public page yet)" and named in the "Built in Altium Designer" section).+ 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.+`altium schematic` record those proofs here (`altium schematic` runs the symbol and pin-type check+itself, below). ## The whole project, by command @@ -31,15 +36,60 @@ After `sourcing`, the Altium list has the same two steps as a KiCad board from s | step | what it does | |---|---|-| `altium library --parts <netlist.json> --footprints <folder> --name <Project>` | ONE project library (a `.SchLib` and a `.PcbLib`): each part from its wiki component page (footprint and symbol converted from the page's KiCad files, the STEP bound), MPN, Manufacturer and the Mouser, DigiKey and LCSC numbers checked against the exact MPN. Board-only pads with no MPN (test points) take their footprint from `--footprints`. Run again with new parts and they join the same library; `--only <MPN>` imports one part again. |+| `altium library --parts <netlist.json> --footprints <folder> --name <Project>` | ONE project library (a `.SchLib` and a `.PcbLib`): each part from its wiki component page (footprint and symbol converted from the page's KiCad files, the STEP bound), MPN, Manufacturer and the Mouser, DigiKey and LCSC numbers checked against the exact MPN. The page is the netlist's `page`, else the one `parts` matched, else the MPN. A part with no public page that `parts --project-lib` took from the board's project library is built from its local files through the same converters (below). Board-only pads with no MPN (test points) take their footprint from `--footprints`. Run again with new parts and they join the same library; `--only <MPN>` imports one part again. Each symbol's spec is kept in `<run>/altium/symbols/` for the schematic's symbol check. | | `altium reference --kicad <board.kicad_pcb> --spec <spec.json>` | A reference design's placement and spec coordinates in the Altium board's frame: `moves.json`, `fixed.json`, `spec.json` in `<run>/altium/`. |-| `altium schematic --netlist <netlist.json> --name <Design>` | The schematic drawn from the netlist on Altium's own sheet template (one column per `block`, every connected pin with a wire and a net label, the title block filled), inside a new project (`<Design>.PrjPcb` with the board, the libraries and the manufacturing OutJob), compiled and checked pin by pin against the netlist. Then the board in the same project: the outline, the fixed parts where the spec puts them, every other part parked below the board, every pad on its net, the run's fab rules loaded. Writes `<run>/altium/<Design>.altium.json` for `start`. |+| `altium schematic --netlist <netlist.json> --name <Design>` | The schematic drawn from the netlist on Altium's own sheet template (one column per `block`, every connected pin with a wire and a net label, the title block filled), inside a new project (`<Design>.PrjPcb` with the board, the libraries and the manufacturing OutJob), compiled and checked pin by pin against the netlist, then its symbols and pin types checked as drawn (below; an error fails the step after everything is built and recorded). Then the board in the same project: the outline, the fixed parts where the spec puts them, every other part parked below the board, every pad on its net, the run's fab rules loaded. Writes `<run>/altium/<Design>.altium.json` for `start`. |+| `altium symbols` | The symbol and pin-type check again on the sheet `altium schematic` drew (after a library fix, or on a run from before 0.2.10); it replaces the schematic proof's `symbols` and keeps the rest. | | `altium board --netlist <netlist.json> --name <Design>` | The board alone, as above (no schematic). | | `altium silkscreen [--title "<board>"]` | The silkscreen planned by the planner the lanes share (`tools/silkscreen-plan.py`) and landed: part names beside their parts, machine contacts framed with their nets on the edge side, values, leaders where a label has to go further out, the board identity on the bottom. Checked on Altium's own measure of every printed label: clear of copper, vias, the board edge and each other. What it cannot fit is listed, never dropped silently. | | `altium view3d` | The board in Altium's 3D view: a still saved in the run (every part must sit flat on its pads) and a slow orbit for the recording. | | `altium export` | The manufacturing set from the project's OutJob: Gerbers (zipped), NC drill, pick and place (CSV, mm) and the BOM with every distributor number, plus the JLCPCB BOM and CPL; pulled into `<run>/manufacturing/` and checked against the board (every layer a fab needs, drill hits = vias + drilled pads, every placed part in the pick and place and the BOM, test points out, machine parts DNP). | | `altium reset` | A refine pass: every track, via and pour cleared from the live board; the parts stay. | +## Parts from the board's project library++A part whose wiki page cannot be public yet (a private page, a reviewed project-only footprint, a page+waiting for its owner) comes from the board's project library, as on KiCad (skill aiflow-sourcing):+`parts --project-lib <dir>` checks its symbol, footprint, STEP model and provenance source and records+them in the run. `altium library` then takes that part from those files instead of a page: its one symbol+is cut out of the library's `.kicad_sym` (with the symbol it extends), and the symbol, the footprint and+the STEP are staged as `<run>/altium/pages/<MPN>/` (where a wiki page is cloned, so the board render takes+its courtyard from that footprint too) and go through the same `altium-kicad2altium` and+`altium-symbol-modes` conversions and the same `altium_import_part`. Manufacturer, package, description and+any `mouser`/`digikey`/`lcsc` numbers come from its provenance.json entry (then the BOM row); the+distributor lookups run as for any part. A page named in the netlist, or one `parts` matched, still wins.+`finish` re-checks that the project-library files are still there.++## Symbols and pin types on the Altium sheet++`altium schematic` checks what a clean compile does not prove (KiCad's `schematic check`, issue #39), on+the sheet as Altium reads it back: `altium_schematic_data` (every placed part's pins with their electrical+type and place), `altium_symbol_prims` per used symbol (its SchLib record) and the symbol spec the library+step imported (kept in `<run>/altium/symbols/`). The logic is `check_symbols` in `tools/altium-lane.py`:++- **Pins on the body.** A pin's x,y is its body end and must sit on the body rectangle's edge, pointing+ away from it: in every display mode of the spec, and in the mode drawn on the sheet (read back, so a+ symbol imported before its spec was kept is still checked there). Pins one pin length off the box with+ their names in the gap (the symbol_modes bug fixed in the bridge on 2026-10-19) are errors. A small part+ drawn as its own symbol (a potentiometer's wiper) only warns.+- **Pin types on active devices** (U/IC references, op-amps and comparators), with KiCad's rules: an+ amplifier input or an output (`OUT`, `VOUT`, `OUTA`) declared Passive is an error; a Passive power pin is+ an error on op-amps and comparators and a warning on other ICs. `spec.schematic.allowPassive` (with a+ datasheet `why`) turns an error into a warning, as on KiCad.+- **Polarity drawings** of small parts drawn as their own symbol: the diode's triangle and cathode bar at+ the pin named K, a BJT's emitter arrow on the pin named E (`spec.schematic.polarityReviewed`).+- Not checked on Altium: sheet readability (`altium_build_schematic` draws one wire stub and one net label+ per pin), op-amp `+`/`-` drawings (the box symbol shows the pin names).++The result goes into the schematic proof (`<run>/proofs/schematic.json`, `symbols: {ok, errors, warnings}`)+and `<run>/schematic-check.json`; the verified list reads it as "schematic symbols and pin types" (a+recorded `schematic-check` outcome still wins), and `finish` refuses while it has errors. A proof from+before 0.2.10 has no `symbols`: the item stays "not known" until `altium symbols` runs.++Today's bridge creates every SchLib pin as Passive (`altium_build_symbol_modes` does not take a pin type+from the spec), so an IC's power pins warn and an output named `OUT`/`VOUT` is an error until the bridge+carries the KiCad pin types into Altium (follow-up in `docs/release-0.2.10.md`).+ ## Starting the board After `altium schematic`, attach the board to the run (same `--run` as the intake):@@ -77,8 +127,8 @@ of the KiCad footprint its Altium footprint was converted from (`pages/<MPN>/*.k 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.+KiCad ones. The verified list reads `altium schematic`'s pin-for-pin parity, compiler ERC and symbol+check 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
docs/release-0.2.10.mdadded+80@@ -0,0 +1,80 @@+# adom-aiflow 0.2.10++The two Altium gaps 0.2.9 left open: the schematic symbol and pin-type check, and parts from the+board's own project library.++- **Symbols and pin types on the Altium sheet.** `altium schematic` now checks, after the sheet compiles+ pin for pin, what a clean compile does not prove (the Altium half of `schematic check`, issue #39). It+ reads the drawn sheet back (`altium_schematic_data`: every placed part's pins with their electrical type+ and place), each used symbol's SchLib record (`altium_symbol_prims`) and the symbol spec the library step+ imported, now kept in `<run>/altium/symbols/`. The logic is `check_symbols` in the new+ `tools/altium-lane.py` (pure, embedded in front of `altium-project.py`):+ - every pin's x,y (its body end) on the body rectangle's edge and pointing away from it, in every+ display mode of the spec and in the mode drawn on the sheet: pins one pin length off the box with+ their names in the gap (the bridge's symbol_modes bug fixed 2026-10-19) are errors;+ - pin electrical types on U/IC parts, op-amps and comparators with KiCad's rules (an amplifier input or+ an `OUT`/`VOUT` output declared Passive is an error; a Passive power pin an error on op-amps and+ comparators, a warning on other ICs), `spec.schematic.allowPassive` honoured;+ - polarity drawings of small parts drawn as their own symbol: diode triangle and cathode bar at the pin+ named K, a BJT's emitter arrow on the pin named E, `spec.schematic.polarityReviewed` honoured.+ The result is recorded in the schematic proof (`symbols: {ok, errors, warnings, checked, report}`) and+ `<run>/schematic-check.json`; an error fails `altium schematic` after the sheet, the board and the proof+ are written. `verified.rs` maps it into the `schematic-check` outcome KiCad's check records (a recorded+ outcome still wins), so the verified list's "schematic symbols and pin types" is yes or no on Altium+ and an Altium board can be fully cleared; `finish` refuses while the Altium check has errors+ (`schematic_symbols::tool_gate`; KiCad runs are judged exactly as before).+- **`altium symbols`.** The same check again on the drawn sheet, without redrawing it (after a library+ fix, or on a run from before 0.2.10, whose proof has no `symbols` and whose list says "not known").+- **Project-library parts on Altium.** `altium library` takes a part with no public wiki page from the+ board's project library (`parts --project-lib <dir>`, or `"projectLib"` in the spec or requirements,+ checked complete there and recorded in the run's `parts` outcome). Its one symbol is cut out of the+ library's `.kicad_sym` (with the symbol it extends; the converter reads every pin in its file), and the+ symbol, footprint and STEP are staged as `<run>/altium/pages/<MPN>/` (so the board render takes its+ courtyard from that footprint, as for a wiki part). The per-part conversion is now one function for+ every source (`part_source` + `convert_part`: `altium-kicad2altium`, `altium-symbol-modes`, the STEP,+ `altium_import_part`); manufacturer, package, description and any `mouser`/`digikey`/`lcsc` come from+ the part's provenance.json entry. The page a wiki part is cloned from is now the netlist's `page`, else+ the page `parts` matched (it knows slugs such as `ap2112k-3-3trg1`), else the MPN as before. The OK+ line and the library proof (`projectLibParts`) name the project-library parts.+- **`project-page` on Altium** names the project-library parts in the "Built in Altium Designer" section+ (the component table already said "project library (no public page yet)"), and its OK line lists them.+- Tests: `tests/altium-lane.py` (15: the check on plain data, a project-library part staged, and+ `altium library` / `altium symbols` end to end against fake bridge and converter CLIs),+ `test_altium_page_lists_project_library_parts` in `tests/project-page-library.py`,+ `tool_proofs_fill_in_parity_erc_and_gerber_fills` extended, `an_altium_symbol_check_with_errors_stops_finish`.++- **One desktop project folder per run.** Every run is named `aiflow-run`, so the Altium (and Fusion) lane put every project in `Documents/AdomAiFlow/aiflow-run` on the desktop, and a second project wrote over the first one's files. The folder is now `<project folder>-<run>`, claimed with a `.aiflow-run` marker naming the run; a folder another run claimed gets a numbered sibling. Runs that already recorded their folder keep it.+- **Older Altium bridges.** Before Altium Bridge 0.2.52 every library pin was Passive and `altium_symbol_prims` answered the body of any display mode, so on such a bridge the pin-type findings, and the sheet-body findings of a symbol with no kept spec, are warnings that say to update the bridge instead of errors.++## Bridge side (adom/altium-bridge 0.2.52)++All three follow-ups below shipped in Altium Bridge 0.2.52 and were verified live on Altium Designer 26.11.1: `altium-symbol-modes` carries the KiCad pin type and `build_symbol_modes` sets it (the TPS54202's GND, SW and VIN read Power (7), FB and EN Input (0), BOOT Passive (4), the numbering this check assumes); `read_symbol` returns each pin's `electrical`; `symbol_prims`' `bodyRectMil` is the union of the drawn mode's rectangles. On the 2026-10-16 TPS54202 buck run, `altium symbols` passes with only the "no kept spec" note for that run's pre-0.2.10 library.++## The original write-up of the follow-ups+++1. **Pin electrical types into Altium (needed for real ICs to pass the pin-type check).** Today every SchLib+ pin is created Passive: `CmdBuildSymbolModes` (src/agent/AdomAgent.pas.tmpl) sets+ `Pin.Electrical := eElectricPassive` for every `PIN` line, and `tools/symbol_modes.py` drops the KiCad+ pin type (`parse_pins` keeps only number and name). So on Altium an IC's power pins warn and an output+ named `OUT`/`VOUT` (an LDO) is an error until:+ - `symbol_modes.py` keeps the type (`(pin <etype> <shape> ...)`, the first atom) and writes it as a+ 10th field of every PIN line, after the hide field (empty for IC pins):+ `PIN|mode|number|name|x|y|orient|len|<hide or empty>|<kicad etype>`, in `build()` and `native_lines()`;+ - `CmdBuildSymbolModes` (the one place `altium_import_part` and `altium_build_symbol_modes` build pins)+ maps `FieldP(L, 10)`: `input` -> eElectricInput, `output` -> eElectricOutput, `bidirectional` ->+ eElectricIO, `tri_state` -> eElectricHiZ, `open_collector` -> eElectricOpenCollector, `open_emitter`+ -> eElectricOpenEmitter, `power_in`/`power_out` -> eElectricPower, anything else or absent ->+ eElectricPassive (today's behaviour);+ - `altium_read_symbol` adds `electrical` (the Pin.Electrical ordinal) to each `pins[]` entry, so a+ library can be checked without a sheet.+ Then `altium library --again` and `altium schematic` (or `altium symbols`) make the Altium pin types+ the KiCad symbol's; nothing in AI Flow changes.+2. **Doc only:** `altium_schematic_data`'s verb_doc lists pins as `{number, name, electrical}`; the agent+ already returns `xMil`, `yMil`, `orientation` and `lengthMil` per pin, which this check uses (the body+ end on the sheet). Say so, and that `electrical` is TPinElectrical (0 Input, 1 IO, 2 Output,+ 3 OpenCollector, 4 Passive, 5 HiZ, 6 OpenEmitter, 7 Power).+3. **Optional:** `altium_symbol_prims` reports `bodyRectMil` for the first rectangle of any display mode and+ counts pins over all modes. Per-mode rectangles and pins with positions (`modes: [{mode, rectsMil,+ pins: [{number, name, xMil, yMil, orientation, lengthMil, electrical}]}]`) would let the check read+ every display mode from Altium itself instead of the kept spec.
package.json+1−1@@ -1,7 +1,7 @@ { "slug": "adom-aiflow", "type": "app",- "version": "0.2.9",+ "version": "0.2.10", "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.9",+ "version": "0.2.10", "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+10−4@@ -181,16 +181,22 @@ everything drawn on the design. The lane's steps are the Fusion lane's, with `al **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. **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>+ design/bom.csv` (every MPN on its wiki component page; a part with no public page yet from the board's+ project library: `parts --project-lib <dir>`, aiflow-sourcing), 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>`.+ .kicad_mod for board-only pads> --name <Project>`. Project-library parts are built from their own+ KiCad symbol, footprint and STEP through the same converters as wiki parts; the OK line names them. 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). 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`).+ sheet drawn and compiled pin for pin, the board built in the same project with the fab's rules, then+ the symbols checked as drawn (every pin on its body edge in every display mode, pin types on active+ parts, diode and transistor polarity drawings). An error there fails the step after everything is+ built: fix the part (`altium library --only <MPN>`) or justify it (`spec.schematic.allowPassive` /+ `polarityReviewed` with a datasheet `why`), then `altium symbols` checks the sheet again. The verified+ list's "schematic symbols and pin types" and `finish` read it. Show the sheet (`altium_show_schematic`). 8. **SPICE**, as on the Fusion lane (`step simulation`, the Adom SPICE dashboard, `evidence add --kind simulation`). 9. **Placement, then routing.** `start --board <run>/altium/<Design>.altium.json --spec <spec.json>
skills/aiflow-sourcing/SKILL.md+1−1@@ -145,7 +145,7 @@ When a part's page must stay private, waits for an owner's sign-off, or the foot {"parts": [{"mpn": "ABC-123", "source": "datasheet rev C, fig. 12 (AI-created footprint)", "symbol": "ABC-123", "footprint": "QFN-16_3x3", "model": "3dmodels/ABC-123.step"}]} ``` -`symbol` defaults to the MPN, `footprint` to the symbol's Footprint property, `model` to the footprint's model file. `parts` checks every listed MPN has all four (symbol, footprint, STEP, source) and still prefers a wiki page when one exists. `project-page` packages these parts into the board's PCM library beside the wiki parts, and `finish` lists them (and refuses if their files went missing). Publish the pages when they can be public; the project library is the evidence until then.+`symbol` defaults to the MPN, `footprint` to the symbol's Footprint property, `model` to the footprint's model file. `parts` checks every listed MPN has all four (symbol, footprint, STEP, source) and still prefers a wiki page when one exists. `project-page` packages these parts into the board's PCM library beside the wiki parts (on Altium, `altium library` builds them into the SchLib and PcbLib from these same files), and `finish` lists them (and refuses if their files went missing). Publish the pages when they can be public; the project library is the evidence until then. ## Required component pages include off-board hardware
tests/altium-lane.pyadded+372@@ -0,0 +1,372 @@+"""The Altium lane's symbol check and project-library parts, with no Altium and no bridge:++ - check_symbols (tools/altium-lane.py) on plain data shaped as altium_schematic_data, altium_symbol_prims+ and the altium-symbol-modes spec return them: pins off the body edge (the symbol_modes bug fixed+ 2026-10-19) are errors, pin types on active parts follow the KiCad check's rules, a diode drawn+ backwards is an error, and a symbol with no kept spec is checked on its drawn mode only;+ - project-library parts: the part's one symbol cut out of a many-symbol library, staged as the lane's+ pages/<key>/, and `altium library` run end to end against fake adom-bridge / converter CLIs on PATH:+ the project-library part goes through the same converters and altium_import_part as a wiki part.++ python3 tests/altium-lane.py+"""+import importlib.util, json, os, subprocess, sys, tempfile, unittest+from pathlib import Path++ROOT = Path(__file__).resolve().parents[1]+sys.dont_write_bytecode = True # no tools/__pycache__ from loading the lane helpers+_spec = importlib.util.spec_from_file_location('altium_lane', ROOT / 'tools' / 'altium-lane.py')+lane = importlib.util.module_from_spec(_spec); _spec.loader.exec_module(lane)++# a 6-pin IC in symbol_modes' 2-column look: body 0..800 x -400..0 mil, pins on its left and right edges+IC_GOOD = """META|TPS54202DDCR|buck|U+MODE|0|std+RECT|0|0|-400|800|0+PIN|0|1|GND|0|-100|left|300+PIN|0|2|SW|0|-200|left|300+PIN|0|3|VIN|0|-300|left|300+PIN|0|4|FB|800|-300|right|300+PIN|0|5|EN|800|-200|right|300+PIN|0|6|BOOT|800|-100|right|300+MODE|1|2col+RECT|1|0|-400|800|0+PIN|1|1|GND|0|-100|left|300+PIN|1|2|SW|0|-200|left|300+PIN|1|3|VIN|0|-300|left|300+PIN|1|4|FB|800|-100|right|300+PIN|1|5|EN|800|-200|right|300+PIN|1|6|BOOT|800|-300|right|300+"""+# the same symbol as symbol_modes wrote it before 2026-10-19: every pin's OUTER end, one pin length off the box+IC_BAD = IC_GOOD.replace('|0|-100|left', '|-300|-100|left').replace('|0|-200|left', '|-300|-200|left').replace('|0|-300|left', '|-300|-300|left').replace('|800|-', '|1100|-')++# KiCad's Device:D as native_lines converts it: K left, bar and triangle apex at the K end (mil, y up)+DIODE_GOOD = """META|D-KEY|diode|D+MODE|0|native+LINE|0|-50|50|-50|-50+LINE|0|50|0|-50|0+LINE|0|50|50|50|-50+LINE|0|50|-50|-50|0+LINE|0|-50|0|50|50+PIN|0|1|K|-50|0|left|100|hide+PIN|0|2|A|50|0|right|100|hide+"""+# the drawing mirrored, pins kept: the triangle and the bar point at the anode+DIODE_BAD = """META|D-KEY|diode|D+MODE|0|native+LINE|0|50|50|50|-50+LINE|0|-50|0|50|0+LINE|0|-50|50|-50|-50+LINE|0|-50|-50|50|0+LINE|0|50|0|-50|50+PIN|0|1|K|-50|0|left|100|hide+PIN|0|2|A|50|0|right|100|hide+"""+++def placed(ref, libref, spec_text, at=(1000, 2000), types=None, mode=0):+ """A placed part as altium_schematic_data reports it: mode-0 pins at their sheet position."""+ sym = lane.parse_symbol_spec(spec_text)+ pins = []+ orient = {'right': 0, 'up': 1, 'left': 2, 'down': 3}+ for p in sym['modes'][mode]['pins']:+ pins.append({'number': p['number'], 'name': p['name'], 'electrical': (types or {}).get(p['name'], 4),+ 'xMil': at[0] + p['x'], 'yMil': at[1] + p['y'], 'orientation': orient[p['orient']], 'lengthMil': p['len']})+ return {'designator': ref, 'libReference': libref, 'comment': '', 'xMil': at[0], 'yMil': at[1], 'orientation': 0, 'pins': pins}+++def prims(spec_text):+ """altium_symbol_prims for a SchLib symbol built from this spec."""+ sym = lane.parse_symbol_spec(spec_text)+ rects = [r for m in sym['modes'].values() for r in m['rects']]+ return {'ok': True, 'pins': sum(len(m['pins']) for m in sym['modes'].values()), 'rectangles': len(rects), 'displayModeCount': len(sym['modes']),+ 'bodyRectMil': [int(v) for v in lane._box(rects[0])] if rects else [0, 0, 0, 0]}+++POWER = {'GND': 7, 'VIN': 7}+++class SymbolCheck(unittest.TestCase):+ def test_a_good_symbol_passes(self):+ rep = lane.check_symbols([placed('U1', 'TPS54202DDCR', IC_GOOD, types=POWER)], {'TPS54202DDCR': IC_GOOD}, {'TPS54202DDCR': prims(IC_GOOD)})+ self.assertTrue(rep['ok'], rep)+ self.assertEqual(rep['warnings'], [])+ self.assertEqual(rep['checked']['bodyEdgeModes'], 2)+ self.assertEqual(rep['checked']['drawnSymbols'], 1)+ self.assertEqual(rep['checked']['pinTypes'], 1)++ def test_pins_one_pin_length_off_the_body_are_errors(self):+ rep = lane.check_symbols([placed('U1', 'TPS54202DDCR', IC_BAD, types=POWER)], {'TPS54202DDCR': IC_BAD}, {'TPS54202DDCR': prims(IC_BAD)})+ self.assertFalse(rep['ok'])+ edge = [e for e in rep['errors'] if e.startswith('pins on the body')]+ # both spec modes and the drawn sheet+ self.assertEqual(len(edge), 3, rep['errors'])+ self.assertIn('6 of 6 pin(s) not on the body edge', edge[0])+ self.assertIn('pin 1 (GND) 300 mil off the body edge', edge[0])+ self.assertTrue(any('as drawn on the sheet' in e for e in edge))++ def test_a_pin_pointing_into_the_body_is_an_error(self):+ bad = IC_GOOD.replace('PIN|0|4|FB|800|-300|right', 'PIN|0|4|FB|800|-300|left')+ rep = lane.check_symbols([], {'X': bad}, {}, None)+ self.assertTrue(rep['ok'], 'nothing placed: nothing checked')+ rep = lane.check_symbols([placed('U1', 'X', bad, types=POWER)], {'X': bad}, {'X': prims(bad)})+ self.assertIn('pin 4 (FB) points into the body', ' '.join(rep['errors']))++ def test_pin_types_follow_the_kicad_rules(self):+ ldo = IC_GOOD.replace('|SW|', '|VOUT|').replace('|BOOT|', '|NC|')+ rep = lane.check_symbols([placed('U2', 'AP2112K', ldo)], {'AP2112K': ldo}, {'AP2112K': prims(ldo)})+ self.assertFalse(rep['ok'])+ self.assertEqual(rep['errors'], ['pin type: U2 pin 2 (AP2112K): output `VOUT` is passive; declare it Output (Power for a regulator output)'])+ self.assertTrue(any('power pin `GND` is passive' in w for w in rep['warnings']), rep['warnings'])+ # typed right in Altium: clean+ rep = lane.check_symbols([placed('U2', 'AP2112K', ldo, types={'VOUT': 7, 'GND': 7, 'VIN': 7})], {'AP2112K': ldo}, {'AP2112K': prims(ldo)})+ self.assertTrue(rep['ok'], rep)+ self.assertEqual(rep['warnings'], [])+ # the datasheet reason in spec.schematic.allowPassive turns the error into a warning; no reason does not count+ why = {'schematic': {'allowPassive': [{'ref': 'U2', 'pin': 'VOUT', 'why': 'datasheet p.3: VOUT is a passive switch'}]}}+ rep = lane.check_symbols([placed('U2', 'AP2112K', ldo)], {'AP2112K': ldo}, {'AP2112K': prims(ldo)}, why)+ self.assertTrue(rep['ok'], rep)+ self.assertTrue(any('allowed by spec.schematic.allowPassive' in w for w in rep['warnings']))+ rep = lane.check_symbols([placed('U2', 'AP2112K', ldo)], {'AP2112K': ldo}, {'AP2112K': prims(ldo)}, {'schematic': {'allowPassive': [{'ref': 'U2', 'pin': '2'}]}})+ self.assertFalse(rep['ok'])+ # a passive part (R, C) is not an active device: its pin types are not judged+ rep = lane.check_symbols([placed('R1', 'AP2112K', ldo)], {'AP2112K': ldo}, {'AP2112K': prims(ldo)})+ self.assertTrue(rep['ok'], rep)+ self.assertEqual(rep['checked']['pinTypes'], 0)++ def test_an_op_amp_passive_input_is_an_error(self):+ amp = IC_GOOD.replace('|SW|', '|IN+|').replace('|VIN|', '|IN-|').replace('|FB|', '|OUT|').replace('|EN|', '|V+|')+ rep = lane.check_symbols([placed('A1', 'OPA', amp, types={'OUT': 2, 'V+': 7, 'GND': 7})], {'OPA': amp}, {'OPA': prims(amp)})+ self.assertEqual(len([e for e in rep['errors'] if 'amplifier input' in e]), 2, rep['errors'])++ def test_a_diode_drawn_backwards_is_an_error(self):+ rep = lane.check_symbols([placed('D1', 'D-KEY', DIODE_GOOD)], {'D-KEY': DIODE_GOOD}, {'D-KEY': prims(DIODE_GOOD)})+ self.assertTrue(rep['ok'], rep)+ self.assertFalse([w for w in rep['warnings'] if 'polarity' in w], rep['warnings'])+ self.assertEqual(rep['checked']['polarity'], 1)+ rep = lane.check_symbols([placed('D1', 'D-KEY', DIODE_BAD)], {'D-KEY': DIODE_BAD}, {'D-KEY': prims(DIODE_BAD)})+ self.assertFalse(rep['ok'])+ self.assertIn('pointing at the anode', rep['errors'][0])+ reviewed = {'schematic': {'polarityReviewed': [{'symbol': 'D-KEY', 'why': 'checked against the datasheet drawing'}]}}+ self.assertTrue(lane.check_symbols([placed('D1', 'D-KEY', DIODE_BAD)], {'D-KEY': DIODE_BAD}, {'D-KEY': prims(DIODE_BAD)}, reviewed)['ok'])++ def test_a_bjt_arrow_on_the_collector_is_an_error(self):+ q = """META|Q|npn|Q+MODE|0|native+LINE|0|0|60|0|-60+LINE|0|0|20|50|100+LINE|0|0|-20|50|-100+LINE|0|40|-80|30|-95+LINE|0|30|-95|48|-97+LINE|0|48|-97|40|-80+PIN|0|1|B|0|0|left|100|hide+PIN|0|2|C|50|100|up|100|hide+PIN|0|3|E|50|-100|down|100|hide+"""+ rep = lane.check_symbols([placed('Q1', 'Q', q)], {'Q': q}, {'Q': prims(q)})+ self.assertTrue(rep['ok'], rep)+ # the arrow head drawn at the collector end instead+ flipped = q.replace('LINE|0|40|-80|30|-95\nLINE|0|30|-95|48|-97\nLINE|0|48|-97|40|-80', 'LINE|0|40|80|30|95\nLINE|0|30|95|48|97\nLINE|0|48|97|40|80')+ self.assertNotEqual(flipped, q)+ rep = lane.check_symbols([placed('Q1', 'Q', flipped)], {'Q': flipped}, {'Q': prims(flipped)})+ self.assertIn('emitter arrow is drawn on the collector lead', ' '.join(rep['errors']))++ def test_no_kept_spec_checks_the_drawn_mode_only(self):+ rep = lane.check_symbols([placed('U1', 'TPS54202DDCR', IC_BAD, types=POWER)], {}, {'TPS54202DDCR': dict(prims(IC_BAD), bodyRectMil=[0, -400, 800, 0])})+ self.assertFalse(rep['ok'])+ self.assertEqual(rep['checked']['noSpec'], ['TPS54202DDCR'])+ self.assertEqual(len(rep['errors']), 1)+ self.assertIn('as drawn on the sheet', rep['errors'][0])+ self.assertTrue(any('no kept symbol spec for TPS54202DDCR' in w for w in rep['warnings']))+ # a SchLib that differs from the kept spec: the spec is set aside, said as a warning+ rep = lane.check_symbols([placed('U1', 'TPS54202DDCR', IC_GOOD, types=POWER)], {'TPS54202DDCR': IC_GOOD}, {'TPS54202DDCR': dict(prims(IC_GOOD), displayModeCount=10)})+ self.assertTrue(rep['ok'], rep)+ self.assertTrue(any('not the one its kept spec describes' in w for w in rep['warnings']), rep['warnings'])++ def test_a_rotated_part_is_read_back_in_its_symbol_frame(self):+ c = placed('U1', 'TPS54202DDCR', IC_GOOD, types=POWER)+ for p in c['pins']: # turn the part 90 degrees counterclockwise about its origin+ dx, dy = p['xMil'] - 1000, p['yMil'] - 2000+ p['xMil'], p['yMil'] = 1000 - dy, 2000 + dx+ p['orientation'] = (p['orientation'] + 1) % 4+ c['orientation'] = 1+ rep = lane.check_symbols([c], {'TPS54202DDCR': IC_GOOD}, {'TPS54202DDCR': prims(IC_GOOD)})+ self.assertTrue(rep['ok'], rep)+ self.assertEqual(rep['warnings'], [])+++SYM_LIB = '''(kicad_symbol_lib (version 20241209) (generator "kicad_symbol_editor")+ (symbol "ABC-123" (property "Reference" "U") (property "Footprint" "lib:QFN-16") (property "Description" "a (tricky) \\"name\\"")+ (symbol "ABC-123_1_1" (pin input line (at -5.08 0 0) (length 2.54) (name "IN" (effects (font (size 1.27 1.27)))) (number "1" (effects (font (size 1.27 1.27)))))))+ (symbol "XYZ-9" (property "Reference" "U") (property "Footprint" "")+ (symbol "XYZ-9_1_1" (pin output line (at 5.08 0 180) (length 2.54) (name "OUT" (effects (font (size 1.27 1.27)))) (number "2" (effects (font (size 1.27 1.27)))))))+ (symbol "ABC-123-T" (extends "ABC-123") (property "Reference" "U"))+)+'''+++def project_lib(d, with_model=True):+ (d / 'lib.pretty').mkdir(parents=True); (d / '3dmodels').mkdir()+ (d / 'lib.kicad_sym').write_text(SYM_LIB)+ (d / 'lib.pretty' / 'QFN-16.kicad_mod').write_text('(footprint "QFN-16" (layer "F.Cu") (pad "1" smd rect (at 0 0) (size 1 1) (layers "F.Cu")) (model "${KIPRJMOD}/3dmodels/ABC-123.step"))\n')+ if with_model: (d / '3dmodels' / 'ABC-123.step').write_text('ISO-10303-21;\nHEADER;\nENDSEC;\n')+ return {'dir': str(d), 'symbol': 'ABC-123', 'symbolFile': str(d / 'lib.kicad_sym'), 'footprint': 'QFN-16', 'footprintFile': str(d / 'lib.pretty' / 'QFN-16.kicad_mod'),+ 'model': str(d / '3dmodels' / 'ABC-123.step'), 'source': 'datasheet rev C, p. 12',+ 'provenance': {'mpn': 'ABC-123', 'source': 'datasheet rev C, p. 12', 'manufacturer': 'Acme', 'lcsc': 'C12345'}}+++class ProjectLibParts(unittest.TestCase):+ def setUp(self):+ self.tmp = tempfile.TemporaryDirectory(); self.d = Path(self.tmp.name)++ def tearDown(self):+ self.tmp.cleanup()++ def test_origin_prefers_a_page_then_the_project_library(self):+ pl = {'dir': '/lib'}+ self.assertEqual(lane.part_origin({'mpn': 'ABC-123', 'page': 'me/abc'}, {'projectLib': pl}), ('wiki', 'me/abc'))+ self.assertEqual(lane.part_origin({'mpn': 'ABC-123'}, {'wiki': 'adom/abc-123', 'projectLib': pl}), ('wiki', 'adom/abc-123'))+ self.assertEqual(lane.part_origin({'mpn': 'ABC-123'}, {'wiki': None, 'projectLib': pl}), ('project-lib', pl))+ self.assertEqual(lane.part_origin({'mpn': 'ABC-123'}, {}), ('wiki', 'abc-123'))+ rows = lane.parts_rows({'outcomes': {'parts': {'parts': [{'mpn': 'abc-123 ', 'projectLib': pl}]}}})+ self.assertIn('ABC-123', rows)++ def test_one_symbol_is_cut_out_of_the_library(self):+ one = lane.single_symbol_lib(SYM_LIB, 'lib:ABC-123')+ self.assertIn('(symbol "ABC-123"', one)+ self.assertNotIn('XYZ-9', one)+ self.assertNotIn('(pin output', one, 'the converter reads every pin in its file')+ self.assertIn('\\"name\\"', one, 'quoted parentheses and escapes survive')+ child = lane.single_symbol_lib(SYM_LIB, 'ABC-123-T')+ self.assertTrue(child.index('(symbol "ABC-123"') < child.index('(symbol "ABC-123-T"'), 'the parent it extends comes along, first')+ self.assertIsNone(lane.single_symbol_lib(SYM_LIB, 'NOPE'))++ def test_a_part_is_staged_like_a_wiki_page(self):+ pl = project_lib(self.d / 'lib')+ files, problems = lane.stage_project_part('ABC-123', pl, self.d / 'pages' / 'ABC-123')+ self.assertEqual(problems, [])+ self.assertEqual(sorted(p.name for p in (self.d / 'pages' / 'ABC-123').iterdir()), ['ABC-123.kicad_sym', 'ABC-123.step', 'QFN-16.kicad_mod'])+ self.assertNotIn('XYZ-9', files['sym'].read_text())+ facts = lane.project_part_facts(pl, {'value': 'ABC'})+ self.assertEqual((facts['mfr'], facts['package'], facts['links'], facts['desc']), ('Acme', 'QFN-16', {'lcsc': 'C12345'}, 'ABC'))+ pl2 = project_lib(self.d / 'lib2', with_model=False)+ files, problems = lane.stage_project_part('ABC-123', pl2, self.d / 'pages' / 'X')+ self.assertIsNone(files)+ self.assertIn('STEP model', problems[0])+++FAKE_BRIDGE = r'''#!/usr/bin/env python3+import json, os, sys+log = os.environ["FAKE_LOG"]+a = sys.argv[1:]+verb = a[a.index("--ai-thread") + 2]+args = json.loads(a[a.index("--ai-thread") + 3])+with open(log, "a") as f: f.write(json.dumps({"verb": verb, "args": args}) + "\n")+replies = json.load(open(os.environ["FAKE_REPLIES"])) if os.environ.get("FAKE_REPLIES") else {}+print(json.dumps(replies.get(verb, {"success": True})))+'''++FAKE_K2A = r'''#!/usr/bin/env python3+import json, os, sys+with open(os.environ["FAKE_LOG"], "a") as f: f.write(json.dumps({"tool": "kicad2altium", "argv": sys.argv[1:]}) + "\n")+open(sys.argv[2], "w").write("FP|%s\n" % sys.argv[3])+'''++FAKE_MODES = r'''#!/usr/bin/env python3+import json, os, sys+src = open(sys.argv[1]).read()+with open(os.environ["FAKE_LOG"], "a") as f: f.write(json.dumps({"tool": "symbol-modes", "argv": sys.argv[1:], "pins": src.count("(pin ")}) + "\n")+open(sys.argv[2], "w").write("META|%s|%s|U\nMODE|0|std\nRECT|0|0|-200|600|0\nPIN|0|1|IN|0|-100|left|300\n" % (sys.argv[3], sys.argv[4]))+'''++FAKE_WIKI = r'''#!/usr/bin/env python3+import sys; sys.exit(1)+'''+++class LibraryCommand(unittest.TestCase):+ """`altium library` end to end with fake CLIs: a part with no wiki page, given by the project library."""++ def test_project_library_part_is_imported(self):+ with tempfile.TemporaryDirectory() as t:+ d = Path(t); b = d / 'bin'; b.mkdir()+ for name, src in (('adom-bridge', FAKE_BRIDGE), ('altium-kicad2altium', FAKE_K2A), ('altium-symbol-modes', FAKE_MODES), ('adom-wiki', FAKE_WIKI)):+ (b / name).write_text(src); (b / name).chmod(0o755)+ run = d / 'run'; run.mkdir()+ pl = project_lib(d / 'lib')+ (run / 'run.json').write_text(json.dumps({'outcomes': {'parts': {'pass': True, 'projectLib': str(d / 'lib'), 'parts': [{'mpn': 'ABC-123', 'refs': ['U1'], 'wiki': None, 'projectLib': pl, 'value': 'ABC'}]}}}))+ net = d / 'netlist.json'; net.write_text(json.dumps({'components': [{'ref': 'U1', 'mpn': 'ABC-123'}], 'nets': {}}))+ script = d / 'altium.py'+ script.write_text('\n'.join((ROOT / 'tools' / f).read_text() for f in ('project-common.py', 'silkscreen-plan.py', 'altium-lane.py', 'altium-project.py')))+ log = d / 'log.jsonl'+ env = dict(os.environ, PATH='%s:%s:/usr/bin:/bin' % (b, os.path.dirname(sys.executable)), AIFLOW_RUN=str(run), AIFLOW_TARGET='box', AIFLOW_BOX_DIR='C:/Users/t/Documents/AdomAiFlow/run', FAKE_LOG=str(log))+ r = subprocess.run([sys.executable, str(script), 'library', '--parts', str(net), '--name', 'Board'], capture_output=True, text=True, env=env, stdin=subprocess.DEVNULL)+ self.assertEqual(r.returncode, 0, r.stdout + r.stderr)+ self.assertIn('1 from the board\'s project library: ABC-123', r.stdout)+ calls = [json.loads(l) for l in log.read_text().splitlines()]+ modes = next(c for c in calls if c.get('tool') == 'symbol-modes')+ self.assertEqual(modes['pins'], 1, 'the converter got the one symbol, not the whole library')+ self.assertTrue(modes['argv'][0].endswith('pages/ABC-123/ABC-123.kicad_sym'))+ k2a = next(c for c in calls if c.get('tool') == 'kicad2altium')+ self.assertTrue(k2a['argv'][0].endswith('pages/ABC-123/QFN-16.kicad_mod'))+ imp = next(c['args'] for c in calls if c.get('verb') == 'altium_import_part')+ self.assertEqual(imp['footprintSpec'], 'C:\\Users\\t\\Documents\\AdomAiFlow\\run\\library\\stage\\ABC-123.fp.spec')+ self.assertTrue(imp['symbolSpec'].endswith('ABC-123.sym.spec') and imp['step'].endswith('ABC-123.step'), imp)+ self.assertEqual((imp['params']['MPN'], imp['params']['Manufacturer'], imp['params']['LCSC Part #']), ('ABC-123', 'Acme', 'C12345'))+ sent = [c['args']['filePaths'] for c in calls if c.get('verb') == 'send_files'][0]+ self.assertEqual(sorted(Path(p).name for p in sent), ['ABC-123.fp.spec', 'ABC-123.step', 'ABC-123.sym.spec'])+ st = json.loads((run / 'altium' / 'project.json').read_text())+ self.assertEqual(st['parts']['ABC-123']['source'], 'project-lib')+ self.assertEqual(st['parts']['ABC-123']['projectLib']['dir'], str(d / 'lib'))+ self.assertTrue((run / 'altium' / 'symbols' / 'ABC-123.sym.spec').is_file(), 'the symbol spec is kept for the schematic check')+ self.assertTrue((run / 'altium' / 'pages' / 'ABC-123' / 'QFN-16.kicad_mod').is_file(), 'the board render finds its courtyard there')+ proof = json.loads((run / 'proofs' / 'library.json').read_text())+ self.assertEqual(proof['data']['projectLibParts'], ['ABC-123'])+++class SymbolsCommand(unittest.TestCase):+ """`altium symbols` end to end with a fake bridge: the sheet and the SchLib read back, the check run,+ the schematic proof's symbols replaced (its parity and ERC kept), the report written."""++ def run_symbols(self, d, spec_text, sheet_spec_text):+ b = d / 'bin'; b.mkdir(exist_ok=True)+ (b / 'adom-bridge').write_text(FAKE_BRIDGE); (b / 'adom-bridge').chmod(0o755)+ run = d / 'run'; (run / 'altium' / 'symbols').mkdir(parents=True, exist_ok=True); (run / 'proofs').mkdir(exist_ok=True)+ (run / 'altium' / 'project.json').write_text(json.dumps({'parts': {}, 'schematic': 'C:\\p\\Buck.SchDoc', 'schLib': 'C:/p/library/Board.SchLib'}))+ (run / 'altium' / 'symbols' / 'TPS54202DDCR.sym.spec').write_text(spec_text)+ (run / 'proofs' / 'schematic.json').write_text(json.dumps({'at': 't', 'tool': 'altium', 'data': {'sch': 'C:\\p\\Buck.SchDoc', 'parity': True, 'erc': {'pass': True, 'errors': []}}}))+ replies = {'altium_schematic_data': {'success': True, 'data': {'ok': True, 'document': 'Buck.SchDoc', 'count': 1, 'components': [placed('U1', 'TPS54202DDCR', sheet_spec_text, types=POWER)]}},+ 'altium_symbol_prims': {'success': True, 'data': dict(prims(spec_text), libref='TPS54202DDCR')}}+ (d / 'replies.json').write_text(json.dumps(replies))+ script = d / 'altium.py'+ script.write_text('\n'.join((ROOT / 'tools' / f).read_text() for f in ('project-common.py', 'silkscreen-plan.py', 'altium-lane.py', 'altium-project.py')))+ env = dict(os.environ, PATH='%s:%s:/usr/bin:/bin' % (b, os.path.dirname(sys.executable)), AIFLOW_RUN=str(run), AIFLOW_TARGET='box', AIFLOW_BOX_DIR='C:/p',+ FAKE_LOG=str(d / 'log.jsonl'), FAKE_REPLIES=str(d / 'replies.json'))+ r = subprocess.run([sys.executable, str(script), 'symbols'], capture_output=True, text=True, env=env, stdin=subprocess.DEVNULL)+ return r, json.loads((run / 'proofs' / 'schematic.json').read_text())['data'], run++ def test_symbols_are_checked_and_recorded(self):+ with tempfile.TemporaryDirectory() as t:+ d = Path(t)+ r, proof, run = self.run_symbols(d, IC_GOOD, IC_GOOD)+ self.assertEqual(r.returncode, 0, r.stdout + r.stderr)+ self.assertIn('pins on their bodies, pin types and polarity drawings check out', r.stdout)+ self.assertEqual(proof['symbols']['ok'], True)+ self.assertEqual((proof['parity'], proof['erc']['pass']), (True, True), 'the rest of the proof is kept')+ self.assertTrue((run / 'schematic-check.json').is_file())+ calls = [json.loads(l) for l in (d / 'log.jsonl').read_text().splitlines()]+ self.assertEqual(next(c['args'] for c in calls if c['verb'] == 'altium_schematic_data')['filePath'], 'C:\\p\\Buck.SchDoc')+ self.assertEqual(next(c['args'] for c in calls if c['verb'] == 'altium_symbol_prims')['symbolName'], 'TPS54202DDCR')++ def test_pins_off_the_body_fail_and_are_recorded(self):+ with tempfile.TemporaryDirectory() as t:+ r, proof, _ = self.run_symbols(Path(t), IC_BAD, IC_BAD)+ self.assertEqual(r.returncode, 1, r.stdout + r.stderr)+ self.assertIn('ERROR: schematic', r.stdout)+ self.assertIn('300 mil off the body edge', r.stdout)+ self.assertEqual(proof['symbols']['ok'], False)+ self.assertEqual(len(proof['symbols']['errors']), 3)+++if __name__ == '__main__':+ unittest.main(verbosity=1)
tests/project-page-library.py+20−1@@ -1,6 +1,7 @@ """project-page's Cloud Library and PCM package against a fake adom-wiki (and a fake kicad-cli) on PATH: HTTP 429 retries, one library per board (recorded lib_ id, no duplicates), an incomplete export refused,-the symbol header normalised and parse-checked, project-library parts packaged (issues #64, #66), and+the symbol header normalised and parse-checked, project-library parts packaged (issues #64, #66) and listed on+the Altium lane's page, and every part resolving through shared footprints and models while a missing footprint or model is refused (#76). python3 tests/project-page-library.py@@ -232,6 +233,24 @@ class ProjectPageLibrary(unittest.TestCase): self.assertIsNone(v["cloudLibrary"]) self.assertEqual(self.read_state().get("calls", []), []) + def test_altium_page_lists_project_library_parts(self):+ # the Altium lane: README revision 1 only (no Cloud Library, no PCM package); the project-library+ # part is in the component table, in projectLibParts and named in the Altium section+ v = self.run_page(parts=self.parts() + [self.project_lib()], eda="altium")+ self.assertTrue(v["ok"], v)+ self.assertEqual(v["eda"], "altium")+ self.assertEqual(v["projectLibParts"], ["ABC-123"])+ self.assertEqual(self.read_state().get("calls", []), [])+ readme = (self.run_dir / "project-page" / "README.md").read_text()+ self.assertIn("| U9 | 1 | | ABC-123 | | project library (no public page yet) |", readme)+ self.assertIn("every one has its Adom wiki page or comes from the board's own project library", readme)+ self.assertIn("for ABC-123 (no public page yet), from the board's own project library", readme)+ self.assertNotIn("Install every part in KiCad", readme)+ v = self.run_page(eda="altium")+ readme = (self.run_dir / "project-page" / "README.md").read_text()+ self.assertIn("AI Flow builds this board's Altium libraries from the parts' wiki pages above:", readme)+ self.assertNotIn("project library", readme)+ def test_brief_short_description_skips_the_heading(self): v = self.run_page(brief="# Test board\n\nA 12 V to 5 V buck.\n") meta = json.loads(zipfile.ZipFile(v["zip"]).read("metadata.json"))
tools/altium-lane.pyadded+499@@ -0,0 +1,499 @@+"""altium-lane: the Altium lane's pure helpers (no bridge, no run state), run in front of altium-project.py+and tested on their own (tests/altium-lane.py).++ Symbols and pin types (the Altium half of `schematic check`, issue #39 items 1 and 2):+ check_symbols(placed, specs, prims, spec) judges the drawn sheet as Altium reads it back+ (altium_schematic_data: every placed part's pins with their electrical type and place), each used+ symbol's SchLib record (altium_symbol_prims: pins, rectangles, display modes, body rectangle) and the+ symbol spec the library step imported (altium-symbol-modes output, kept in <run>/altium/symbols/):+ - pins on the body: a pin's x,y is its BODY end and must sit on the body rectangle's edge, pointing+ away from it, in every display mode (the spec) and on the sheet (the drawn mode, read back);+ - pin electrical types on active devices (U/IC references, op-amps and comparators), with the KiCad+ check's rules: an amplifier input or an output declared passive is an error, a passive power pin+ an error on op-amps and comparators and a warning on other ICs;+ - polarity drawings of the small parts drawn as their own symbol (the spec's native mode): the diode+ triangle and cathode bar at the pin named K, a BJT's emitter arrow on the pin named E.+ Project-library parts (issue #66 on the Altium lane):+ part_origin(), single_symbol_lib(), stage_project_part(): a part with no wiki component page that the+ board's project library gives (`parts --project-lib`) is staged as the lane's pages/<key>/ and goes+ through the same converters as a wiki part.+"""+import math, re, shutil+from pathlib import Path++MIL = 0.0254 # mm per mil++# Altium's TPinElectrical ordinals (altium_schematic_data reports the ordinal) and the KiCad names the+# rules use; Altium has one Power type (no power_in / power_out)+ALTIUM_ETYPE = {0: 'input', 1: 'bidirectional', 2: 'output', 3: 'open_collector', 4: 'passive', 5: 'tri_state', 6: 'open_emitter', 7: 'power'}+# Altium's pin Orientation ordinal (eRotate0..eRotate270): the direction the pin runs away from the body+ALTIUM_ORIENT = {0: 'right', 1: 'up', 2: 'left', 3: 'down'}+DIRS = {'right': (1, 0), 'up': (0, 1), 'left': (-1, 0), 'down': (0, -1)}++LIMITS = ("Altium: pins checked on the body rectangle (every display mode from the imported spec, the drawn mode read back "+ "from the sheet); pin types read from the placed parts (Altium's Power has no in/out); polarity only on small parts "+ "drawn as their own symbol (diodes with pins named K/A, BJTs with pins named E/C), arcs ignored; op-amp +/- are the "+ "box symbol's shown pin names (no drawn signs to contradict them); sheet readability not checked (altium_build_schematic "+ "draws one wire stub and one net label per pin)")+++# ---------------------------------------------------------------- symbol specs++def parse_symbol_spec(text):+ """An altium-symbol-modes spec (META, MODE, RECT, PIN, LINE, ARC lines; mils, y up) as+ {libref, desc, designator, modes: {i: {name, rects, lines, arcs, pins: [{number, name, x, y, orient, len, hidden}]}}}."""+ sym = {'libref': '', 'desc': '', 'designator': 'U', 'modes': {}}++ def mode(i):+ return sym['modes'].setdefault(i, {'name': '', 'rects': [], 'lines': [], 'arcs': [], 'pins': []})+ for raw in (text or '').splitlines():+ f = raw.rstrip('\r').split('|')+ try:+ if f[0] == 'META': sym['libref'], sym['desc'], sym['designator'] = (f + ['', '', ''])[1:4]+ elif f[0] == 'MODE': mode(int(f[1]))['name'] = f[2] if len(f) > 2 else ''+ elif f[0] == 'RECT': mode(int(f[1]))['rects'].append(tuple(float(v) for v in f[2:6]))+ elif f[0] == 'LINE': mode(int(f[1]))['lines'].append(tuple(float(v) for v in f[2:6]))+ elif f[0] == 'ARC': mode(int(f[1]))['arcs'].append(tuple(float(v) for v in f[2:7]))+ elif f[0] == 'PIN':+ mode(int(f[1]))['pins'].append({'number': f[2], 'name': f[3], 'x': float(f[4]), 'y': float(f[5]), 'orient': f[6],+ 'len': float(f[7]), 'hidden': len(f) > 8 and f[8] == 'hide'})+ except (ValueError, IndexError):+ continue+ return sym+++def _box(r):+ return min(r[0], r[2]), min(r[1], r[3]), max(r[0], r[2]), max(r[1], r[3])+++def pin_edge_problems(pins, rect, tol=1.0):+ """The pins whose body end (x, y) is not on the rectangle's edge, or that point into the body:+ [(pin, 'off', distance in mil) | (pin, 'inward', 0)]."""+ x0, y0, x1, y1 = _box(rect)+ out = []+ for p in pins:+ x, y = p['x'], p['y']+ on_l, on_r, on_b, on_t = abs(x - x0) <= tol, abs(x - x1) <= tol, abs(y - y0) <= tol, abs(y - y1) <= tol+ in_y, in_x = y0 - tol <= y <= y1 + tol, x0 - tol <= x <= x1 + tol+ if not (((on_l or on_r) and in_y) or ((on_b or on_t) and in_x)):+ dx = max(x0 - x, 0, x - x1); dy = max(y0 - y, 0, y - y1)+ d = math.hypot(dx, dy) if (dx or dy) else min(x - x0, x1 - x, y - y0, y1 - y)+ out.append((p, 'off', round(d)))+ continue+ ux, uy = DIRS.get(p.get('orient'), (0, 0))+ away = (on_l and in_y and ux < 0) or (on_r and in_y and ux > 0) or (on_b and in_x and uy < 0) or (on_t and in_x and uy > 0)+ if not away: out.append((p, 'inward', 0))+ return out+++def _edge_line(who, what, probs, total, native):+ eg = []+ for p, kind, d in probs[:3]:+ nm = ' (%s)' % p['name'] if p.get('name') else ''+ eg.append('pin %s%s %s' % (p['number'], nm, ('%d mil off the body edge' % d) if kind == 'off' else 'points into the body'))+ more = '' if len(probs) <= 3 else ', ...'+ why = ("a small part's pin should end on its drawing" if native else+ "a pin's x,y is its body end, on the body rectangle's edge (pins one pin length off the box, names in the gap, was the symbol_modes bug fixed 2026-10-19)")+ return '%s %s: %d of %d pin(s) not on the body edge: %s%s; %s' % (who, what, len(probs), total, '; '.join(eg), more, why)+++# ---------------------------------------------------------------- names (the KiCad check's rules)++def norm(name):+ n = (name or '').strip().upper()+ for ch in '{}~ _': n = n.replace(ch, '')+ return n.replace('−', '-').replace('–', '-')+++def amp_input(name):+ """'+' or '-' for a pin named as an amplifier input, else None."""+ n = norm(name)+ if n in ('+', '-'): return n+ if 'IN' in n and not n.startswith('V'):+ if '+' in n: return '+'+ if '-' in n: return '-'+ if n.endswith('INP') or n == 'NONINV': return '+'+ if n.endswith('INN'): return '-'+ return None+++def is_output(name):+ s = norm(name).lstrip('0123456789')+ return s in ('OUT', 'VOUT') or (s.startswith('OUT') and len(s) == 4)+++def is_power(name):+ n = norm(name)+ return (n in ('V+', 'V-', '+V', '-V', 'VS', 'VS+', 'VS-', '+VS', '-VS', 'AVDD', 'DVDD', 'AVSS', 'DVSS', 'AVCC')+ or any(n.startswith(p) for p in ('VCC', 'VDD', 'VEE', 'VSS')) or (n.endswith('GND') and len(n) <= 5))+++def ref_prefix(r):+ return re.match(r'[A-Za-z]*', r or '').group(0).upper()+++def amp_like(info, pin_names):+ i = (info or '').lower()+ return any(k in i for k in ('opamp', 'op amp', 'op-amp', 'operational', 'comparator')) or (+ any(amp_input(n) == '+' for n in pin_names) and any(amp_input(n) == '-' for n in pin_names))+++def pin_type_findings(pins, amp):+ """[(severity 'error'|'warn', pin, message)] for one symbol's pins ({number, name, etype})."""+ out, seen = [], set()+ ok_out = ('output', 'tri_state', 'open_collector', 'open_emitter', 'power', 'power_out', 'bidirectional')+ for p in pins:+ k = (p['number'], p['name'])+ if k in seen: continue+ seen.add(k)+ et, s = p['etype'], amp_input(p['name'])+ if s:+ if et == 'passive': out.append(('error', p, 'amplifier input `%s` (%s) is passive; declare it Input' % (p['name'], s)))+ elif et != 'input': out.append(('warn', p, 'amplifier input `%s` is %s; amplifier inputs are Input' % (p['name'], et)))+ elif is_output(p['name']):+ if et == 'passive': out.append(('error', p, 'output `%s` is passive; declare it Output (Power for a regulator output)' % p['name']))+ elif et not in ok_out: out.append(('warn', p, 'output `%s` is %s' % (p['name'], et)))+ elif is_power(p['name']):+ if et == 'passive': out.append(('error' if amp else 'warn', p, 'power pin `%s` is passive; declare it Power' % p['name']))+ elif et not in ('power', 'power_in', 'power_out'): out.append(('warn', p, 'power pin `%s` is %s; power pins are Power' % (p['name'], et)))+ return out+++def passive_allowed(spec, ref, pin):+ """spec.schematic.allowPassive [{ref, pin?, why}]: True (allowed with a datasheet why), False (named+ without a why), None (not named)."""+ for e in ((spec or {}).get('schematic') or {}).get('allowPassive') or []:+ if e.get('ref') == ref and (e.get('pin') is None or e.get('pin') in (pin['number'], pin['name'])):+ return len((e.get('why') or '').strip()) >= 8+ return None+++def polarity_reviewed(spec, libref, refs):+ for e in ((spec or {}).get('schematic') or {}).get('polarityReviewed') or []:+ if (e.get('symbol') == libref or e.get('symbol') in refs) and len((e.get('why') or '').strip()) >= 8: return True+ return False+++# ---------------------------------------------------------------- polarity (the KiCad check, on LINE segments)++def _d(a, b): return math.hypot(a[0] - b[0], a[1] - b[1])+++def _same(a, b): return _d(a, b) < 0.01+++def _triangles(segs):+ """Closed triangles drawn as three LINE segments (a KiCad closed polyline becomes segments in the spec)."""+ tris, used = [], set()+ n = len(segs)+ for i in range(n):+ for j in range(i + 1, n):+ for k in range(j + 1, n):+ ends = [segs[i][0], segs[i][1], segs[j][0], segs[j][1], segs[k][0], segs[k][1]]+ pts = []+ for e in ends:+ if not any(_same(e, q) for q in pts): pts.append(e)+ if len(pts) == 3 and all(sum(1 for e in ends if _same(e, q)) == 2 for q in pts) and all(not _same(s[0], s[1]) for s in (segs[i], segs[j], segs[k])):+ tris.append(pts); used |= {i, j, k}+ return tris, used+++def _vees(segs, used, max_len=1.5):+ """Two short segments sharing an end (an arrow head whose fill the converter dropped): their 3 points."""+ out = []+ for i in range(len(segs)):+ for j in range(i + 1, len(segs)):+ if i in used or j in used: continue+ a, b = segs[i], segs[j]+ if _d(*a) > max_len or _d(*b) > max_len: continue+ for p in a:+ for q in b:+ if _same(p, q): out.append([a[0] if a[1] is p else a[1], p, b[0] if b[1] is q else b[1]])+ return out+++def _named(pins, names):+ return [p for p in pins if norm(p['name']) in names]+++def diode_finding(pins, tris, bars_open, bars_closed):+ """The KiCad check's diode rule: (severity, message) or None. pins carry 'at' (outer end) in mm."""+ k, a = _named(pins, ('K', 'CATHODE', 'KATHODE')), _named(pins, ('A', 'ANODE'))+ if len(k) != 1 or len(a) != 1: return None+ k, a = k[0], a[0]+ l = _d(a['at'], k['at'])+ if l < 0.1: return None+ u = ((k['at'][0] - a['at'][0]) / l, (k['at'][1] - a['at'][1]) / l)+ t = lambda p: ((p[0] - a['at'][0]) * u[0] + (p[1] - a['at'][1]) * u[1]) / l+ s = lambda p: -(p[0] - a['at'][0]) * u[1] + (p[1] - a['at'][1]) * u[0]+ who = 'pin %s (%s) is the cathode, pin %s (%s) the anode' % (k['number'], k['name'], a['number'], a['name'])+ tri = None+ for pts in tris:+ for i in range(3):+ ap, pj, pk = pts[i], pts[(i + 1) % 3], pts[(i + 2) % 3]+ width = abs(s(pj) - s(pk))+ if width < 0.3 or abs(t(pj) - t(pk)) * l > 0.25 * width or abs(s(ap)) > 0.25 * width or abs((s(pj) + s(pk)) / 2) > 0.25 * width: continue+ if tri is None or width > tri[2]: tri = ((t(pj) + t(pk)) / 2, t(ap), width)+ bars = []+ for pts, closed in [(b, False) for b in bars_open] + [(b, True) for b in bars_closed]:+ ts, ss = [t(p) for p in pts], [s(p) for p in pts]+ along = (max(ts) - min(ts)) * l+ smin, smax = min(ss), max(ss)+ across = smax - smin+ if across < 0.5 or across < (3.0 if closed else 1.5) * along or smin > -0.2 or smax < 0.2: continue+ n = len(pts)+ i = max(range(n if closed else n - 1), key=lambda i: _d(pts[i], pts[(i + 1) % n]))+ bars.append((t(((pts[i][0] + pts[(i + 1) % n][0]) / 2, (pts[i][1] + pts[(i + 1) % n][1]) / 2)), across))+ if tri: bar = min(bars, key=lambda b: abs(b[0] - tri[1]))[0] if bars else None+ else: bar = max(bars, key=lambda b: b[1])[0] if bars else None+ if tri:+ base, apex = tri[0], tri[1]+ if apex < base:+ if bar is None or abs(bar - apex) < abs(bar - base):+ return ('error', 'the diode is drawn pointing at the anode: the triangle and cathode bar are at the pin %s end, but %s' % (a['number'], who))+ return ('warn', 'the diode triangle points at pin %s (named %s) while the bar is at the other end; %s' % (a['number'], a['name'], who))+ if bar is not None and bar < base - 0.02: return ('warn', 'the cathode bar is on the anode side of the triangle; %s' % who)+ return None+ if bar is None: return ('warn', 'no cathode bar or triangle recognised in the drawing; check by eye that the bar is at the cathode (%s)' % who)+ if bar < 0.4: return ('error', 'the cathode bar is drawn at the pin %s end, but %s' % (a['number'], who))+ if bar < 0.6: return ('warn', 'the cathode bar is near the middle, so the drawing does not show which end is the cathode; %s' % who)+ return None+++def bjt_finding(pins, arrows):+ """The KiCad check's BJT rule on arrow heads (point lists, mm): (severity, message) or None."""+ e, c = _named(pins, ('E', 'EMITTER')), _named(pins, ('C', 'COLLECTOR'))+ if len(e) != 1 or len(c) != 1: return None+ e, c = e[0], c[0]+ small = [pts for pts in arrows if math.hypot(max(p[0] for p in pts) - min(p[0] for p in pts), max(p[1] for p in pts) - min(p[1] for p in pts)) < 2.5]+ if not small: return ('warn', 'a transistor with E and C pins but no emitter arrow recognised in the drawing')+ cen = [(sum(p[0] for p in pts) / len(pts), sum(p[1] for p in pts) / len(pts)) for pts in small]+ best = min(cen, key=lambda x: min(_d(x, e['inner']), _d(x, c['inner'])))+ de, dc = _d(best, e['inner']), _d(best, c['inner'])+ who = 'pin %s is named %s and pin %s %s' % (e['number'], e['name'], c['number'], c['name'])+ if dc * 1.5 < de: return ('error', 'the emitter arrow is drawn on the collector lead; %s' % who)+ if dc < de: return ('warn', 'the emitter arrow is closer to the collector than to the emitter; %s' % who)+ return None+++def polarity_findings(mode):+ """Polarity of one spec mode (a small part drawn as its symbol): [(severity, message)]."""+ mm = lambda v: v * MIL+ pins = []+ for p in mode['pins']:+ ux, uy = DIRS.get(p['orient'], (0, 0))+ inner = (mm(p['x']), mm(p['y']))+ pins.append(dict(p, inner=inner, at=(inner[0] + ux * mm(p['len']), inner[1] + uy * mm(p['len']))))+ segs = [((mm(l[0]), mm(l[1])), (mm(l[2]), mm(l[3]))) for l in mode['lines']]+ tris, used = _triangles(segs)+ bars_open = [[s[0], s[1]] for i, s in enumerate(segs) if i not in used]+ bars_closed = []+ for r in mode['rects']:+ x0, y0, x1, y1 = (mm(v) for v in _box(r))+ bars_closed.append([(x0, y0), (x1, y0), (x1, y1), (x0, y1)])+ out = [f for f in (diode_finding(pins, tris, bars_open, bars_closed), bjt_finding(pins, tris + _vees(segs, used))) if f]+ return out+++# ---------------------------------------------------------------- the check++def _rel(pin, comp):+ """A placed pin's place in its symbol's frame (mils): the sheet position less the part's, rotated back."""+ dx, dy = pin.get('xMil', 0) - comp.get('xMil', 0), pin.get('yMil', 0) - comp.get('yMil', 0)+ q = int(comp.get('orientation') or 0) % 4+ for _ in range(q): dx, dy = dy, -dx # undo one 90 degree counterclockwise turn+ orient = DIRS_ROT(ALTIUM_ORIENT.get(int(pin.get('orientation') or 0) % 4, 'right'), -q)+ return {'number': str(pin.get('number', '')), 'name': pin.get('name') or '', 'x': dx, 'y': dy, 'orient': orient}+++def DIRS_ROT(orient, quarter_turns):+ order = ['right', 'up', 'left', 'down']+ return order[(order.index(orient) + quarter_turns) % 4] if orient in order else orient+++def check_symbols(placed, specs, prims, spec=None):+ """The Altium schematic's symbols and pin types (see the module doc). placed: altium_schematic_data+ components; specs: {libref: spec text or parse_symbol_spec()}; prims: {libref: altium_symbol_prims data};+ spec: the run's spec (schematic.allowPassive, schematic.polarityReviewed). Returns the report the+ `altium schematic` proof carries: {ok, errors, warnings, symbols, librarySymbols, checked, limits}."""+ errors, warnings = [], []+ by_lib = {}+ for c in placed or []:+ d = by_lib.setdefault(c.get('libReference') or '', {'refs': [], 'comps': []})+ d['refs'].append(c.get('designator') or '?'); d['comps'].append(c)+ spec_up = {k.upper(): v for k, v in (specs or {}).items()}+ prim_up = {k.upper(): v for k, v in (prims or {}).items()}+ checked = {'pinTypes': 0, 'bodyEdgeModes': 0, 'drawnSymbols': 0, 'polarity': 0, 'noSpec': []}+ for lr, d in sorted(by_lib.items()):+ refs, comp = d['refs'], d['comps'][0]+ who = '%s (%s)' % (','.join(refs), lr)+ sp = spec_up.get(lr.upper())+ sp = parse_symbol_spec(sp) if isinstance(sp, str) else sp+ pr = prim_up.get(lr.upper())+ live = [_rel(p, comp) for p in comp.get('pins') or []]+ # 1. the SchLib symbol is the one its imported spec describes (else the spec says nothing about it)+ if sp and pr:+ n_pins = sum(len(m['pins']) for m in sp['modes'].values()); n_rect = sum(len(m['rects']) for m in sp['modes'].values())+ diffs = []+ if pr.get('displayModeCount') is not None and int(pr['displayModeCount']) != len(sp['modes']): diffs.append('%s display modes, the spec %d' % (pr['displayModeCount'], len(sp['modes'])))+ if pr.get('pins') is not None and int(pr['pins']) != n_pins: diffs.append('%s pins, the spec %d' % (pr['pins'], n_pins))+ if pr.get('rectangles') is not None and int(pr['rectangles']) != n_rect: diffs.append('%s rectangles, the spec %d' % (pr['rectangles'], n_rect))+ if diffs:+ warnings.append('library: %s: the SchLib symbol is not the one its kept spec describes (%s); only the drawn mode was checked. `altium library --only <MPN>` imports it again' % (who, '; '.join(diffs)))+ sp = None+ if not sp:+ checked['noSpec'].append(lr)+ # 2. pins on the body edge, every display mode of the spec+ if sp:+ for i, m in sorted(sp['modes'].items()):+ if len(m['rects']) != 1 or not m['pins']: continue+ checked['bodyEdgeModes'] += 1+ native = m['name'] == 'native'+ probs = pin_edge_problems(m['pins'], m['rects'][0])+ if probs: (warnings if native else errors).append('pins on the body: ' + _edge_line(who, 'mode %d (%s)' % (i, m['name'] or '?'), probs, len(m['pins']), native))+ # ... and the drawn mode, read back from the sheet+ body = None+ if sp and sp['modes'].get(0) and len(sp['modes'][0]['rects']) == 1: body = sp['modes'][0]['rects'][0]+ elif not sp and pr and int(pr.get('rectangles') or 0) >= 1 and pr.get('bodyRectMil'): body = tuple(pr['bodyRectMil'])+ if body and live:+ checked['drawnSymbols'] += 1+ native = (sp['modes'][0]['name'] == 'native') if sp else len(live) <= 3+ probs = pin_edge_problems(live, body)+ if probs: (warnings if native else errors).append('pins on the body: ' + _edge_line(who, 'as drawn on the sheet', probs, len(live), native))+ if sp and sp['modes'].get(0) and live:+ want = {p['number']: p for p in sp['modes'][0]['pins']}+ moved = [p for p in live if p['number'] in want and (abs(p['x'] - want[p['number']]['x']) > 1 or abs(p['y'] - want[p['number']]['y']) > 1)]+ if moved: warnings.append('library: %s: %d drawn pin(s) are not where the spec puts mode 0 (e.g. pin %s at %d,%d mil, the spec %d,%d)' % (+ who, len(moved), moved[0]['number'], moved[0]['x'], moved[0]['y'], want[moved[0]['number']]['x'], want[moved[0]['number']]['y']))+ # 3. polarity of a small part drawn as its own symbol+ if sp and sp['modes'].get(0) and sp['modes'][0]['name'] == 'native':+ checked['polarity'] += 1+ reviewed = polarity_reviewed(spec, lr, refs)+ for sev, msg in polarity_findings(sp['modes'][0]):+ line = '%s: %s' % (who, msg)+ if sev == 'error' and not reviewed: errors.append('polarity drawing: ' + line)+ else: warnings.append('polarity drawing: ' + line + (' [reviewed in spec.schematic.polarityReviewed]' if sev == 'error' else ''))+ # 4. pin electrical types on active devices, as Altium holds them on the placed parts+ pins = [{'number': str(p.get('number', '')), 'name': p.get('name') or '', 'etype': ALTIUM_ETYPE.get(p.get('electrical'), str(p.get('electrical')))} for p in comp.get('pins') or []]+ info = ' '.join([lr, comp.get('comment') or '', (sp or {}).get('desc') or ''])+ amp = amp_like(info, [p['name'] for p in pins])+ active = [r for r in refs if ref_prefix(r) in ('U', 'IC')] if not amp else list(refs)+ if active:+ checked['pinTypes'] += 1+ for sev, pin, msg in pin_type_findings(pins, amp):+ if sev == 'warn':+ warnings.append('pin type: %s pin %s (%s): %s' % (','.join(active), pin['number'], lr, msg)); continue+ blocked = []+ for r in active:+ a = passive_allowed(spec, r, pin)+ if a is True: warnings.append('pin type: %s pin %s (%s): %s [allowed by spec.schematic.allowPassive]' % (r, pin['number'], lr, msg))+ elif a is False: warnings.append('pin type: spec.schematic.allowPassive names %s pin %s without a datasheet `why`; it does not count' % (r, pin['number'])); blocked.append(r)+ else: blocked.append(r)+ if blocked: errors.append('pin type: %s pin %s (%s): %s' % (','.join(blocked), pin['number'], lr, msg))+ if checked['noSpec']:+ warnings.append('no kept symbol spec for %s (imported before AI Flow 0.2.10, or the SchLib differs): only the drawn mode was checked, not the other display modes or the polarity drawing; `altium library --only <MPN>` imports it again and keeps its spec' % ', '.join(checked['noSpec']))+ return {'ok': not errors, 'errors': errors, 'warnings': warnings, 'symbols': len(placed or []), 'librarySymbols': len(by_lib),+ 'checked': checked, 'limits': LIMITS}+++# ---------------------------------------------------------------- project-library parts++def mpn_key(m):+ return (m or '').strip().upper()+++def parts_rows(run_data):+ """The `parts` step's rows (each MPN's wiki page or project-library files) by MPN."""+ rows = (((run_data or {}).get('outcomes') or {}).get('parts') or {}).get('parts') or []+ return {mpn_key(r.get('mpn')): r for r in rows if isinstance(r, dict) and r.get('mpn')}+++def part_origin(comp, row):+ """Where one netlist part's KiCad files come from: ('wiki', slug) or ('project-lib', projectLib).+ A page named in the netlist wins, then the page `parts` matched, then the board's project library+ (`parts --project-lib`, already checked complete there), then the MPN as the page slug."""+ if comp.get('page'): return 'wiki', comp['page']+ if (row or {}).get('wiki'): return 'wiki', row['wiki']+ if isinstance((row or {}).get('projectLib'), dict): return 'project-lib', row['projectLib']+ return 'wiki', (comp.get('mpn') or '').lower()+++def _top_blocks(text):+ """The top-level child blocks of an s-expression file (strings with parentheses respected)."""+ out, depth, start, in_str, i, n = [], 0, None, False, 0, len(text)+ while i < n:+ ch = text[i]+ if in_str:+ if ch == '\\': i += 2; continue+ if ch == '"': in_str = False+ elif ch == '"': in_str = True+ elif ch == '(':+ depth += 1+ if depth == 2: start = i+ elif ch == ')':+ if depth == 2 and start is not None: out.append(text[start:i + 1]); start = None+ depth -= 1+ i += 1+ return out+++def _sym_name(block):+ m = re.match(r'\(symbol\s+"((?:[^"\\]|\\.)*)"', block)+ return m.group(1) if m else None+++def single_symbol_lib(text, name):+ """A .kicad_sym with just the symbol `name` (and the symbol it extends): the converter reads every pin+ in its file, and a project library holds many symbols. None when the library has no such symbol."""+ syms = {}+ for b in _top_blocks(text):+ n = _sym_name(b)+ if n is not None: syms[n] = b+ key = name.split(':')[-1]+ hit = syms.get(key) or next((b for n, b in syms.items() if n.upper() == key.upper()), None)+ if hit is None: return None+ want = [hit]+ ext = re.search(r'\(extends\s+"((?:[^"\\]|\\.)*)"', hit)+ if ext and ext.group(1) in syms: want.insert(0, syms[ext.group(1)])+ v = re.search(r'\(version\s+(\d+)\)', text)+ return '(kicad_symbol_lib (version %s) (generator "adom-aiflow")\n %s\n)\n' % (v.group(1) if v else '20241209', '\n '.join(want))+++def is_step(path):+ try:+ return Path(path).read_bytes()[:64].decode('latin-1').lstrip().startswith('ISO-10303-21')+ except OSError:+ return False+++def stage_project_part(key, pl, dest):+ """One project-library part's KiCad files staged where a wiki part's page is cloned (the lane's+ pages/<key>/: the board render takes its courtyard from the footprint there): its one symbol, its+ footprint and its STEP model. Returns ({sym, outline, fp, step}, []) or (None, [what is wrong])."""+ sf, ff, mf = Path(pl.get('symbolFile') or ''), Path(pl.get('footprintFile') or ''), Path(pl.get('model') or '')+ problems = ['%s %s is missing' % (what, f) for what, f in (('symbol file', sf), ('footprint', ff), ('STEP model', mf)) if not f.is_file()]+ if problems: return None, problems+ name = pl.get('symbol') or key+ text = single_symbol_lib(sf.read_text(errors='ignore'), name)+ if text is None: problems.append('no symbol %s in %s' % (name, sf))+ if not is_step(mf): problems.append('%s is not a STEP file (no ISO-10303-21 header)' % mf)+ if problems: return None, problems+ dest = Path(dest)+ shutil.rmtree(dest, ignore_errors=True); dest.mkdir(parents=True)+ sym = dest / (re.sub(r'[^A-Za-z0-9_.-]', '_', name.split(':')[-1]) + '.kicad_sym')+ sym.write_text(text)+ fp = dest / ff.name; shutil.copy(ff, fp)+ step = dest / mf.name; shutil.copy(mf, step)+ return {'sym': sym, 'outline': None, 'fp': fp, 'step': step}, []+++def project_part_facts(pl, row):+ """What a project-library part says about itself for the Altium parameters: description, manufacturer,+ package and any distributor numbers named in its provenance.json entry."""+ prov = pl.get('provenance') or {}+ links = dict(prov.get('distributor_links') or {})+ for k in ('mouser', 'digikey', 'lcsc'):+ if prov.get(k) and not links.get(k): links[k] = prov[k]+ desc = prov.get('description') or (row or {}).get('value') or pl.get('footprint') or ''+ return {'desc': str(desc)[:120].replace('\n', ' '), 'mfr': prov.get('manufacturer') or (row or {}).get('manufacturer') or '',+ 'package': prov.get('package') or pl.get('footprint') or '', 'links': links, 'source': pl.get('source') or prov.get('source') or ''}
tools/altium-project.py+173−46@@ -1,13 +1,16 @@ """adom-aiflow altium: the Altium side of a project, done the same way every time (run after project-common). library one project library in Altium (a .SchLib and a .PcbLib): every part from its wiki component- page, its footprint and symbol converted from the page's KiCad files, its STEP model bound,+ page (or, with no public page yet, from the board's project library: `parts --project-lib`), its+ footprint and symbol converted from the KiCad files, its STEP model bound, the Mouser, DigiKey and LCSC numbers looked up and checked; later parts join the SAME library reference placement and spec coordinates from a reference KiCad board, in the Altium board's frame (the board's lower-left corner at 0,0, y up) schematic the design's schematic drawn in Altium from the netlist (one column per block, every pin labelled with its net) inside a project, compiled and checked pin by pin against the- netlist; then the board, as `board` builds it, in the same project+ netlist, its symbols and pin types checked as drawn; then the board, as `board` builds it, in the same project+ symbols the schematic's symbols checked again on the drawn sheet (pins on their bodies, pin types on+ active parts, polarity drawings), as `schematic` does after it compiles; recorded in its proof board the design's board built in Altium from the netlist: the outline, the fixed parts where the spec puts them, every other part parked below the board for placement, every pad on its net; saved, read back pin by pin, and the read written for `adom-aiflow start`@@ -42,6 +45,62 @@ def fp_name(s): # ---------------------------------------------------------------- library +def run_data():+ """The run's state (run.json), or {}."""+ try:+ d = json.loads((RUN / 'run.json').read_text())+ return d.get('data') or d+ except Exception:+ return {}+++def run_spec():+ """The run's spec (its schematic overrides: allowPassive, polarityReviewed), or {}."""+ try:+ return json.loads(Path(run_data().get('spec') or '').read_text())+ except Exception:+ return {}+++def part_source(key, w):+ """One part's KiCad files and facts, from its wiki component page or from the board's project library+ (`parts --project-lib`): {sym, outline, fp, step, desc, mfr, package, links, origin, source}."""+ pdir = STATE / 'pages' / key+ if w['origin'] == 'project-lib':+ pl = w['projectLib']+ files, problems = stage_project_part(key, pl, pdir)+ if problems: err('%s: the project library part (%s) is not complete: %s' % (w['mpn'], pl.get('dir'), '; '.join(problems)),+ ['Fix the project library (symbol, footprint, STEP model and its provenance.json entry), run `adom-aiflow parts --project-lib %s` again, then `altium library --only %s`.' % (pl.get('dir') or '<dir>', w['mpn'])])+ (pdir / 'provenance.json').write_text(json.dumps(pl, indent=1))+ return dict(files, origin='project-lib', **project_part_facts(pl, w.get('row')))+ page = wiki_page(w['page'], pdir)+ if not page: err('%s: no wiki component page at %s' % (w['mpn'], w['page']), ['Fetch the part first (chip-fetcher publishes a component page), or give its page slug in the netlist: "page": "<owner>/<slug>".',+ 'A part whose page cannot be public yet comes from the board\'s project library: `adom-aiflow parts --project-lib <dir>` (symbol, footprint, STEP model, provenance.json), then `altium library` again.'])+ comp = page.get('component', {})+ sym, outline, fp, step = pick_files(pdir, comp)+ if not fp: err('%s: the page has no .kicad_mod footprint' % w['mpn'])+ return {'sym': sym, 'outline': outline, 'fp': fp, 'step': step, 'desc': (page.get('brief') or comp.get('package') or '')[:120].replace('\n', ' '),+ 'mfr': comp.get('manufacturer') or '', 'package': comp.get('package') or '', 'links': comp.get('distributor_links'), 'origin': 'wiki', 'source': w['page']}+++def convert_part(key, label, src, stage):+ """The import specs of one part from its KiCad files, by the same converters whatever their source:+ altium-kicad2altium (footprint), altium-symbol-modes (symbol; a chip's outline symbol when the page has+ one and the footprint more than two pads), the STEP bound as it is. Returns (files, symbol spec or None)."""+ spec_fp = stage / (key + '.fp.spec'); spec_sym = stage / (key + '.sym.spec'); files = []+ r = run(['altium-kicad2altium', str(src['fp']), str(spec_fp), key, src['desc']])+ if r.returncode != 0 or not spec_fp.is_file(): err('%s: footprint conversion failed: %s' % (label, clip(r.stdout + r.stderr, 300)))+ files.append(spec_fp)+ outline, sym = src.get('outline'), src.get('sym')+ use = outline if (outline and len(set(re.findall(r'\(pad "([^"]+)"', Path(src['fp']).read_text(errors='ignore')))) > 2) else sym+ if use:+ r = run(['altium-symbol-modes', str(use), str(spec_sym), key, src['desc']])+ if r.returncode != 0 or not spec_sym.is_file(): err('%s: symbol conversion failed: %s' % (label, clip(r.stdout + r.stderr, 300)))+ files.append(spec_sym)+ if src.get('step'): files.append(src['step'])+ return files, (spec_sym if spec_sym in files else None)++ def cmd_library(a): for tool, pkg in (('altium-kicad2altium', 'adom/altium-bridge'), ('altium-symbol-modes', 'adom/altium-bridge'), ('adom-wiki', 'adom/adom-wiki')): need(tool, pkg)@@ -55,77 +114,74 @@ def cmd_library(a): cache = json.loads(Path(a.numbers).read_text()) if a.numbers else {} refs = st.setdefault('refs', {}) stage = STATE / 'stage'; shutil.rmtree(stage, ignore_errors=True); stage.mkdir(parents=True)+ kept_specs = STATE / 'symbols'; kept_specs.mkdir(parents=True, exist_ok=True) bdir, sch_lib, pcb_lib = lib_paths(st)- added, kept, unverified = [], [], {}+ added, kept, unverified, from_lib = [], [], {}, []+ # the `parts` step's choice per MPN: the wiki page it matched, or the board's project library+ rows = parts_rows(run_data()) work = {} for c in comps: mpn = c.get('mpn') if mpn: key = fp_name(mpn)- work.setdefault(key, {'mpn': mpn, 'page': c.get('page') or mpn.lower(), 'refs': []})['refs'].append(c.get('ref'))+ row = rows.get(mpn_key(mpn)) or {}+ origin, where = part_origin(c, row)+ w = work.setdefault(key, {'mpn': mpn, 'origin': origin, 'page': where if origin == 'wiki' else None,+ 'projectLib': where if origin == 'project-lib' else None, 'row': row, 'refs': []})+ w['refs'].append(c.get('ref')) else: fpn = (c.get('fp') or '').split(':')[-1] hit = next(iter(sorted(Path(a.footprints).rglob(fpn + '.kicad_mod'))), None) if a.footprints and fpn else None if not hit: err('%s has no MPN and its footprint %s was not found' % (c.get('ref'), fpn or '?'), ['Give --footprints <folder with %s.kicad_mod>, or an MPN in the netlist.' % fpn]) key = fp_name(fpn)- work.setdefault(key, {'footprint': str(hit), 'refs': []})['refs'].append(c.get('ref'))+ work.setdefault(key, {'footprint': str(hit), 'origin': 'board-only', 'refs': []})['refs'].append(c.get('ref')) refs[c.get('ref')] = key only = {fp_name(m) for m in (a.only or [])} if only - set(work): err('--only names part(s) not in --parts: %s' % ', '.join(sorted(only - set(work)))) for key, w in work.items():+ if w['origin'] == 'project-lib': from_lib.append(w['mpn']) if only and key not in only: kept.append(key); continue if st['parts'].get(key, {}).get('imported') and not a.again and not only: kept.append(key); continue- spec_fp = stage / (key + '.fp.spec'); spec_sym = stage / (key + '.sym.spec'); files = [] params = {}- step = None- if w.get('footprint'):- r = run(['altium-kicad2altium', w['footprint'], str(spec_fp), key, 'board-only pad'])- if r.returncode != 0 or not spec_fp.is_file(): err('%s: footprint conversion failed: %s' % (key, clip(r.stdout + r.stderr, 300)))- files.append(spec_fp)+ if w['origin'] == 'board-only':+ files, spec_sym = convert_part(key, key, {'fp': w['footprint'], 'desc': 'board-only pad'}, stage)+ step = None else:- pdir = STATE / 'pages' / key- page = wiki_page(w['page'], pdir)- if not page: err('%s: no wiki component page at %s' % (w['mpn'], w['page']), ['Fetch the part first (chip-fetcher publishes a component page), or give its page slug in the netlist: "page": "<owner>/<slug>".'])- comp = page.get('component', {})- sym, outline, fp, step = pick_files(pdir, comp)- if not fp: err('%s: the page has no .kicad_mod footprint' % w['mpn'])- desc = (page.get('brief') or comp.get('package') or '')[:120].replace('\n', ' ')- r = run(['altium-kicad2altium', str(fp), str(spec_fp), key, desc])- if r.returncode != 0 or not spec_fp.is_file(): err('%s: footprint conversion failed: %s' % (w['mpn'], clip(r.stdout + r.stderr, 300)))- files.append(spec_fp)- use = outline if (outline and len(set(re.findall(r'\(pad "([^"]+)"', fp.read_text(errors='ignore')))) > 2) else sym- if use:- r = run(['altium-symbol-modes', str(use), str(spec_sym), key, desc])- if r.returncode != 0 or not spec_sym.is_file(): err('%s: symbol conversion failed: %s' % (w['mpn'], clip(r.stdout + r.stderr, 300)))- files.append(spec_sym)- if step: files.append(step)- numbers, notes = lookup_numbers(w['mpn'], comp.get('distributor_links'), cache)- mfr = comp.get('manufacturer') or ''- params = {'MPN': w['mpn'], 'Manufacturer': mfr, 'Package': comp.get('package') or ''}+ src = part_source(key, w)+ files, spec_sym = convert_part(key, w['mpn'], src, stage)+ step = src.get('step')+ numbers, notes = lookup_numbers(w['mpn'], src.get('links'), cache)+ mfr = src.get('mfr') or ''+ params = {'MPN': w['mpn'], 'Manufacturer': mfr, 'Package': src.get('package') or ''} if numbers.get('mouser'): params['Mouser Part #'] = numbers['mouser'] if numbers.get('digikey'): params['DigiKey Part #'] = numbers['digikey'] if numbers.get('lcsc'): params['LCSC Part #'] = numbers['lcsc'] missing = [k for k in ('mouser', 'digikey', 'lcsc') if not numbers.get(k)] if missing and mfr.strip().lower() != 'adom': unverified[w['mpn']] = missing + (['(' + '; '.join(sorted(set(notes))) + ')'] if notes else [])+ spec_fp = files[0] send(files, bdir + '/stage') imp = {'pcbLib': win(pcb_lib), 'footprintSpec': win(bdir + '/stage/' + spec_fp.name)}- if spec_sym in files:+ if spec_sym: imp['schLib'] = win(sch_lib); imp['symbolSpec'] = win(bdir + '/stage/' + spec_sym.name)- if step: imp['step'] = win(bdir + '/stage/' + step.name)+ if step: imp['step'] = win(bdir + '/stage/' + Path(step).name) if params: imp['params'] = params r = ab('altium_import_part', imp, timeout=900) if not r.get('success'): err('%s: altium_import_part failed: %s' % (key, str(r.get('error') or r.get('errorCode'))[:300]), [r.get('_hint')])- st['parts'][key] = {'imported': True, 'mpn': w.get('mpn'), 'refs': w['refs'], 'symbol': spec_sym in files, 'step': bool(step), 'params': params}+ # the symbol spec as imported, kept for the schematic's symbol check (every display mode's pins on its body)+ if spec_sym: shutil.copy(spec_sym, kept_specs / spec_sym.name)+ st['parts'][key] = {'imported': True, 'mpn': w.get('mpn'), 'refs': w['refs'], 'symbol': bool(spec_sym), 'step': bool(step), 'params': params, 'source': w['origin']}+ if w['origin'] == 'project-lib': st['parts'][key]['projectLib'] = {'dir': w['projectLib'].get('dir'), 'source': w['projectLib'].get('source')} added.append(key)- say('part %s: footprint%s%s, refs %s' % (key, ', symbol' if spec_sym in files else '', ', 3D' if step else '', ' '.join(w['refs'])))+ say('part %s: footprint%s%s, refs %s%s' % (key, ', symbol' if spec_sym else '', ', 3D' if step else '', ' '.join(w['refs']), ' (from the project library)' if w['origin'] == 'project-lib' else '')) save_state(st) st['schLib'], st['pcbLib'] = sch_lib, pcb_lib save_state(st)- prove('library', {'name': name, 'parts': len(st['parts']), 'tool': 'altium'})- ok('library %s: %d part(s) (%d added, %d kept): %s and %s' % (name, len(st['parts']), len(added), len(kept), pcb_lib, sch_lib),- ['No distributor number yet: ' + '; '.join('%s %s' % (m, ' '.join(v)) for m, v in unverified.items()) + '. Run `altium library` again when the services are back, or add the numbers to the part\'s wiki page.' if unverified else '',+ prove('library', {'name': name, 'parts': len(st['parts']), 'tool': 'altium', 'projectLibParts': from_lib})+ ok('library %s: %d part(s) (%d added, %d kept%s): %s and %s' % (name, len(st['parts']), len(added), len(kept),+ '; %d from the board\'s project library: %s' % (len(from_lib), ', '.join(from_lib)) if from_lib else '', pcb_lib, sch_lib),+ ['No distributor number yet: ' + '; '.join('%s %s' % (m, ' '.join(v)) for m, v in unverified.items()) + '. Run `altium library` again when the services are back, or add the numbers to the part\'s wiki page (a project-library part: "mouser"/"digikey"/"lcsc" in its provenance.json entry).' if unverified else '', 'Next: adom-aiflow altium reference --kicad <reference .kicad_pcb> --spec <spec.json> (if there is a reference board), then adom-aiflow altium schematic --netlist <netlist.json> --name <Design> (the schematic, then the board in the same project).']) @@ -276,7 +332,7 @@ def cmd_schematic(a): st['schematic'] = sch; st['project'] = r.get('project'); save_state(st) b = build_board(a) # the project opened before its board file existed lists the board twice (one greyed): open it again- # from its file, now that every document is there, and show the sheet+ # from its file, now that every document is there if r.get('project'): for d in (b['pcb'], sch): ab('altium_save', {'filePath': d})@@ -284,18 +340,88 @@ def cmd_schematic(a): for d in (b['pcb'], sch, win(st['schLib']), win(st['pcbLib']), r['project']): ab('altium_close_document', {'path': d}) 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'}})+ # symbols and pin types (what a clean compile does not prove): the sheet read back from Altium+ say('checking the drawn symbols: pins on their bodies, pin types, polarity drawings')+ sym = symbol_check(st, sch)+ ab('altium_show_schematic', {'filePath': sch})+ ab('altium_run_command', {'command': 'Sch:Zoom', 'params': {'Action': 'All'}}) # 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)+ # the compiler's messages (Altium's connection matrix checks pin types); symbols: tools/altium-lane.py 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')),+ 'parity': True, 'erc': {'pass': not r.get('compileErrors'), 'errors': r.get('compileErrors') or []},+ 'symbols': {'ok': sym['ok'], 'errors': sym['errors'], 'warnings': sym['warnings'], 'checked': sym.get('checked'), 'report': str((RUN / 'schematic-check.json').resolve())}})+ warn = 'Symbol warnings (%d): %s%s' % (len(sym['warnings']), '; '.join(sym['warnings'][:6]), '; ... (all in %s)' % (RUN / 'schematic-check.json') if len(sym['warnings']) > 6 else '') if sym['warnings'] else ''+ nxt = 'Then the board: adom-aiflow start --board %s --spec <spec.json> --engine <you> --target %s --remote-board altium:%s' % (b['out'], TARGET, b['pcb'])+ if not sym['ok']:+ err('schematic %s drawn and compiled to the netlist pin for pin, the board built (%s), but its symbols do not check out:\n %s' % (sch, b['pcb'], '\n '.join(sym['errors'])),+ ['Pins off the body: the symbol spec came from an older altium-symbol-modes; update adom/altium-bridge and import the part again (`altium library --only <MPN>`), then `altium schematic` again.',+ 'Pin types: Altium holds what the SchLib symbol declares (amplifier inputs Input, outputs Output, power pins Power). When the datasheet really says otherwise, add {"ref", "pin", "why"} to spec.schematic.allowPassive; a reviewed polarity drawing goes in spec.schematic.polarityReviewed.',+ 'Recorded in the schematic proof: the verified list says "schematic symbols and pin types NO" and finish refuses until a clean `altium schematic`. Report: %s' % (RUN / 'schematic-check.json'),+ warn, nxt])+ ok('schematic %s: %d part(s) drawn, %d labelled pin(s), compiled to the netlist pin for pin (%d nets), symbols and pin types checked (%d symbol(s), %d warning(s)); 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, sym.get('librarySymbols') or 0, len(sym['warnings']), b['parts'], b['parked'], r.get('project')), ['Board-only parts (no symbol, on the board only): %s.' % ', '.join(board_only) if board_only else '',+ warn, 'Look at it: altium_show_schematic {"filePath": "%s"} and a screenshot; every part in its block, every pin labelled with its net.' % sch, 'Next: simulate it before the board (the Adom SPICE dashboard; record it with `evidence add --kind simulation`).',- 'Then the board: adom-aiflow start --board %s --spec <spec.json> --engine <you> --target %s --remote-board altium:%s' % (b['out'], TARGET, b['pcb'])])+ nxt])+++def symbol_check(st, sch):+ """The drawn sheet's symbols and pin types, read back from Altium: altium_schematic_data (every placed+ part's pins with their electrical type and place), altium_symbol_prims per used symbol (its SchLib+ record), the symbol specs the library step kept; judged by check_symbols (tools/altium-lane.py).+ Writes <run>/schematic-check.json."""+ sd = ab('altium_schematic_data', {'filePath': sch, 'pins': True}, timeout=300)+ data = sd.get('data') if isinstance(sd.get('data'), dict) else sd+ if not sd.get('success') or not isinstance(data.get('components'), list):+ rep = {'ok': False, 'errors': ['could not read the drawn sheet back (altium_schematic_data): %s' % clip(str(sd.get('error') or data.get('error') or sd.get('errorCode') or 'no components'), 200)], 'warnings': [], 'checked': {}}+ else:+ comps = data['components']+ specs, prims = {}, {}+ kept = {f.name[:-len('.sym.spec')].upper(): f for f in (STATE / 'symbols').glob('*.sym.spec')} if (STATE / 'symbols').is_dir() else {}+ for lr in sorted({c.get('libReference') for c in comps if c.get('libReference')}):+ if lr.upper() in kept: specs[lr] = kept[lr.upper()].read_text(errors='ignore')+ p = ab('altium_symbol_prims', {'libraryPath': win(st['schLib']), 'symbolName': lr}, timeout=120)+ pd = p.get('data') if isinstance(p.get('data'), dict) else p+ if p.get('success') and pd.get('ok', True) is not False: prims[lr] = pd+ rep = check_symbols(comps, specs, prims, run_spec())+ # a bridge before 0.2.52 makes every library pin Passive and answers symbol_prims' body from+ # any display mode: its pin types and its sheet body say nothing, so those findings warn+ ver = str(sd.get('bridgeVersion') or data.get('bridgeVersion') or '')+ old = tuple(int(x) for x in re.findall(r'\d+', ver)[:3]) < (0, 2, 52) if ver else False+ if old:+ keep, moved = [], []+ for e in rep['errors']:+ (moved if e.startswith('pin type') or ('as drawn on the sheet' in e and not specs) else keep).append(e)+ if moved:+ rep['errors'] = keep+ rep['warnings'] = ['Altium Bridge %s predates 0.2.52 (every library pin Passive, the body from any display mode); update the bridge, then `altium library --again` and `altium symbols`: %s' % (ver, m) for m in moved] + rep['warnings']+ rep['ok'] = not keep+ rep['bridgeVersion'] = ver or None+ (RUN / 'schematic-check.json').write_text(json.dumps(rep, indent=1))+ return rep+++def cmd_symbols(a):+ """The symbol check alone, on the sheet `altium schematic` drew (after a library fix, or on a run from+ before 0.2.10): the schematic proof's symbols result is replaced, the rest of the proof kept."""+ st = load_state()+ sch = a.sch or st.get('schematic')+ if not sch or not st.get('schLib'): err('no Altium schematic yet', ['Run `adom-aiflow altium schematic --netlist <netlist.json> --name <Design>` first: it draws the sheet and runs this check.'])+ pf = RUN / 'proofs' / 'schematic.json'+ data = (json.loads(pf.read_text()).get('data') or {}) if pf.is_file() else {}+ sym = symbol_check(st, sch)+ ab('altium_show_schematic', {'filePath': sch})+ data['symbols'] = {'ok': sym['ok'], 'errors': sym['errors'], 'warnings': sym['warnings'], 'checked': sym.get('checked'), 'report': str((RUN / 'schematic-check.json').resolve())}+ data.setdefault('sch', sch)+ prove('schematic', data)+ warn = 'Symbol warnings (%d): %s' % (len(sym['warnings']), '; '.join(sym['warnings'][:8])) if sym['warnings'] else ''+ if not sym['ok']:+ err('schematic %s: %d symbol error(s):\n %s' % (sch, len(sym['errors']), '\n '.join(sym['errors'])),+ [warn, 'Recorded in the schematic proof (verified list: schematic symbols and pin types NO; finish refuses). Report: %s' % (RUN / 'schematic-check.json')])+ ok('schematic %s: %d placed part(s), %d symbol(s): pins on their bodies, pin types and polarity drawings check out (%d warning(s)); recorded in the schematic proof' % (+ sch, sym.get('symbols') or 0, sym.get('librarySymbols') or 0, len(sym['warnings'])), [warn]) # ---------------------------------------------------------------- silkscreen@@ -573,12 +699,13 @@ def main(): 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 / 'altium')) p = sub.add_parser('board'); p.add_argument('--netlist', required=True); p.add_argument('--name', required=True); p.add_argument('--fixed', help='fixed.json from `altium reference` (default: <run>/altium/fixed.json)'); p.add_argument('--outline', nargs=2, type=float, help='W H in mm when there is no fixed.json') p = sub.add_parser('schematic'); p.add_argument('--netlist', required=True); p.add_argument('--name', required=True); p.add_argument('--fixed', help='fixed.json from `altium reference` (default: <run>/altium/fixed.json)'); p.add_argument('--outline', nargs=2, type=float, help='W H in mm when there is no fixed.json'); p.add_argument('--revision', default='A')+ p = sub.add_parser('symbols'); p.add_argument('--sch', help='the .SchDoc (default: the project schematic)') p = sub.add_parser('silkscreen'); p.add_argument('--design', help='the .PcbDoc (default: the project board)'); p.add_argument('--title', help='the board identity on the bottom (default: the design name)'); p.add_argument('--dry', action='store_true', help='plan only'); p.add_argument('--fresh', action='store_true', help='clear every free overlay text and line first (earlier passes left on the board)') p = sub.add_parser('export'); p.add_argument('--design', help='the .PcbDoc (default: the project board)'); p.add_argument('--out-dir', help='default <run>/manufacturing') p = sub.add_parser('reset'); p.add_argument('--design') p = sub.add_parser('view3d'); p.add_argument('--design'); p.add_argument('--pause', type=float, default=0.7) a = ap.parse_args()- {'library': cmd_library, 'reference': cmd_reference, 'schematic': cmd_schematic, 'board': cmd_board, 'export': cmd_export, 'silkscreen': cmd_silkscreen, 'reset': cmd_reset, 'view3d': cmd_view3d}[a.cmd](a)+ {'library': cmd_library, 'reference': cmd_reference, 'schematic': cmd_schematic, 'symbols': cmd_symbols, 'board': cmd_board, 'export': cmd_export, 'silkscreen': cmd_silkscreen, 'reset': cmd_reset, 'view3d': cmd_view3d}[a.cmd](a) main()
tools/fusion-project.py+18−1@@ -146,7 +146,24 @@ def box_dir(): 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+ # one folder per run, never shared: named after the project folder and the run (every run is+ # "aiflow-run", so the run's name alone sent a second project into the first one's folder and+ # its Altium files), and claimed with a marker naming this run; a folder another run claimed+ # gets a numbered sibling+ base = home.replace('\\', '/') + '/Documents/AdomAiFlow/'+ me = str(RUN.resolve())+ stem = re.sub(r'[^A-Za-z0-9._-]+', '-', '%s-%s' % (RUN.resolve().parent.name, RUN.resolve().name)).strip('-') or 'run'+ d = None+ for k in range(1, 50):+ cand = base + stem + ('' if k == 1 else '-%d' % k)+ w = cand.replace('/', '\\')+ r = ab('shell_execute', {'command': 'if exist "%s\\.aiflow-run" (type "%s\\.aiflow-run") else if exist "%s" (echo AIFLOW-TAKEN) else (echo AIFLOW-FREE)' % (w, w, w)})+ o = str(r.get('stdout') or r.get('output') or '').strip()+ if 'AIFLOW-FREE' in o or me in [l.strip() for l in o.splitlines()]:+ d = cand; break+ if d is None: err('no free project folder under %s for %s' % (base, stem), ['Move old AI Flow project folders out of Documents/AdomAiFlow on the desktop.'])+ w = d.replace('/', '\\')+ ab('shell_execute', {'command': 'mkdir "%s" 2>nul & (echo %s)> "%s\\.aiflow-run"' % (w, me, w)}) st['boxDir'] = d; save_state(st) return d
tools/project-common.py+18−1@@ -106,7 +106,24 @@ def box_dir(): 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+ # one folder per run, never shared: named after the project folder and the run (every run is+ # "aiflow-run", so the run's name alone sent a second project into the first one's folder and+ # its Altium files), and claimed with a marker naming this run; a folder another run claimed+ # gets a numbered sibling+ base = home.replace('\\', '/') + '/Documents/AdomAiFlow/'+ me = str(RUN.resolve())+ stem = re.sub(r'[^A-Za-z0-9._-]+', '-', '%s-%s' % (RUN.resolve().parent.name, RUN.resolve().name)).strip('-') or 'run'+ d = None+ for k in range(1, 50):+ cand = base + stem + ('' if k == 1 else '-%d' % k)+ w = cand.replace('/', '\\')+ r = ab('shell_execute', {'command': 'if exist "%s\\.aiflow-run" (type "%s\\.aiflow-run") else if exist "%s" (echo AIFLOW-TAKEN) else (echo AIFLOW-FREE)' % (w, w, w)})+ o = str(r.get('stdout') or r.get('output') or '').strip()+ if 'AIFLOW-FREE' in o or me in [l.strip() for l in o.splitlines()]:+ d = cand; break+ if d is None: err('no free project folder under %s for %s' % (base, stem), ['Move old AI Flow project folders out of Documents/AdomAiFlow on the desktop.'])+ w = d.replace('/', '\\')+ ab('shell_execute', {'command': 'mkdir "%s" 2>nul & (echo %s)> "%s\\.aiflow-run"' % (w, me, w)}) st['boxDir'] = d; save_state(st) return d
tools/project-page.py+9−4@@ -539,9 +539,14 @@ 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 altium_section(parts):+ """The README's Altium section: where the SchLib and PcbLib come from (the wiki pages, and the board's+ own project library for parts with no public page yet, converted the same way)."""+ proj = sorted({p["mpn"] for p in parts if p.get("projectLib") and not p.get("wiki")})+ src = "the parts' wiki pages above" + (f" and, for {', '.join(proj)} (no public page yet), from the board's own project library: the same KiCad symbol, footprint and STEP model converted the same way" if proj else "")+ return f"""## Built in Altium Designer++AI Flow builds this board's Altium libraries from {src}: 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. """ @@ -566,7 +571,7 @@ def readme(cfg, lib, pkgid, repo_url): |---|---|---|---|---|---| {chr(10).join(rows)} -{ALTIUM_SECTION if cfg.get("eda") == "altium" else ""}{"" if cfg.get("eda") == "altium" else f"""## Install every part in KiCad+{altium_section(cfg["parts"]) 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: