← Commit history

Merge PR #363: Large contacts take large corner pins unless the spec says why not

Colby Knox ·4b80dd9f2f · ·parent fed5f2f
2 files changed +120−11
crates/adom-aiflow/src/checks.rs+110−6
@@ -189,7 +189,7 @@ pub fn molecule_check_bom(b: &Board, spec: &Value, before: Option<&Board>, bom:     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 (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 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 (MachineContactLarge 4.4 mm pad / 2.62 mm drill for a power net over 3.5 A, which takes large corner pins); 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 {@@ -357,8 +357,16 @@ pub const LARGE_PIN_SIDE_MM: f64 = 100.0; 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;+/// The large pin and contact class rating: a net carried only by large parts is judged against this.+pub const LARGE_CLASS_CURRENT_A: f64 = 20.8; const MECH_KINDS: [&str; 5] = ["motor", "actuator", "heatsink", "cabled-connector", "button"]; +/// A large machine pin or contact (MachinePinLarge*, MachineContactLarge), by footprint name.+fn is_large_machine_part(lib: &str) -> bool {+    let name = lib.rsplit(':').next().unwrap_or(lib).to_ascii_lowercase();+    name.starts_with("machinepinlarge") || name == "machinecontactlarge"+}+ /// 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();@@ -409,7 +417,9 @@ pub fn bom_mass(bom: &str) -> Option<(f64, usize, String)> { /// 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).+/// its longest side is over 100 mm; a pin carries more than 3.5 A; it has a large contact+/// (MachineContactLarge), unless spec.molecule.largeContactMediumPins says why medium is right.+/// 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();@@ -442,12 +452,22 @@ fn pin_class_check(b: &Board, spec: &Value, m: &Value, bom: Option<&str>, f: &mu             if !e.contains(r) { e.push(r.clone()); }         }     }+    // a net whose every carrier is a large pin or contact is within the large class rating, not the+    // medium one: it says nothing about the corner pins (a 12 A rail on large contacts)+    let all_large = |refs: &[String]| refs.iter().all(|r| b.footprints.get(r).map(|fp| is_large_machine_part(&fp.lib)).unwrap_or(false));     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(", ")))); }+                let what = format!("net {} ({})", norm_net(net), refs.join(", "));+                if all_large(refs) {+                    if amps > LARGE_CLASS_CURRENT_A {+                        f.warn(format!("{what} carries {amps} A (spec.loads), over the large class's {LARGE_CLASS_CURRENT_A} A"), "Share that rail across more large contacts, or declare the reviewed per-contact current (skill aiflow-molecule, corner-pin class).");+                    } else { notes.push(format!("{what} carries {amps} A on large parts only (rated {LARGE_CLASS_CURRENT_A} A)")); }+                    continue;+                }+                if pin_amps.as_ref().map(|p| amps > p.0).unwrap_or(true) { pin_amps = Some((amps, what)); }             }         }     }@@ -535,12 +555,34 @@ fn pin_class_check(b: &Board, spec: &Value, m: &Value, bom: Option<&str>, f: &mu             notes.push(format!("{} confirmed as no mechanical load (not in spec.molecule.mechanicalLoads)", undeclared.join(", ")));         }     }+    // (6) a large contact: the board takes heavy current or a heavy wire, so its corners go large too,+    // unless spec.molecule.largeContactMediumPins says why medium corners are right for this board+    let large_contacts: Vec<String> = b.footprints.iter()+        .filter(|(_, fp)| fp.lib.rsplit(':').next().unwrap_or(&fp.lib).eq_ignore_ascii_case("machinecontactlarge"))+        .map(|(r, _)| r.clone()).collect();+    let keep_medium = match m.get("largeContactMediumPins") {+        Some(Value::String(s)) if !s.trim().is_empty() => Some(s.trim().to_string()),+        None | Some(Value::Null) => None,+        Some(_) => { f.warn("spec.molecule.largeContactMediumPins must be a sentence saying why medium corner pins are right with a large contact; it is ignored".into(), declare_hint); None }+    };+    let override_hint = "When medium corner pins are right for this board anyway (a light board in a LrgMed user scaffold, say), keep them on purpose: set spec.molecule.largeContactMediumPins to the reason (skill aiflow-molecule, corner-pin class).";+    let mut overridable = false;+    match (large_contacts.is_empty(), &keep_medium) {+        (false, None) => { reasons.push(format!("{} is a large contact (MachineContactLarge); a board with a large contact takes large corner pins", large_contacts.join(", "))); overridable = true; }+        (false, Some(why)) => notes.push(format!("large contact {}, medium corner pins kept on purpose (spec.molecule.largeContactMediumPins: {why})", large_contacts.join(", "))),+        (true, Some(_)) => f.warn("spec.molecule.largeContactMediumPins is set but the board has no MachineContactLarge".into(), "Remove it, or add the large contact it was written for."),+        (true, None) => {}+    }     // 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("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).");+            // only the large-contact reason can be overridden; say so when it is the only one+            if overridable && reasons.len() == 1 { f.hints.push(override_hint.to_string()); }+        }         ("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(", ") })),@@ -551,7 +593,8 @@ fn pin_class_check(b: &Board, spec: &Value, m: &Value, bom: Option<&str>, f: &mu     }     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())});+        "pinCurrentA": pin_amps.as_ref().map(|x| x.0), "pinCurrentFrom": pin_amps.as_ref().map(|x| x.1.clone()),+        "largeContacts": large_contacts, "largeContactMediumPins": keep_medium}); }  // ----------------------------------------------------------------------------- fab rules (#10)@@ -1031,6 +1074,67 @@ mod tests {             .replace("124", "132").replace(" 84", " 68").replace("(start 98 82)", "(start 96 64)").replace("(end 126 102)", "(end 136 104)")     } +    /// A MachineContactLarge J2 on VMOT, 6 mm right of J1 (on the 2 mm grid from MP1).+    fn with_large_contact(text: &str) -> String {+        text.replacen(" (footprint \"Resistor_SMD", " (footprint \"Molecule:MachineContactLarge\" (layer \"F.Cu\") (at 106 88) (property \"Reference\" \"J2\") (pad \"1\" thru_hole circle (at 0 0) (size 4.4 4.4) (drill 2.62) (layers \"*.Cu\") (net \"VMOT\")))\n (footprint \"Resistor_SMD", 1)+    }++    #[test]+    fn pin_class_large_contact_needs_large_corners() {+        let f = molecule_check(&board(&with_large_contact(BOARD)), &calm(json!({})), None);+        let e = pin_errors(&f);+        assert!(e.len() == 1 && e[0].contains("J2 is a large contact"), "{:?}", f.lines);+        assert!(f.hints.iter().any(|h| h.contains("largeContactMediumPins")), "{:?}", f.hints);+        assert_eq!(f.pin_class["required"], "large");+        assert_eq!(f.pin_class["largeContacts"], json!(["J2"]));+        // the contact itself passes the contact checks (4.4 mm pad, 2.62 mm drill, on the grid)+        assert!(f.lines.iter().any(|l| l.starts_with("OK") && l.contains("J2 MachineContactLarge")), "{:?}", f.lines);+        // large corners: as needed+        let f = molecule_check(&board(&with_large_contact(&large_board())), &calm(json!({"seat": "base"})), None);+        assert!(f.errors.is_empty(), "{:?}", f.lines);+        assert!(f.lines.iter().any(|l| l.contains("large corner pins, as needed") && l.contains("J2")), "{:?}", f.lines);+    }++    #[test]+    fn pin_class_large_contact_override_keeps_medium() {+        let why = "light 28 x 20 mm board in a LrgMed scaffold; J2 is only the 14 AWG motor feed";+        let mut spec = calm(json!({"largeContactMediumPins": why}));+        spec["loads"] = json!({"VMOT": {"amps": 12}});+        let f = molecule_check(&board(&with_large_contact(BOARD)), &spec, None);+        // 12 A on a net only the large contact carries is within the large rating, not a medium-pin reason+        assert!(f.errors.is_empty() && f.warnings.is_empty(), "{:?}", f.lines);+        assert_eq!(f.pin_class["required"], "medium");+        assert_eq!(f.pin_class["largeContactMediumPins"], why);+        assert!(f.pin_class["notes"].as_array().unwrap().iter().any(|n| n.as_str().unwrap().contains("kept on purpose")), "{}", f.pin_class);+        // the override covers the large contact only, never another reason+        let f = molecule_check(&board(&with_large_contact(BOARD)), &calm(json!({"largeContactMediumPins": why, "massG": 150})), None);+        let e = pin_errors(&f);+        assert!(e.len() == 1 && e[0].contains("150 g") && !e[0].contains("J2"), "{:?}", f.lines);+        // it has to say why+        let f = molecule_check(&board(&with_large_contact(BOARD)), &calm(json!({"largeContactMediumPins": true})), None);+        assert!(f.warnings.iter().any(|w| w.contains("must be a sentence")), "{:?}", f.lines);+        assert!(!pin_errors(&f).is_empty());+        // and it needs a large contact to apply to+        let f = molecule_check(&board(BOARD), &calm(json!({"largeContactMediumPins": why})), None);+        assert!(f.errors.is_empty() && f.warnings.iter().any(|w| w.contains("no MachineContactLarge")), "{:?}", f.lines);+    }++    #[test]+    fn pin_class_large_only_net_is_judged_against_the_large_rating() {+        let text = with_large_contact(&large_board());+        let mut spec = calm(json!({"seat": "base"}));+        spec["loads"] = json!({"VMOT": {"amps": 12}});+        let f = molecule_check(&board(&text), &spec, None);+        assert!(f.errors.is_empty() && f.warnings.is_empty(), "{:?}", f.lines);+        spec["loads"] = json!({"VMOT": {"amps": 25}});+        let f = molecule_check(&board(&text), &spec, None);+        assert!(f.warnings.iter().any(|w| w.contains("net VMOT (J2) carries 25 A") && w.contains("20.8 A")), "{:?}", f.lines);+        // a medium contact on the same heavy net still counts against the medium rating+        let shared = with_large_contact(BOARD).replace("(net \"VIN\")))", "(net \"VMOT\")))");+        let f = molecule_check(&board(&shared), &calm(json!({"largeContactMediumPins": "test"})).tap_loads(json!({"VMOT": {"amps": 12}})), None);+        assert!(pin_errors(&f).iter().any(|e| e.contains("net VMOT (J1, J2) carries 12 A")), "{:?}", f.lines);+    }+     #[test]     fn pin_class_large_pins_with_no_reason_pass() {         let f = molecule_check(&board(&large_board()), &calm(json!({"seat": "board"})), None);
skills/aiflow-molecule/SKILL.md+10−5
@@ -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, 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.+  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, a pin carries over 3.5 A, or it has a large contact unless spec.molecule.largeContactMediumPins says why medium is right; 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, MachineContactLarge (4.4 mm pad, 2.62 mm drill, 14 AWG solid wire) for a power net over 3.5 A, 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, MachineContactLarge, large contact, 14 AWG, high-current contact, 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@@ -17,9 +17,12 @@ Choose the corner pins at intake, before placement, with this rule (issue #87, a 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.+6. It has a large contact (`MachineContactLarge`), unless the spec overrides it (below).  Otherwise use MEDIUM pins (`MachinePinMediumShort`): small boards that sit in another board or a LrgMed user scaffold. Do not invent other thresholds. +**Large contacts.** A power net over 3.5 A comes onto the board through `MachineContactLarge` (4.4 mm pad, 2.62 mm drill), wired with 14 AWG solid wire, which carries well above the medium contact's rating (the large class is rated 20.8 A). A board that needs a large contact is a heavy-current board, so as a rule of thumb its corner pins go large too (rule 6). When medium corners are right anyway (a light board in a LrgMed user scaffold whose one large contact is a motor feed, say), keep them on purpose: set `spec.molecule.largeContactMediumPins` to a sentence saying why. The override covers rule 6 only; any other reason still needs large pins. A net carried only by large contacts and large pins is judged against the large class's 20.8 A, not the medium 3.5 A, so a 12 A rail on a large contact does not count against medium corners; a medium contact on the same net still does.+ | | Medium | Large | |---|---|---| | Tail / shaft | 0.51 / 1.2 mm diameter | 1.85 / 3.6 mm diameter (about 27 times the bending stiffness) |@@ -36,7 +39,8 @@ Declare the inputs in the spec so `molecule check` can apply the rule:   "seat": "lrgmed",   "massG": 42,   "mechanicalLoads": [{"ref": "M1", "kind": "motor"}, {"ref": "HS1", "kind": "heatsink", "massG": 25}],-  "pinCurrentA": 1.2+  "pinCurrentA": 1.2,+  "largeContactMediumPins": "light board in a LrgMed scaffold; J2 is only the motor feed" } ``` @@ -45,8 +49,9 @@ Declare the inputs in the spec so `molecule check` can apply the rule: - `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).+- `largeContactMediumPins`: only with a large contact on the board: the reason medium corner pins are kept. It must be a sentence (`true` is ignored with a warning), and it warns when the board has no large contact. -`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`).+`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`, `largeContacts`, `largeContactMediumPins`).  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. @@ -59,7 +64,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), 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 |+| Contacts | footprint `MachineContactMedium`: 1.3 mm pad, 0.78 mm drill, one per signal the Control Panel wires to; `MachineContactLarge` (4.4 mm pad, 2.62 mm drill, 14 AWG solid wire) for a power net over 3.5 A, which takes large corner pins (rule 6) | | 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 | | Outline | pin span plus an edge margin all round (24 x 16 mm span + 2 mm = 28 x 20 mm here), with **rounded corners**: an arc of radius = the edge margin, centred on each corner pin, so the edge stays the same distance from the pin all the way round (2 mm here; the 1.6 mm pin pad then clears the edge by 1.2 mm). Suggest this by default; never 45 degree chamfers, which read as unfinished. |@@ -92,7 +97,7 @@ The DRC gate uses the board's own `.kicad_dru`, so the fab's limits must be in i  - 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.+- Every contact is `MachineContactMedium` or `MachineContactLarge` with its pad and drill, on the 2 mm grid from MP1. A large contact requires large corner pins unless `largeContactMediumPins` gives the reason. - 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`).