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
Publish 0.2.17
10 files changed
+457−29
Cargo.lock+10−10@@ -4,7 +4,7 @@ version = 4 [[package]] name = "adom-aiflow"-version = "0.2.16"+version = "0.2.17" dependencies = [ "aiflow-analyze", "aiflow-board",@@ -25,7 +25,7 @@ dependencies = [ [[package]] name = "aiflow-analyze"-version = "0.2.16"+version = "0.2.17" dependencies = [ "aiflow-board", "serde",@@ -35,7 +35,7 @@ dependencies = [ [[package]] name = "aiflow-board"-version = "0.2.16"+version = "0.2.17" dependencies = [ "roxmltree", "serde",@@ -44,7 +44,7 @@ dependencies = [ [[package]] name = "aiflow-bridge"-version = "0.2.16"+version = "0.2.17" dependencies = [ "aiflow-board", "serde",@@ -53,7 +53,7 @@ dependencies = [ [[package]] name = "aiflow-copper"-version = "0.2.16"+version = "0.2.17" dependencies = [ "aiflow-board", "aiflow-grid",@@ -63,7 +63,7 @@ dependencies = [ [[package]] name = "aiflow-grid"-version = "0.2.16"+version = "0.2.17" dependencies = [ "aiflow-board", "serde",@@ -72,7 +72,7 @@ dependencies = [ [[package]] name = "aiflow-place"-version = "0.2.16"+version = "0.2.17" dependencies = [ "aiflow-board", "serde",@@ -81,7 +81,7 @@ dependencies = [ [[package]] name = "aiflow-pours"-version = "0.2.16"+version = "0.2.17" dependencies = [ "aiflow-board", "aiflow-copper",@@ -91,7 +91,7 @@ dependencies = [ [[package]] name = "aiflow-router"-version = "0.2.16"+version = "0.2.17" dependencies = [ "aiflow-board", "aiflow-grid",@@ -101,7 +101,7 @@ dependencies = [ [[package]] name = "aiflow-run"-version = "0.2.16"+version = "0.2.17" dependencies = [ "serde", "serde_json",
Cargo.toml+1−1@@ -14,7 +14,7 @@ members = [ ] [workspace.package]-version = "0.2.16"+version = "0.2.17" edition = "2021" license = "MIT" repository = "https://wiki.adom.inc/adom/adom-aiflow"
SKILL.md+1−1@@ -252,7 +252,7 @@ No recording may outlive an hour (a hard cap on every recording), `finish` and ` Deterministic checks that refuse, each an OK/ERROR with hints: - **`netlist check [--sch design.kicad_sch] [--board B] [--net exported.net]`**: exports the schematic's netlist with kicad-cli (`sch export netlist --format kicadsexpr`, through `ADOM_AIFLOW_KICAD_CLI`; `adom-aiflow-kicad-cli-remote` does it on a desktop's KiCad 10 and carries the sub-sheets, lib tables and `${KIPRJMOD}` symbol libraries) and compares it pin by pin with the board's pads: missing and extra references, and every pin whose net differs (`/VIN` equals `VIN`; unlabelled `Net-(...)` nets compare by their pins). In a run it is recorded; `finish` re-checks the stored netlist against the board it finishes on and asks again when the schematic changed.-- **`molecule check [--board B --spec S]`**, and inside `place check`, `gate` and `finish` whenever the spec has `"molecule": {...}`: MP1 to MP4 are MachinePin footprints (MediumShort 1.6 mm pad / 1.2 mm drill, or large 5.2 / 3.45), MP1 is the front-left pin, the four sit on a rectangle whose sides are whole grid steps (a warning when not 4 mm multiples, which a LrgMed scaffold needs), every MachineContact (medium 1.3 / 0.78) sits on the grid from MP1, pins and contacts keep the edge margin, `molecule.fixed` positions hold, and `fixedRefs` are where the start board had them. Spec: `"molecule": {"gridMm": 2, "edgeMarginMm": 2, "cornerPinsNet": "GND", "contacts": [...], "fixed": {"J1": [0, 12]}}` (mm from MP1, y up; all optional).+- **`molecule check [--board B --spec S]`**, and inside `place check`, `gate` and `finish` whenever the spec has `"molecule": {...}`: MP1 to MP4 are MachinePin footprints (MediumShort 1.6 mm pad / 1.2 mm drill, or large 5.2 / 3.45), MP1 is the front-left pin, the four sit on a rectangle whose sides are whole grid steps (a warning when not 4 mm multiples, which a LrgMed scaffold needs), every MachineContact (medium 1.3 / 0.78) sits on the grid from MP1, pins and contacts keep the edge margin, `molecule.fixed` positions hold, and `fixedRefs` are where the start board had them. Corner-pin class (0.2.17, issue #87): an error when the pins are medium but the board needs large ones (seats in the base scaffold, over 100 g with its parts, a motor, actuator, heatsink over 20 g, cabled connector or hard-pressed button, longest side over 100 mm, or a pin net over 3.5 A in `spec.loads`); a warning when the mass is unknown or a part looks like an undeclared mechanical load; large pins with no reason are fine. The rule, its data and the spec fields are in skill aiflow-molecule. Spec: `"molecule": {"gridMm": 2, "edgeMarginMm": 2, "cornerPinsNet": "GND", "contacts": [...], "fixed": {"J1": [0, 12]}, "seat": "base|lrgmed|board", "massG": 42, "mechanicalLoads": [{"ref": "M1", "kind": "motor"}], "pinCurrentA": 1.2}` (mm from MP1, y up; all optional); `--bom <csv>` (default: the run's BOM) gives a `mass_g` column. - **Fab rules**: `"fab": {"rules": "fab" | "jlcpcb"}` in the spec. `fab` (the 3rd party fab) requires the project's own `.kicad_dru` beside the board; those rules are private and never shipped or restated. `jlcpcb` requires the JLCPCB pack for the board's copper layer count: on 2 layers the shipped profile, installed with `rules install --profile jlcpcb --layers 2` (JLCPCB's published 2-layer capabilities, cited in the file; `--force` replaces another rules file, keeps it as `.kicad_dru.bak` and says what it replaced); on 4, 6 or more layers `rules use --profile jlcpcb --layers <n>` loads the pack from adom/pcb-design-rules (`rules install` refuses a layer count it does not ship). A layer suffix pins the count: `"rules": "jlcpcb-6L"`. When the spec has no `fab.rules`, the gate, finish and `rules check` use what `rules use` recorded in run.json `fabRules` (an intake run has no spec yet), and warn when the spec and `fabRules` disagree. `rules check` says whether the board matches; the gate and finish refuse when it does not. - **3D models right way up**: `models` (and `models --offline`, no bridge) takes every bound model's bbox from `step2glb features` on the shared service (paced, cached per file in the run; the STEP file's own points when the service gives nothing), applies the footprint's rotate/offset as KiCad does, and warns when a flat SMD chip's thinnest axis is not Z or an SMD body's bottom is off the board. Fix the binding and look in the native 3D viewer; when a model is right as it is, `models --ack R1,C2 --why "<what you saw>"`. `finish` refuses while any warning is open. Never adom-chipsmith. - **Pads are real copper in every board GLB**: after `step2glb convert`, `board-glb copper --glb <kicad.glb> --board <design>.kicad_pcb --out <board>.glb [--finish copper|enig|hasl] [--paste none|solder]` makes them so by construction from the footprints' layers (pad/via/track meshes by KiCad layer name get a copper material with explicit metal and roughness; the mask is rebuilt as the outline minus every F.Mask/B.Mask aperture; solder only on F.Paste/B.Paste apertures of fitted parts; test points stay bare copper), then runs the gate. `board-glb check` alone refuses grey or default-metal pads, a mask over any exposed pad centre, solder on a pad without paste, or a board file whose pads do not line up with the GLB. A board file with no pads and no F.Mask/B.Mask apertures has no exposed copper: the GLB is still written (substrate and silkscreen opaque, mask whole) and the gate passes with the note "no exposed copper on this board". In a run the result is recorded (`boardGlb`). Rule and details: aiflow-molecule. Never hand-recolour a material index or lift pads through the mask.
bin/adom-aiflow⋯ 1 unchanged line ⋯
crates/adom-aiflow/src/checks.rs+382−2@@ -149,6 +149,8 @@ pub struct Findings { pub errors: Vec<String>, pub warnings: Vec<String>, pub hints: Vec<String>,+ /// The corner-pin class decision (issue #87): required class, the pins found, every reason.+ pub pin_class: Value, } impl Findings {@@ -172,14 +174,22 @@ fn near_multiple(v: f64, m: f64) -> bool { } /// Conformance of the board to the Adom molecule format. `spec_mol` is spec.molecule; `before` is-/// the board the run started from (for the fixed references), when there is a run.+/// the board the run started from (for the fixed references), when there is a run. Tests call this;+/// the commands pass the run's BOM through `molecule_check_bom`.+#[cfg(test)] pub fn molecule_check(b: &Board, spec: &Value, before: Option<&Board>) -> Findings {+ molecule_check_bom(b, spec, before, None)+}++/// `molecule_check` with the run's BOM (CSV text), which may give the mass of the board with its+/// parts (`mass_g` / `weight_g` column) and hint at parts that push, pull or twist the board.+pub fn molecule_check_bom(b: &Board, spec: &Value, before: Option<&Board>, bom: Option<&str>) -> Findings { let mut f = Findings::default(); let m = spec.get("molecule").cloned().unwrap_or(json!({})); let grid = m["gridMm"].as_f64().unwrap_or(2.0); let margin = m["edgeMarginMm"].as_f64().unwrap_or(2.0); let pin_net = m["cornerPinsNet"].as_str().unwrap_or("GND").to_string();- let hint_fp = "Use the Adom KiCad Library 1.2.3 footprints (lib/Molecule.pretty): MachinePinMediumShort 1.6 mm pad / 1.2 mm drill for MP1 to MP4, MachineContactMedium 1.3 mm pad / 0.78 mm drill for contacts; spec.molecule.pinPadMm / pinDrillMm / contactPadMm / contactDrillMm override the expected sizes (skill aiflow-molecule).";+ let hint_fp = "Use the Adom KiCad Library 1.2.3 footprints (lib/Molecule.pretty): MachinePinMediumShort 1.6 mm pad / 1.2 mm drill (or MachinePinLargeShort / LargeStandard 5.2 mm pad / 3.45 mm drill when the corner-pin class rule needs large pins) for MP1 to MP4, MachineContactMedium 1.3 mm pad / 0.78 mm drill for contacts; spec.molecule.pinPadMm / pinDrillMm / contactPadMm / contactDrillMm override the expected sizes (skill aiflow-molecule)."; let pad_of = |r: &str| b.pads_of(r).into_iter().next().cloned(); let mut pins: BTreeMap<String, (f64, f64)> = BTreeMap::new(); for k in 1..=4 {@@ -332,9 +342,218 @@ pub fn molecule_check(b: &Board, spec: &Value, before: Option<&Board>) -> Findin if moved.is_empty() { f.ok(format!("{} fixed references match explicit positions or starting-board locks; anchor and rotations retained", refs.len())); } else { f.error(format!("fixed references moved since the run started: {}", moved.join("; ")), "Put them back where the start board had them (spec.fixedRefs); placement must not move the molecule interface."); } }+ pin_class_check(b, spec, &m, bom, &mut f); f } +// ----------------------------------------------------------------------------- corner-pin class (#87)++/// Large corner pins when the board with its parts weighs more than this (4 medium contacts hold+/// about 640 g of pull-out).+pub const LARGE_PIN_MASS_G: f64 = 100.0;+/// Large corner pins when the outline's longest side is over this.+pub const LARGE_PIN_SIDE_MM: f64 = 100.0;+/// Large corner pins when a machine pin or contact carries more than this (the medium class rating).+pub const LARGE_PIN_CURRENT_A: f64 = 3.5;+/// A heatsink heavier than this is a load that pushes, pulls or twists the board.+pub const LARGE_PIN_HEATSINK_G: f64 = 20.0;+const MECH_KINDS: [&str; 5] = ["motor", "actuator", "heatsink", "cabled-connector", "button"];++/// The board's corner-pin class: "medium", "large", "mixed", or None when a pin is missing or not a MachinePin.+fn corner_pin_class(b: &Board) -> Option<&'static str> {+ let mut classes = BTreeSet::new();+ for k in 1..=4 {+ let fp = b.footprints.get(&format!("MP{k}"))?;+ let name = fp.lib.rsplit(':').next().unwrap_or(&fp.lib).to_ascii_lowercase();+ classes.insert(match name.as_str() {+ "machinepinmediumshort" | "machinepinmediumstandard" => "medium",+ "machinepinlargeshort" | "machinepinlargestandard" => "large",+ _ => return None,+ });+ }+ if classes.len() == 1 { classes.into_iter().next() } else { Some("mixed") }+}++/// A part whose name suggests a load that pushes, pulls or twists the board: the matched keyword.+fn mech_keyword(text: &str) -> Option<&'static str> {+ let t: String = text.to_ascii_lowercase().chars().filter(|c| c.is_ascii_alphanumeric()).collect();+ if t.contains("motor") { return Some("motor"); }+ if t.contains("actuator") { return Some("actuator"); }+ if t.contains("heatsink") { return Some("heatsink"); }+ if t.contains("barreljack") || t.contains("dcjack") { return Some("barrel jack"); }+ if t.contains("usb") && (t.contains("connector") || t.contains("receptacle") || t.contains("plug")) { return Some("USB connector"); }+ None+}++/// The mass of the board with its parts from a BOM column (`mass_g`, `weight_g`): (total grams,+/// rows without a value, column name). None when the BOM has no such column.+pub fn bom_mass(bom: &str) -> Option<(f64, usize, String)> {+ let rows = crate::preboard::parse_csv(bom);+ let header = rows.first()?;+ let norm = |s: &str| s.trim().to_ascii_lowercase().replace([' ', '-', '#'], "_");+ let find = |names: &[&str]| header.iter().position(|h| names.iter().any(|n| norm(h) == *n));+ let c_mass = find(&["mass_g", "weight_g", "mass_(g)", "weight_(g)"])?;+ let c_ref = find(&["ref", "refs", "reference", "references", "designator", "designators", "refdes"]);+ let c_qty = find(&["qty", "quantity", "count"]);+ let (mut total, mut missing) = (0.0, 0usize);+ for row in rows.iter().skip(1) {+ let cell = |c: Option<usize>| c.and_then(|i| row.get(i)).map(|s| s.trim().to_string()).unwrap_or_default();+ let refs = cell(c_ref).split(|c: char| c == ' ' || c == ',' || c == ';').filter(|s| !s.trim().is_empty()).count();+ let n = if refs > 0 { refs as f64 } else { cell(c_qty).parse::<f64>().unwrap_or(1.0) };+ match cell(Some(c_mass)).parse::<f64>() { Ok(g) => total += g * n, Err(_) => missing += 1 }+ }+ Some((total, missing, header[c_mass].clone()))+}++/// Issue #87, approved by John 2026-10-21: large corner pins (MachinePinLargeShort, or+/// MachinePinLargeStandard on LrgMed user-scaffold corners) when ANY of: the board seats directly in+/// the base scaffold; the board with its parts weighs more than 100 g; it carries a load that pushes,+/// pulls or twists it (motor, actuator, heatsink over 20 g, cabled connector, button pressed hard);+/// its longest side is over 100 mm; a pin carries more than 3.5 A. Otherwise medium (MediumShort).+fn pin_class_check(b: &Board, spec: &Value, m: &Value, bom: Option<&str>, f: &mut Findings) {+ let mut reasons: Vec<String> = Vec::new();+ let mut notes: Vec<String> = Vec::new();+ let declare_hint = "Declare the board's mechanics in spec.molecule (skill aiflow-molecule, corner-pin class): massG (board with parts, grams), seat (\"base\" | \"lrgmed\" | \"board\"), mechanicalLoads ([{\"ref\": \"M1\", \"kind\": \"motor\"}], or [] to confirm there are none), pinCurrentA.";+ // (1) seat+ let seat = m["seat"].as_str().map(|s| s.trim().to_ascii_lowercase());+ match seat.as_deref() {+ Some("base") => reasons.push("seats directly in the base scaffold (spec.molecule.seat \"base\")".into()),+ Some("lrgmed") | Some("board") | None => {}+ Some(other) => f.warn(format!("spec.molecule.seat \"{other}\" is not one of base, lrgmed, board"), declare_hint),+ }+ // (4) size: the outer Edge.Cuts loop's bounding box+ let mut longest = None;+ if b.outline.len() >= 3 {+ let (x0, y0, x1, y1) = geom::bbox(&b.outline);+ let side = (x1 - x0).max(y1 - y0);+ longest = Some(side);+ if side > LARGE_PIN_SIDE_MM + 0.005 { reasons.push(format!("longest side {side:.1} mm is over {LARGE_PIN_SIDE_MM:.0} mm")); }+ else { notes.push(format!("longest side {side:.1} mm")); }+ }+ // (5) current: the highest spec.loads current on a net a machine pin or contact carries+ let mut carriers: BTreeMap<String, Vec<String>> = BTreeMap::new();+ let contact_refs: Vec<String> = m["contacts"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default();+ for (r, fp) in &b.footprints {+ let lib = fp.lib.to_ascii_lowercase();+ if !(lib.contains("machinepin") || lib.contains("machinecontact") || contact_refs.contains(r)) { continue; }+ for p in b.pads_of(r) {+ if p.net.is_empty() || unconnected_name(&p.net) { continue; }+ let e = carriers.entry(norm_net(&p.net)).or_default();+ if !e.contains(r) { e.push(r.clone()); }+ }+ }+ let mut pin_amps: Option<(f64, String)> = None;+ if let Some(loads) = spec["loads"].as_object() {+ for (net, ld) in loads {+ let amps = ld["amps"].as_f64().or_else(|| ld.as_f64()).unwrap_or(0.0);+ if let Some(refs) = carriers.get(&norm_net(net)) {+ if pin_amps.as_ref().map(|p| amps > p.0).unwrap_or(true) { pin_amps = Some((amps, format!("net {} ({})", norm_net(net), refs.join(", ")))); }+ }+ }+ }+ // a declared per-pin current is the reviewed figure and governs; without it the whole net's load is+ // taken as one pin's (conservative: a net shared by several pins splits its current between them)+ if let Some(a) = m["pinCurrentA"].as_f64() {+ if a > LARGE_PIN_CURRENT_A { reasons.push(format!("a pin carries {a} A (spec.molecule.pinCurrentA), over the medium pin's {LARGE_PIN_CURRENT_A} A")); }+ else { notes.push(format!("pinCurrentA {a} A (declared per pin){}", pin_amps.as_ref().map(|(n, w)| format!("; the heaviest pin net is {w} at {n} A")).unwrap_or_default())); }+ } else if let Some((a, what)) = &pin_amps {+ if *a > LARGE_PIN_CURRENT_A { reasons.push(format!("{what} carries {a} A (spec.loads), over the medium pin's {LARGE_PIN_CURRENT_A} A; when several pins share that net's current, declare the per-pin figure as spec.molecule.pinCurrentA")); }+ else { notes.push(format!("highest pin current {a} A on {what}")); }+ }+ // (2) mass: declared, else the BOM's mass column, else a warning (never a guess)+ let mut mass = None;+ if let Some(g) = m["massG"].as_f64() {+ mass = Some((g, "spec.molecule.massG".to_string()));+ if g > LARGE_PIN_MASS_G { reasons.push(format!("weighs {g} g with its parts (spec.molecule.massG), over {LARGE_PIN_MASS_G:.0} g")); }+ else { notes.push(format!("mass {g} g (spec.molecule.massG)")); }+ } else if let Some((g, missing, col)) = bom.and_then(bom_mass) {+ let src = format!("BOM column {col}");+ if g > LARGE_PIN_MASS_G {+ reasons.push(format!("weighs {g:.1} g{} with its parts ({src}), over {LARGE_PIN_MASS_G:.0} g", if missing > 0 { " or more" } else { "" }));+ mass = Some((g, src));+ } else if missing > 0 {+ f.warn(format!("mass unknown: the BOM's {col} column has no value on {missing} row(s) (the rest sum to {g:.1} g); declare spec.molecule.massG (board with parts, grams) so the corner-pin class can be chosen"), declare_hint);+ } else {+ notes.push(format!("mass {g:.1} g ({src}; the bare board counts only if the BOM lists it)"));+ mass = Some((g, src));+ }+ } else {+ f.warn("mass unknown: declare spec.molecule.massG (board with parts, grams), or give the BOM a mass_g column; over 100 g needs large corner pins".into(), declare_hint);+ }+ // (3) loads that push, pull or twist the board+ let declared = m.get("mechanicalLoads");+ let mut declared_refs: BTreeSet<String> = BTreeSet::new();+ match declared {+ Some(Value::Array(items)) => {+ for it in items {+ let r = it["ref"].as_str().unwrap_or("?").to_string();+ let kind = it["kind"].as_str().unwrap_or("").trim().to_ascii_lowercase();+ declared_refs.insert(r.clone());+ if !MECH_KINDS.contains(&kind.as_str()) {+ f.warn(format!("spec.molecule.mechanicalLoads {r}: kind \"{kind}\" is not one of {}", MECH_KINDS.join(", ")), declare_hint);+ continue;+ }+ if kind == "heatsink" {+ match it["massG"].as_f64() {+ Some(g) if g > LARGE_PIN_HEATSINK_G => reasons.push(format!("{r} is a {g} g heatsink, over {LARGE_PIN_HEATSINK_G:.0} g (spec.molecule.mechanicalLoads)")),+ Some(g) => notes.push(format!("{r} heatsink {g} g, not over {LARGE_PIN_HEATSINK_G:.0} g")),+ None => f.warn(format!("{r} heatsink (spec.molecule.mechanicalLoads) has no massG; over {LARGE_PIN_HEATSINK_G:.0} g needs large corner pins"), declare_hint),+ }+ } else {+ reasons.push(format!("{r} is a {kind} that pushes, pulls or twists the board (spec.molecule.mechanicalLoads)"));+ }+ }+ }+ Some(Value::Null) | None => {}+ Some(_) => f.warn("spec.molecule.mechanicalLoads is not a list".into(), declare_hint),+ }+ // keyword hints from the board's footprints (library id, value) and the BOM's text columns+ let mut hinted: BTreeMap<String, &'static str> = BTreeMap::new();+ for fp in b.root.find_all("footprint") {+ let Some(r) = aiflow_board::reference_of(fp) else { continue };+ let lib = fp.value().unwrap_or("").to_string();+ let value = fp.find_all("property").find(|p| p.atom(1) == Some("Value")).and_then(|p| p.atom(2)).unwrap_or("").to_string();+ if let Some(k) = mech_keyword(&lib).or_else(|| mech_keyword(&value)) { hinted.entry(r).or_insert(k); }+ }+ if let Some(text) = bom {+ let rows = crate::preboard::parse_csv(text);+ if let Some(header) = rows.first() {+ let norm = |s: &str| s.trim().to_ascii_lowercase().replace([' ', '-', '#'], "_");+ let c_ref = header.iter().position(|h| ["ref", "refs", "reference", "references", "designator", "designators", "refdes"].contains(&norm(h).as_str()));+ for row in rows.iter().skip(1) {+ let Some(k) = row.iter().enumerate().filter(|(i, _)| Some(*i) != c_ref).find_map(|(_, c)| mech_keyword(c)) else { continue };+ let refs: Vec<String> = c_ref.and_then(|i| row.get(i)).map(|s| s.split(|c: char| c == ' ' || c == ',' || c == ';').map(str::trim).filter(|s| !s.is_empty()).map(str::to_string).collect()).unwrap_or_default();+ for r in refs { hinted.entry(r).or_insert(k); }+ }+ }+ }+ let undeclared: Vec<String> = hinted.iter().filter(|(r, _)| !declared_refs.contains(*r)).map(|(r, k)| format!("{r} ({k})")).collect();+ if !undeclared.is_empty() {+ if declared.is_none() {+ f.warn(format!("{} look like parts that push, pull or twist the board: declare them in spec.molecule.mechanicalLoads (a motor, actuator, cabled connector people unplug, button pressed hard, or heatsink over {LARGE_PIN_HEATSINK_G:.0} g needs large corner pins), or confirm medium is fine with \"mechanicalLoads\": []", undeclared.join(", ")), declare_hint);+ } else {+ notes.push(format!("{} confirmed as no mechanical load (not in spec.molecule.mechanicalLoads)", undeclared.join(", ")));+ }+ }+ // the decision+ let class = corner_pin_class(b);+ let required = if reasons.is_empty() { "medium" } else { "large" };+ match (required, class) {+ ("large", Some("medium")) => f.error(format!("corner pins are medium but this board needs LARGE pins: {}", reasons.join("; ")),+ "Use MachinePinLargeShort for MP1 to MP4 (MachinePinLargeStandard where this board is a LrgMed user scaffold's corners), seated on the base scaffold's 32 mm grid. A large pin is not a footprint-name swap: update the schematic, library, BOM, layout, models and fixture (skill aiflow-molecule, corner-pin class)."),+ ("large", Some("large")) => f.ok(format!("large corner pins, as needed: {}", reasons.join("; "))),+ ("medium", Some("large")) => f.ok(format!("large corner pins; medium would do (no large-pin reason: {}). Large is allowed", if notes.is_empty() { "nothing declared".to_string() } else { notes.join(", ") })),+ ("medium", Some("medium")) => f.ok(format!("medium corner pins: no large-pin reason ({})", if notes.is_empty() { "nothing declared".to_string() } else { notes.join(", ") })),+ _ => {}+ }+ if class == Some("large") && seat.as_deref() == Some("lrgmed") {+ f.warn("large corner pins with spec.molecule.seat \"lrgmed\": a LrgMed user scaffold's medium contacts take medium pins; large pins seat in the base scaffold's large contacts".into(), "Seat the board in the base scaffold (seat \"base\") or recheck which contacts its corner pins land in.");+ }+ f.pin_class = json!({"required": required, "pins": class, "reasons": reasons, "notes": notes, "seat": seat,+ "longestSideMm": longest.map(|v| (v * 100.0).round() / 100.0), "massG": mass.as_ref().map(|x| x.0), "massSource": mass.as_ref().map(|x| x.1.clone()),+ "pinCurrentA": pin_amps.as_ref().map(|x| x.0), "pinCurrentFrom": pin_amps.as_ref().map(|x| x.1.clone())});+}+ // ----------------------------------------------------------------------------- fab rules (#10) /// JLCPCB's published 2-layer standard capabilities as KiCad custom rules (shipped with the binary).@@ -668,6 +887,167 @@ mod tests { assert!(!result.errors.iter().any(|e| e.contains("base scaffold 32 mm grid"))); } + // ---- corner-pin class (#87)++ fn pin_errors(f: &Findings) -> Vec<String> { f.errors.iter().filter(|e| e.contains("needs LARGE pins")).cloned().collect() }+ fn calm(extra: Value) -> Value {+ let mut m = json!({"massG": 40, "mechanicalLoads": [], "seat": "lrgmed"});+ for (k, v) in extra.as_object().unwrap() { m[k] = v.clone(); }+ json!({"molecule": m})+ }++ #[test]+ fn pin_class_declared_per_pin_current_governs_a_shared_net() {+ // 5 A on GND, which all four corner pins carry: taken alone it needs large pins, but four pins+ // share it, and the reviewed per-pin figure (1.25 A) is what the rule judges+ let mut spec = calm(json!({}));+ spec["loads"] = json!({"GND": {"amps": 5}});+ let f = molecule_check(&board(BOARD), &spec, None);+ assert!(pin_errors(&f).iter().any(|e| e.contains("GND") && e.contains("pinCurrentA")), "{:?}", f.lines);+ let f = molecule_check(&board(BOARD), &calm(json!({"pinCurrentA": 1.25})).tap_loads(json!({"GND": {"amps": 5}})), None);+ assert!(pin_errors(&f).is_empty(), "{:?}", f.lines);+ assert_eq!(f.pin_class["required"], "medium");+ }++ trait TapLoads { fn tap_loads(self, loads: Value) -> Value; }+ impl TapLoads for Value { fn tap_loads(mut self, loads: Value) -> Value { self["loads"] = loads; self } }++ #[test]+ fn pin_class_medium_board_under_every_threshold_passes() {+ let mut spec = calm(json!({}));+ spec["loads"] = json!({"VIN": {"amps": 3.5}, "Net-(R1-Pad2)": {"amps": 9}});+ let f = molecule_check(&board(BOARD), &spec, None);+ assert!(f.errors.is_empty() && f.warnings.is_empty(), "{:?}", f.lines);+ assert_eq!(f.pin_class["required"], "medium");+ assert_eq!(f.pin_class["pins"], "medium");+ assert_eq!(f.pin_class["pinCurrentA"], 3.5);+ assert!(f.lines.iter().any(|l| l.contains("medium corner pins: no large-pin reason")), "{:?}", f.lines);+ }++ #[test]+ fn pin_class_size_over_100_mm_needs_large() {+ let long = BOARD.replace("(end 126 102)", "(end 200 102)");+ let f = molecule_check(&board(&long), &calm(json!({})), None);+ let e = pin_errors(&f);+ assert!(e.len() == 1 && e[0].contains("longest side 102.0 mm"), "{:?}", f.lines);+ assert!(f.hints.iter().any(|h| h.contains("MachinePinLargeShort") && h.contains("MachinePinLargeStandard")));+ // exactly 100 mm is not over+ let edge = BOARD.replace("(end 126 102)", "(end 198 102)");+ assert!(pin_errors(&molecule_check(&board(&edge), &calm(json!({})), None)).is_empty());+ }++ #[test]+ fn pin_class_base_seat_needs_large() {+ let f = molecule_check(&board(BOARD), &calm(json!({"seat": "base"})), None);+ assert!(pin_errors(&f).iter().any(|e| e.contains("base scaffold")), "{:?}", f.lines);+ assert_eq!(f.pin_class["required"], "large");+ let f = molecule_check(&board(BOARD), &calm(json!({"seat": "board"})), None);+ assert!(f.errors.is_empty(), "{:?}", f.lines);+ }++ #[test]+ fn pin_class_current_on_a_pin_net_needs_large() {+ // J1 (a machine contact) is on VIN+ let mut spec = calm(json!({}));+ spec["loads"] = json!({"/VIN": {"amps": 4}});+ let f = molecule_check(&board(BOARD), &spec, None);+ assert!(pin_errors(&f).iter().any(|e| e.contains("net VIN (J1) carries 4 A")), "{:?}", f.lines);+ // a heavy load on a net no pin or contact carries does not count+ spec["loads"] = json!({"Net-(R1-Pad2)": {"amps": 10}});+ assert!(molecule_check(&board(BOARD), &spec, None).errors.is_empty());+ // the corner pins' own net+ spec["loads"] = json!({"GND": {"amps": 5}});+ assert!(pin_errors(&molecule_check(&board(BOARD), &spec, None)).iter().any(|e| e.contains("MP1")));+ // declared pin current+ let f = molecule_check(&board(BOARD), &calm(json!({"pinCurrentA": 4.2})), None);+ assert!(pin_errors(&f).iter().any(|e| e.contains("pinCurrentA")), "{:?}", f.lines);+ }++ #[test]+ fn pin_class_declared_mass() {+ let f = molecule_check(&board(BOARD), &calm(json!({"massG": 150})), None);+ assert!(pin_errors(&f).iter().any(|e| e.contains("150 g")), "{:?}", f.lines);+ assert!(molecule_check(&board(BOARD), &calm(json!({"massG": 100})), None).errors.is_empty());+ }++ #[test]+ fn pin_class_unknown_mass_warns_and_bom_mass_counts() {+ let spec = json!({"molecule": {"mechanicalLoads": []}});+ let f = molecule_check(&board(BOARD), &spec, None);+ assert!(f.errors.is_empty(), "{:?}", f.lines);+ assert!(f.warnings.iter().any(|w| w.contains("mass unknown") && w.contains("spec.molecule.massG")), "{:?}", f.lines);+ let heavy = "Ref,MPN,mass_g\nR1 R2,RC0402,60\nJ1,MC,1\n";+ let f = molecule_check_bom(&board(BOARD), &spec, None, Some(heavy));+ assert!(pin_errors(&f).iter().any(|e| e.contains("121.0 g") && e.contains("BOM column mass_g")), "{:?}", f.lines);+ let light = "Ref,MPN,weight_g\nR1 R2,RC0402,0.01\nJ1,MC,1\n";+ let f = molecule_check_bom(&board(BOARD), &spec, None, Some(light));+ assert!(f.errors.is_empty() && f.warnings.is_empty(), "{:?}", f.lines);+ let gap = "Ref,MPN,mass_g\nR1 R2,RC0402,0.01\nJ1,MC,\n";+ let f = molecule_check_bom(&board(BOARD), &spec, None, Some(gap));+ assert!(f.warnings.iter().any(|w| w.contains("mass unknown") && w.contains("1 row")), "{:?}", f.lines);+ // no mass column: still unknown+ let f = molecule_check_bom(&board(BOARD), &spec, None, Some("Ref,MPN\nR1,X\n"));+ assert!(f.warnings.iter().any(|w| w.contains("mass unknown")), "{:?}", f.lines);+ }++ #[test]+ fn pin_class_declared_motor_needs_large() {+ let f = molecule_check(&board(BOARD), &calm(json!({"mechanicalLoads": [{"ref": "M1", "kind": "motor"}]})), None);+ assert!(pin_errors(&f).iter().any(|e| e.contains("M1 is a motor")), "{:?}", f.lines);+ let f = molecule_check(&board(BOARD), &calm(json!({"mechanicalLoads": [{"ref": "J9", "kind": "cabled-connector"}]})), None);+ assert!(!pin_errors(&f).is_empty());+ }++ #[test]+ fn pin_class_heatsink_over_20_g() {+ let f = molecule_check(&board(BOARD), &calm(json!({"mechanicalLoads": [{"ref": "HS1", "kind": "heatsink", "massG": 15}]})), None);+ assert!(f.errors.is_empty() && f.warnings.is_empty(), "{:?}", f.lines);+ let f = molecule_check(&board(BOARD), &calm(json!({"mechanicalLoads": [{"ref": "HS1", "kind": "heatsink", "massG": 25}]})), None);+ assert!(pin_errors(&f).iter().any(|e| e.contains("HS1 is a 25 g heatsink")), "{:?}", f.lines);+ }++ #[test]+ fn pin_class_keyword_is_a_warning_until_declared() {+ let motor = BOARD.replacen(" (footprint \"Resistor_SMD", " (footprint \"Motor:Motor_DC_Small\" (layer \"F.Cu\") (at 116 90) (property \"Reference\" \"M1\") (property \"Value\" \"DC motor\"))\n (footprint \"Resistor_SMD", 1);+ let spec = json!({"molecule": {"massG": 40}});+ let f = molecule_check(&board(&motor), &spec, None);+ assert!(f.errors.is_empty(), "{:?}", f.lines);+ assert!(f.warnings.iter().any(|w| w.contains("M1 (motor)") && w.contains("mechanicalLoads")), "{:?}", f.lines);+ // confirmed none: quiet+ let f = molecule_check(&board(&motor), &calm(json!({})), None);+ assert!(f.errors.is_empty() && f.warnings.is_empty(), "{:?}", f.lines);+ // declared: large required+ let f = molecule_check(&board(&motor), &calm(json!({"mechanicalLoads": [{"ref": "M1", "kind": "motor"}]})), None);+ assert!(!pin_errors(&f).is_empty());+ // a BOM description hints too+ let bom = "Ref,MPN,Description\nJ2,PJ-002A,DC Barrel Jack 2.1 mm\nU1,USBLC6-2SC6,ESD protection\n";+ let f = molecule_check_bom(&board(BOARD), &spec, None, Some(bom));+ assert!(f.warnings.iter().any(|w| w.contains("J2 (barrel jack)") && !w.contains("U1")), "{:?}", f.lines);+ }++ fn large_board() -> String {+ BOARD.replace("MachinePinMediumShort", "MachinePinLargeShort")+ .replace("size 1.6 1.6", "size 5.2 5.2").replace("drill 1.2", "drill 3.45")+ .replace("124", "132").replace(" 84", " 68").replace("(start 98 82)", "(start 96 64)").replace("(end 126 102)", "(end 136 104)")+ }++ #[test]+ fn pin_class_large_pins_with_no_reason_pass() {+ let f = molecule_check(&board(&large_board()), &calm(json!({"seat": "board"})), None);+ assert!(f.errors.is_empty(), "{:?}", f.lines);+ assert_eq!(f.pin_class["required"], "medium");+ assert_eq!(f.pin_class["pins"], "large");+ assert!(f.lines.iter().any(|l| l.contains("Large is allowed")), "{:?}", f.lines);+ // large pins pass the existing pad/drill checks (5.2 mm pad, 3.45 mm drill) and the LrgMed 8 mm rule stays off+ assert!(f.lines.iter().any(|l| l.contains("MP1") && l.contains("5.2 mm pad, 3.45 mm drill")), "{:?}", f.lines);+ let f = molecule_check(&board(&large_board()), &calm(json!({"seat": "base", "massG": 300})), None);+ assert!(f.errors.is_empty(), "{:?}", f.lines);+ assert!(f.lines.iter().any(|l| l.contains("large corner pins, as needed")), "{:?}", f.lines);+ // LargeStandard is a large pin too+ let std = large_board().replace("MachinePinLargeShort", "MachinePinLargeStandard");+ assert!(molecule_check(&board(&std), &calm(json!({"seat": "base"})), None).errors.is_empty());+ }+ fn netlist(r2_pin2: &str, extra: &str) -> String { format!(r#"(export (version "E") (components (comp (ref "MP1")) (comp (ref "MP2")) (comp (ref "MP3")) (comp (ref "MP4")) (comp (ref "J1")) (comp (ref "R1") (footprint "Resistor_SMD:R_0402_1005Metric")) (comp (ref "R2")) {extra})
crates/adom-aiflow/src/main.rs+17−6@@ -110,8 +110,9 @@ enum Cmd { /// `netlist check`: export the schematic's netlist with kicad-cli (ADOM_AIFLOW_KICAD_CLI) and compare it pin by pin with the board's pads; OK only when equal. In a run it defaults to the run's schematic and current board and records the outcome `finish` requires. Netlist { #[arg(value_parser = ["check"])] action: String, #[arg(long)] sch: Option<String>, #[arg(long)] board: Option<String>, /// an already exported netlist (kicadsexpr) instead of exporting one #[arg(long)] net: Option<String> },- /// `molecule check`: Adom molecule conformance (MP1 to MP4 machine pins, contacts on the grid from MP1, edge margin, fixed references) from spec.molecule; `place check`, `gate` and `finish` run it too when the spec has "molecule".- Molecule { #[arg(value_parser = ["check"])] action: String, #[arg(long)] board: Option<String>, #[arg(long)] spec: Option<String> },+ /// `molecule check`: Adom molecule conformance (MP1 to MP4 machine pins, contacts on the grid from MP1, edge margin, fixed references) and the corner-pin class (large pins when the board seats in the base scaffold, weighs over 100 g, carries a motor/actuator/heavy heatsink/cabled connector/hard-pressed button, is over 100 mm long or a pin carries over 3.5 A; issue #87) from spec.molecule; `place check`, `gate` and `finish` run it too when the spec has "molecule".+ Molecule { #[arg(value_parser = ["check"])] action: String, #[arg(long)] board: Option<String>, #[arg(long)] spec: Option<String>, /// a BOM CSV whose mass_g / weight_g column gives the board's mass for the corner-pin class (default in a run: the BOM sourcing check or parts passed)+ #[arg(long)] bom: Option<String> }, /// `rules use --profile 3rdPartyFab|<slug>`: the design-rules step. Loads a fab's rule pack from the wiki page adom/pcb-design-rules (3rdPartyFab is the default; `rules list` shows them all): the KiCad .kicad_dru beside the board and in the run, the Fusion DRC rules into the open design, the choice into the spec and the run. `rules install --profile jlcpcb --layers 2`: put the shipped JLCPCB 2-layer .kicad_dru beside the board (the only shipped pack; other layer counts come from `rules use`); `rules check`: does the board's project carry the rules profile spec.fab.rules names, else run.json fabRules ("fab" = the project's own .kicad_dru, "jlcpcb" = the JLCPCB pack for the board's copper layer count, "jlcpcb-6L" = that count exactly). /// `testplan check --tests tests.json`: the test-plan step. Every measurement the AI plans for the probing workcell gets a probe spot: before the board, a test point on its net in the netlist (`--netlist <netlist.json> --add` adds TP<n> for each gap, 0.5 mm round pads from adom/testpoint-pad-0-5mm); with a board, a real 0.5 mm landing (test point or exposed pad) clear of part bodies and other nets, within 50 mm of a ground contact. finish runs the board check again. /// `thermal-vias plan`: the heatsink-via step. For every part in spec.hot ({REF: {watts, tabNet}}) a via target from its dissipation and the rise budget (at least 6), and the spots beside its heat pads, clear of other nets, the edge and every pad; existing vias count. `thermal-vias land` lands the plan one via at a time; `--ack REF --why` accepts a part short of its target. thermal and finish refuse a hot part below target.@@ -2250,7 +2251,15 @@ fn first_board(r: &Run) -> Option<Board> { fn molecule_findings(spec: &Value, board: &Board, r: Option<&Run>) -> Option<checks::Findings> { if spec.get("molecule").is_none() { return None; } let before = r.and_then(first_board);- Some(checks::molecule_check(board, spec, before.as_ref()))+ let bom = r.and_then(run_bom).and_then(|p| std::fs::read_to_string(p).ok());+ Some(checks::molecule_check_bom(board, spec, before.as_ref(), bom.as_deref()))+}++/// The run's BOM: the one `sourcing check` passed on, else the one `parts` used (issue #87 reads its+/// mass column for the corner-pin class).+fn run_bom(r: &Run) -> Option<String> {+ let o = r.outcomes();+ ["sourcing", "parts"].iter().find_map(|k| o.get(*k).and_then(|v| v["bom"].as_str()).filter(|p| Path::new(p).is_file()).map(str::to_string)) } /// Why the run's schematic and current board are not proven equal, or Ok when they are: the@@ -4503,7 +4512,7 @@ fn main() { } ok(&lines.join("\n"), &["This is electrical parity between schematic and board only: not ERC, DRC, courtyards, pours or fabrication readiness; never tell the human the board can go to the fab on this result.".into(), if run.is_some() { "Recorded in the run; finish re-checks the stored netlist against the board it finishes on.".into() } else { "No run here: nothing recorded. Start the run with --sch so finish requires this check.".into() }, fp_hint]); }- Cmd::Molecule { action: _, board, spec } => {+ Cmd::Molecule { action: _, board, spec, bom } => { let mut run = Run::open(&dir); if run.is_some() && board.is_none() { thread(&cli); } let spec_v = match spec { Some(p) => read_json(p), None => run.as_ref().map(spec_of).unwrap_or_else(|| err("molecule check needs --spec <spec.json> outside a run", &[])) };@@ -4511,9 +4520,11 @@ fn main() { let board_path = board.clone().or_else(|| run.as_ref().map(|r| r.current_board())).unwrap_or_else(|| err("molecule check needs --board <file.kicad_pcb> outside a run", &[])); let b = Board::load(&board_path).unwrap_or_else(|e| err(&e, &[])); let before = if board.is_none() { run.as_ref().and_then(first_board) } else { None };- let f = checks::molecule_check(&b, &spec_v, before.as_ref());+ let bom_path = bom.clone().or_else(|| run.as_ref().and_then(run_bom));+ let bom_text = bom_path.as_ref().map(|p| std::fs::read_to_string(p).unwrap_or_else(|e| err(&format!("{p}: {e}"), &[])));+ let f = checks::molecule_check_bom(&b, &spec_v, before.as_ref(), bom_text.as_deref()); if let (Some(r), None) = (run.as_mut(), board) {- r.outcome("molecule", json!({"ok": f.errors.is_empty(), "board": board_path, "errors": f.errors, "warnings": f.warnings}));+ r.outcome("molecule", json!({"ok": f.errors.is_empty(), "board": board_path, "errors": f.errors, "warnings": f.warnings, "pinClass": f.pin_class, "bom": bom_path})); r.save().unwrap_or_else(|e| err(&e, &[])); } let text = format!("molecule check on {board_path}: {} error(s), {} warning(s)\n{}", f.errors.len(), f.warnings.len(), f.lines.join("\n"));
package.json+1−1@@ -1,7 +1,7 @@ { "slug": "adom-aiflow", "type": "app",- "version": "0.2.16",+ "version": "0.2.17", "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.16",+ "version": "0.2.17", "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+1−1@@ -300,7 +300,7 @@ No recording may outlive an hour (a hard cap on every recording), `finish` and ` Deterministic checks that refuse, each an OK/ERROR with hints: - **`netlist check [--sch design.kicad_sch] [--board B] [--net exported.net]`**: exports the schematic's netlist with kicad-cli (`sch export netlist --format kicadsexpr`, through `ADOM_AIFLOW_KICAD_CLI`; `adom-aiflow-kicad-cli-remote` does it on a desktop's KiCad 10 and carries the sub-sheets, lib tables and `${KIPRJMOD}` symbol libraries) and compares it pin by pin with the board's pads: missing and extra references, and every pin whose net differs (`/VIN` equals `VIN`; unlabelled `Net-(...)` nets compare by their pins). In a run it is recorded; `finish` re-checks the stored netlist against the board it finishes on and asks again when the schematic changed.-- **`molecule check [--board B --spec S]`**, and inside `place check`, `gate` and `finish` whenever the spec has `"molecule": {...}`: MP1 to MP4 are MachinePin footprints (MediumShort 1.6 mm pad / 1.2 mm drill, or large 5.2 / 3.45), MP1 is the front-left pin, the four sit on a rectangle whose sides are whole grid steps (a warning when not 4 mm multiples, which a LrgMed scaffold needs), every MachineContact (medium 1.3 / 0.78) sits on the grid from MP1, pins and contacts keep the edge margin, `molecule.fixed` positions hold, and `fixedRefs` are where the start board had them. Spec: `"molecule": {"gridMm": 2, "edgeMarginMm": 2, "cornerPinsNet": "GND", "contacts": [...], "fixed": {"J1": [0, 12]}}` (mm from MP1, y up; all optional).+- **`molecule check [--board B --spec S]`**, and inside `place check`, `gate` and `finish` whenever the spec has `"molecule": {...}`: MP1 to MP4 are MachinePin footprints (MediumShort 1.6 mm pad / 1.2 mm drill, or large 5.2 / 3.45), MP1 is the front-left pin, the four sit on a rectangle whose sides are whole grid steps (a warning when not 4 mm multiples, which a LrgMed scaffold needs), every MachineContact (medium 1.3 / 0.78) sits on the grid from MP1, pins and contacts keep the edge margin, `molecule.fixed` positions hold, and `fixedRefs` are where the start board had them. Corner-pin class (0.2.17, issue #87): an error when the pins are medium but the board needs large ones (seats in the base scaffold, over 100 g with its parts, a motor, actuator, heatsink over 20 g, cabled connector or hard-pressed button, longest side over 100 mm, or a pin net over 3.5 A in `spec.loads`); a warning when the mass is unknown or a part looks like an undeclared mechanical load; large pins with no reason are fine. The rule, its data and the spec fields are in skill aiflow-molecule. Spec: `"molecule": {"gridMm": 2, "edgeMarginMm": 2, "cornerPinsNet": "GND", "contacts": [...], "fixed": {"J1": [0, 12]}, "seat": "base|lrgmed|board", "massG": 42, "mechanicalLoads": [{"ref": "M1", "kind": "motor"}], "pinCurrentA": 1.2}` (mm from MP1, y up; all optional); `--bom <csv>` (default: the run's BOM) gives a `mass_g` column. - **Fab rules**: `"fab": {"rules": "fab" | "jlcpcb"}` in the spec. `fab` (the 3rd party fab) requires the project's own `.kicad_dru` beside the board; those rules are private and never shipped or restated. `jlcpcb` requires the JLCPCB pack for the board's copper layer count: on 2 layers the shipped profile, installed with `rules install --profile jlcpcb --layers 2` (JLCPCB's published 2-layer capabilities, cited in the file; `--force` replaces another rules file, keeps it as `.kicad_dru.bak` and says what it replaced); on 4, 6 or more layers `rules use --profile jlcpcb --layers <n>` loads the pack from adom/pcb-design-rules (`rules install` refuses a layer count it does not ship). A layer suffix pins the count: `"rules": "jlcpcb-6L"`. When the spec has no `fab.rules`, the gate, finish and `rules check` use what `rules use` recorded in run.json `fabRules` (an intake run has no spec yet), and warn when the spec and `fabRules` disagree. `rules check` says whether the board matches; the gate and finish refuse when it does not. - **3D models right way up**: `models` (and `models --offline`, no bridge) takes every bound model's bbox from `step2glb features` on the shared service (paced, cached per file in the run; the STEP file's own points when the service gives nothing), applies the footprint's rotate/offset as KiCad does, and warns when a flat SMD chip's thinnest axis is not Z or an SMD body's bottom is off the board. Fix the binding and look in the native 3D viewer; when a model is right as it is, `models --ack R1,C2 --why "<what you saw>"`. `finish` refuses while any warning is open. Never adom-chipsmith. - **Pads are real copper in every board GLB**: after `step2glb convert`, `board-glb copper --glb <kicad.glb> --board <design>.kicad_pcb --out <board>.glb [--finish copper|enig|hasl] [--paste none|solder]` makes them so by construction from the footprints' layers (pad/via/track meshes by KiCad layer name get a copper material with explicit metal and roughness; the mask is rebuilt as the outline minus every F.Mask/B.Mask aperture; solder only on F.Paste/B.Paste apertures of fitted parts; test points stay bare copper), then runs the gate. `board-glb check` alone refuses grey or default-metal pads, a mask over any exposed pad centre, solder on a pad without paste, or a board file whose pads do not line up with the GLB. A board file with no pads and no F.Mask/B.Mask apertures has no exposed copper: the GLB is still written (substrate and silkscreen opaque, mask whole) and the gate passes with the note "no exposed copper on this board". In a run the result is recorded (`boardGlb`). Rule and details: aiflow-molecule. Never hand-recolour a material index or lift pads through the mask. Publish only the `board-glb copper` output, never the raw step2glb GLB.
skills/aiflow-molecule/SKILL.md+43−6@@ -1,7 +1,7 @@ --- name: aiflow-molecule description: >-- Make an adom-aiflow board an Adom molecule that fits the scaffold: the 2 mm grid, the origin at the MP1 (front-left) machine pin, MachinePinMediumShort (1.6 mm pad, 1.2 mm drill) at the corners as MP1 to MP4, MachineContactMedium (1.3 mm pad, 0.78 mm drill) for the signal contacts, footprints from the Adom KiCad Library 1.2.3, the fab-rules profile (3rd party fab default, jlcpcb optional), then STEP export, `step2glb convert --molecule` with its stats gates, real copper pads (`adom-aiflow board-glb copper`: pads and test points are copper, the mask opens and paste shows only where the footprint layers say, with a gate), and molecule-publish. Medium-pin molecules mount on a LrgMed user scaffold (8 mm medium contact grid, offset 4 mm from its front-left large pin, large pins on a 32 mm base grid). Trigger words: aiflow molecule, make it a molecule, fits our scaffold, machine pins, MachinePinMediumShort, MachineContactMedium, MP1, molecule grid, molecule outline, fab rules profile, kicad_dru, molecule conformance, molecule export, step2glb molecule, publish molecule, LrgMed scaffold, copper pads, grey pads, test points, solder mask openings, paste layer, board GLB, board-glb.+ Make an adom-aiflow board an Adom molecule that fits the scaffold: the 2 mm grid, the origin at the MP1 (front-left) machine pin, the corner-pin class rule (large MachinePinLargeShort when the board seats in the base scaffold, weighs over 100 g, carries a motor, actuator, heatsink over 20 g, cabled connector or hard-pressed button, is over 100 mm long or a pin carries over 3.5 A; otherwise MachinePinMediumShort, 1.6 mm pad, 1.2 mm drill) at the corners as MP1 to MP4, MachineContactMedium (1.3 mm pad, 0.78 mm drill) for the signal contacts, footprints from the Adom KiCad Library 1.2.3, the fab-rules profile (3rd party fab default, jlcpcb optional), then STEP export, `step2glb convert --molecule` with its stats gates, real copper pads (`adom-aiflow board-glb copper`: pads and test points are copper, the mask opens and paste shows only where the footprint layers say, with a gate), and molecule-publish. Medium-pin molecules mount on a LrgMed user scaffold (8 mm medium contact grid, offset 4 mm from its front-left large pin, large pins on a 32 mm base grid). Trigger words: aiflow molecule, make it a molecule, fits our scaffold, machine pins, large or medium pins, MachinePinLargeShort, MachinePinMediumShort, MachineContactMedium, MP1, molecule grid, molecule outline, fab rules profile, kicad_dru, molecule conformance, molecule export, step2glb molecule, publish molecule, LrgMed scaffold, copper pads, grey pads, test points, solder mask openings, paste layer, board GLB, board-glb. --- # aiflow-molecule: a board that drops into the scaffold@@ -10,11 +10,47 @@ A molecule is a small board whose machine pins seat it on a scaffold and whose c ## Select the corner-pin class before placement -Do not always default to medium pins. Use large pins for larger or heavier assemblies and for equipment intended for frequent workcell insertion/extraction. Record the decision and its basis (board dimensions, cooler mass/load path and expected handling) in the design requirements. This is a qualitative Adom selection policy, not a verified insertion-cycle rating; do not invent numerical size, mass or lifetime thresholds.+Choose the corner pins at intake, before placement, with this rule (issue #87, approved by John 2026-10-21). Use LARGE pins (`MachinePinLargeShort`, or `MachinePinLargeStandard` on LrgMed user-scaffold corners per base-scaffold-system) if ANY of:++1. The board seats directly in the base scaffold (its 32 mm grid is large contacts).+2. The board with its parts weighs more than 100 g (4 medium contacts hold about 640 g of pull-out).+3. It carries anything that pushes, pulls or twists it: a motor or actuator, a heatsink over 20 g, a cabled connector people unplug (barrel jack, USB), or a button pressed hard.+4. Its longest side is over 100 mm.+5. Any pin carries more than 3.5 A.++Otherwise use MEDIUM pins (`MachinePinMediumShort`): small boards that sit in another board or a LrgMed user scaffold. Do not invent other thresholds.++| | Medium | Large |+|---|---|---|+| Tail / shaft | 0.51 / 1.2 mm diameter | 1.85 / 3.6 mm diameter (about 27 times the bending stiffness) |+| Current class rating | 3.5 A | 20.8 A |+| Retention per contact | about 160 to 180 g extraction | about 210 to 250 g extraction |+| Seats in | medium contacts (a board or a LrgMed user scaffold) | large contacts (the base scaffold grid, or LrgMed corners) |++Retention figures are Mill-Max standard clips at the same tail diameters; our HXH contacts have not been measured yet, so a pull test should replace them.++Declare the inputs in the spec so `molecule check` can apply the rule:++```json+"molecule": {+ "seat": "lrgmed",+ "massG": 42,+ "mechanicalLoads": [{"ref": "M1", "kind": "motor"}, {"ref": "HS1", "kind": "heatsink", "massG": 25}],+ "pinCurrentA": 1.2+}+```++- `seat`: where the board seats: `"base"` (directly in the base scaffold: large required), `"lrgmed"` (on a LrgMed user scaffold's medium contacts) or `"board"` (in another board).+- `massG`: the board with its parts, in grams. When it is missing, the check reads a `mass_g` or `weight_g` column of the run's BOM (the one `sourcing check` or `parts` used, or `molecule check --bom <csv>`; per part, times the references on the row; the bare board counts only if the BOM lists it). When neither gives a mass, the check warns and names `spec.molecule.massG`; it never guesses a mass.+- `mechanicalLoads`: every part that pushes, pulls or twists the board, `{ref, kind, massG?}` with kind `motor`, `actuator`, `heatsink`, `cabled-connector` or `button`. A heatsink counts only when its `massG` is over 20. Footprint names, values and BOM text that mention a motor, actuator, barrel jack, USB connector or heatsink are a hint only: undeclared, they are a warning; declare them, or confirm medium is fine with `"mechanicalLoads": []`.+- Pin current: a pin's current is taken from the loaded nets it is on: the highest `spec.loads` current (`amps`, the shape `analyze current` reads) on a net that a machine pin or machine contact carries, the whole net's current with no sharing between pins (conservative). `pinCurrentA` declares the reviewed per-pin current and, when present, governs instead: declare it when several pins share a net's current (four GND corner pins sharing a 5 A return carry about 1.25 A each). Over 3.5 A requires large pins.+- Size: the longest side of the outline's bounding box (the outer Edge.Cuts loop).++`molecule check` errors when large pins are required and the corner pins are medium, naming every reason and the fix; large pins with no reason are allowed (an OK line). The decision is recorded in the run's molecule outcome as `pinClass` (`required`, `pins`, `reasons`, `notes`, `seat`, `longestSideMm`, `massG`, `massSource`, `pinCurrentA`). Large pins mate directly to the base scaffold on a 32 mm grid. Medium pins require the chosen LrgMed scaffold's actual 8 mm contact grid and available span. Check all four sockets, seating height, pin model, drill/pad dimensions and outline clearance. A large-pin replacement is not a footprint-name substitution: update schematic/library/BOM, layout, models, fixture and electrical checks. Use the component page's engineering assets, not its animated inspection hero. Keep a compatible free medium GND contact for the probe tether when large corner heads are not verified compatible. -The medium-pin dimensions and example below apply only when medium pins were selected. Large-pin dimensions must come from the selected component assets; record unresolved conflicts between documentation and model geometry before claiming mechanical fit.+The medium-pin dimensions and example below apply only when medium pins were selected. Large pins are 5.2 mm pad / 3.45 mm drill in the Adom KiCad Library (the check's expected sizes); confirm them against the selected component assets and record unresolved conflicts between documentation and model geometry before claiming mechanical fit. ## 1. The rules @@ -22,7 +58,7 @@ The medium-pin dimensions and example below apply only when medium pins were sel |---|---| | Grid | 2 mm for every pin and contact, measured from MP1 | | Origin | the centre of MP1, the front-left (FL) corner pin; aux and grid origin on it; y up in the design frame, y down in KiCad |-| Corner pins | MP1 (FL), MP2 (FR), MP3 (BL), MP4 (BR), footprint `MachinePinMediumShort`: 1.6 mm pad, 1.2 mm drill, net GND |+| Corner pins | MP1 (FL), MP2 (FR), MP3 (BL), MP4 (BR), net GND; the class from the rule above: `MachinePinMediumShort` (1.6 mm pad, 1.2 mm drill) or `MachinePinLargeShort` (5.2 mm pad, 3.45 mm drill; `MachinePinLargeStandard` on LrgMed user-scaffold corners) | | Contacts | footprint `MachineContactMedium`: 1.3 mm pad, 0.78 mm drill, one per signal the Control Panel wires to | | Library | the Adom KiCad Library 1.2.3 (KiCad plugin manager); copy the footprints and their STEP models into the project (`lib/Molecule.pretty`, `3d/`) so the project travels | | Markers | MP1 to MP4 are the anchoring markers the step2glb molecule conversion reads; keep those exact references and the library's 3D models on them |@@ -54,9 +90,10 @@ The DRC gate uses the board's own `.kicad_dru`, so the fab's limits must be in i `adom-aiflow molecule check --board <design>.kicad_pcb --spec aiflow/spec.json` (in a run: `molecule check`; `place check`, `gate` and `finish` run it by themselves when the spec has `"molecule": {...}`). Put the interface in the spec: `"molecule": {"gridMm": 2, "edgeMarginMm": 2, "cornerPinsNet": "GND", "fixed": {"J1": [0, 12], ...}}` (mm from MP1, y up). It checks: -- MP1 to MP4 present, references exact, `MachinePinMediumShort`, at the four corners of the span, all on GND.+- MP1 to MP4 present, references exact, one MachinePin class (medium or large, with its pad and drill), at the four corners of the span, all on GND.+- The corner-pin class: large pins when any large-pin reason applies (seat, mass, mechanical loads, size, pin current; see above), else a warning for an unknown mass or an undeclared part that looks like a mechanical load. - Every contact is `MachineContactMedium`, on the 2 mm grid from MP1.-- Pin span on 8 mm multiples; outline = span + margin with rounded corners (radius = margin, centred on the corner pins); board origin on MP1.+- Pin span on 8 mm multiples with medium pins (LrgMed), 32 mm multiples with large pins (base scaffold); outline = span + margin with rounded corners (radius = margin, centred on the corner pins); board origin on MP1. - All interface parts locked and in `fixedRefs`. - Every pin and contact has its STEP model bound (`adom-aiflow models`).