← Commit history

AI Flow 0.1.61: silkscreen the fab would clip blocks gate and finish; board-glb clips silk at mask openings and its check refuses an unclipped render

John Lauer ·b3629def61 · ·parent 2071ef0
5 files changed +67−15
SKILL.md+5
@@ -123,6 +123,11 @@ The clock starts at the prompt even when there is no board yet. - **`evidence add --kind calcs|simulation|vendor-model --file <json|md> [--note ...]`** records the design record in the run and the ledger with its sha256 (a copy lands in `<run>/evidence/`). A simulation result that says it fails (a top-level `"pass": false`, or a `"checks"` row `{"name","value","limit","pass"}` that fails) is refused. Vendor PSpice/LTspice runs belong to the separately published adom/adom-spice-skillpack; `--kind vendor-model` records their result. - **`finish`** also refuses without a passed sourcing check against the board when the spec or the requirements name a sourcing profile, and without the evidence kinds the spec lists in `"requireEvidence": ["calcs", "simulation"]`. +**Silkscreen the fab would clip blocks the board (0.1.61).** KiCad grades `silk_over_copper`, `silk_overlap`, `silk_edge_clearance`, `text_height` and `text_thickness` as warnings by default.+- `gate` and `finish` treat them as blocking, like the other manufacturing warnings. `finish` runs KiCad's full DRC on the live board for this.+- Ink over a mask opening is clipped off by the fab: the text prints broken and the pad carries ink at its edge. The fix is to move the text, or the pad, never to downgrade the rule.+- KiCad's STEP export keeps the whole ink. So `board-glb copper` lays the exposed copper over the ink inside every mask opening, the way the fab prints it, and `board-glb check` fails a GLB whose silkscreen is not clipped.+ ## The fields step (adom-fields)  After the pours and the analyses: `adom-fields analyze --board <the current board> --spec <spec> --out fields` (about two minutes; `adom-wiki pkg install adom/adom-fields` if it is missing), `adom-fields serve --fields fields --port 8874 &`, then `step fields` and `tour fields`: the binary opens the app in a pup window on the test box, films that window while `adom-fields tour` walks the board gently (the app paces itself: slow eased moves about nine seconds long, the board drifting a few degrees through every held beat, one copper pour isolated at a time, a quick turn-over to the bottom layer, because the flow plays this clip back at ten times speed, where a snap reads as frenetic) (each net lit, the hot parts, the issues flown to; the app's toasts are the captions), and records the issues in the run. When the Fields URL is a localhost proxy (an Adom Hydrogen desktop container's `VSCODE_PROXY_URI` is `http://{{port}}.localhost:<port>/`, reachable only on the machine hosting the container), the window opens on the user's primary machine (`adom-bridge targets` names it) instead of the test box, and the answer says so; `--on <box>` overrides. The issues (a current neck, a tab over its rise budget, a chip missing its thermal vias) are yours to answer before delivery; the app's screenshots are drawings you can register with `artifact`.
crates/adom-aiflow/src/main.rs+13−10
@@ -2990,6 +2990,16 @@ fn main() {                 if live["unconnected"].as_u64() != Some(0) || live["errors"].as_u64().unwrap_or(u64::MAX) > inherited {                     missing.push(format!("live validate: {} unconnected, {} errors (inherited {inherited})", live["unconnected"], live["errors"]));                 }+                // KiCad's full DRC on the live board, warnings included: the silkscreen findings the fab makes real+                // (ink over a mask opening is clipped off, ink on ink, ink past the edge, text under the fab minimum)+                // block whatever severity the project gives them; the routing validate above counts errors only+                let drc = br.call("kicad_run_drc", &json!({"filePath": r.data["remoteBoard"].as_str().unwrap_or(""), "refillZones": true, "reason": "finish: full DRC, warnings included"}));+                let found: Vec<Value> = drc["summary"]["first"].as_array().or(drc["violations"].as_array()).cloned().unwrap_or_default();+                let silk: Vec<String> = found.iter().filter(|v| v["type"].as_str().map(|t| aiflow_copper::SILK_BLOCKING.contains(&t)).unwrap_or(false))+                    .map(|v| format!("{} ({})", v["type"].as_str().unwrap_or(""), v["items"].as_array().map(|a| a.iter().map(|i| i.as_str().map(str::to_string).unwrap_or_else(|| i["description"].as_str().unwrap_or("").to_string())).collect::<Vec<_>>().join(" / ")).unwrap_or_default())).collect();+                if !silk.is_empty() {+                    missing.push(format!("silkscreen the fab would clip or blur ({}): {}. Move the text (or the pad), never downgrade the rule (skill aiflow-silkscreen)", silk.len(), silk.iter().take(6).cloned().collect::<Vec<_>>().join("; ")));+                }             }             // the work before the board: the sourcing check when a profile is named, the evidence the spec asks for             let (spec_v, req_v) = (spec_soft(&r), requirements_of(&r));@@ -3446,12 +3456,8 @@ fn main() {             ok(&format!("{done} board corner(s) rounded to {rad} mm; the outline has no sharp corner"), &["Copper keeps its edge clearance from the new arcs: refill the pours (`land pours` again) if a pour followed the old corner.".into()]);         }         Cmd::Timeline { action, port } => {-            let mut r = load_run(&cli);+            let r = load_run(&cli);             let flow_full: Value = serde_json::from_str(include_str!("../../../flows/board.json")).unwrap();-            if r.data["steps"].as_object().map(|o| o.keys().all(|k| k == "intake")).unwrap_or(true) {-                let want = json!(sequence::sequence_of(&flow_full, sequence::lane(&dir, &r.data)));-                if r.data["sequence"] != want { r.data["sequence"] = want; r.save().unwrap(); }-            }             let seq: Vec<String> = r.data["sequence"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_else(|| sequence::sequence_of(&flow_full, sequence::lane(&dir, &r.data)));             let tl = sequence::timeline(&dir, &r.data, &flow_full, &seq, &r.step_report());             sequence::write_timeline(&dir, &tl, TIMELINE_PAGE);@@ -3941,11 +3947,8 @@ fn main() {             // the required steps, in order: a run that starts now carries its lane's sequence, and no step             // in it can be skipped (sequence.rs)             let flow_full: Value = serde_json::from_str(include_str!("../../../flows/board.json")).unwrap();-            // the lane can still change while only intake is done (the run learns it is a Fusion board from the-            // brief or the requirements written during intake)-            if r.data["steps"].as_object().map(|o| o.keys().all(|k| k == "intake")).unwrap_or(true) {-                let want = json!(sequence::sequence_of(&flow_full, sequence::lane(&dir, &r.data)));-                if r.data["sequence"] != want { r.data["sequence"] = want; }+            if r.data["sequence"].is_null() && r.data["steps"].as_object().map(|o| o.keys().all(|k| k == "intake")).unwrap_or(true) {+                r.data["sequence"] = json!(sequence::sequence_of(&flow_full, sequence::lane(&dir, &r.data)));             }             let seq: Vec<String> = r.data["sequence"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default();             if !seq.is_empty() && !*back {
crates/aiflow-copper/src/lib.rs+17−3
@@ -512,10 +512,16 @@ fn count_types(list: &[&Value]) -> BTreeMap<String, usize> {     m } +/// Silkscreen findings the fab makes real: ink over a mask opening is clipped off (the text prints broken, the pad+/// carries ink at its edge), ink on ink is unreadable, ink past the edge is cut, text under the fab's minimum blurs.+/// A render of the KiCad STEP shows the unclipped ink, a board no fab makes. Blocking whatever the project's severity.+pub const SILK_BLOCKING: &[&str] = &["silk_over_copper", "silk_overlap", "silk_edge_clearance", "text_height", "text_thickness"];+ // Manufacturing constraints remain blocking even when a project downgrades them to warnings.-fn blocking_violation(v: &Value) -> bool {-    v["severity"] == "error" || (v["severity"] == "warning" && matches!(v["type"].as_str(),-        Some("hole_to_hole" | "holes_co_located" | "drill_out_of_range" | "annular_width" | "track_width" | "clearance" | "copper_edge_clearance" | "courtyards_overlap" | "courtyard_overlap")))+pub fn blocking_violation(v: &Value) -> bool {+    v["severity"] == "error" || (v["severity"] == "warning" && (matches!(v["type"].as_str(),+        Some("hole_to_hole" | "holes_co_located" | "drill_out_of_range" | "annular_width" | "track_width" | "clearance" | "copper_edge_clearance" | "courtyards_overlap" | "courtyard_overlap"))+        || v["type"].as_str().map(|t| SILK_BLOCKING.contains(&t)).unwrap_or(false))) }  /// The gate counts errors and manufacturing warnings, matching inherited findings by identity.@@ -569,4 +575,12 @@ mod gate_tests {         let moved=json!({"violations":[{"type":"hole_to_hole","severity":"warning","description":"too close","items":[{"pos":{"x":2,"y":2}}]}]});         assert_eq!(summarize(&candidate,Some(&moved))["newErrors"],1);     }+    #[test]+    fn silkscreen_the_fab_would_clip_blocks() {+        // a test pad placed under a part's value text: KiCad grades it a warning, the fab clips the ink+        let over = json!({"type":"silk_over_copper","severity":"warning","description":"Silkscreen clipped by solder mask","items":[{"description":"PCB text '150k'"},{"description":"Pad 1 [VIN] of TP6"}]});+        let overlap = json!({"type":"silk_overlap","severity":"warning","description":"Silkscreen clearance"});+        let library = json!({"type":"lib_footprint_issues","severity":"warning","description":"library not enabled"});+        assert_eq!(summarize(&json!({"violations":[over, overlap, library]}),None)["newErrors"],2);+    } }
package.json+1−1
@@ -86,4 +86,4 @@     "aiflow-simulate",     "aiflow-test-plan"   ]-}+}\ No newline at end of file
tools/board-glb-copper.py+31−1
@@ -460,7 +460,7 @@ def world_matrices(g):     return wm, parent  -LAYER_RE = re.compile(r'_(PCB|pad|pads|via|vias|copper|soldermask|silkscreen)(?:[-_.]\d+)?$', re.I)+LAYER_RE = re.compile(r'_(PCB|pad|pads|via|vias|copper|soldermask|silkscreen|silkclip)(?:[-_.]\d+)?$', re.I)   def board_meshes(g, stem):@@ -685,6 +685,32 @@ def copper(args):         g['meshes'].append({'name': f'{L["stem"]}_soldermask', 'primitives': prims, 'extras': {'adomBoardLayer': 'soldermask', 'adomGenerated': 'outline minus F.Mask/B.Mask apertures'}})         add_node_like(g, fr.pcb_node, len(g['meshes']) - 1, 'adom-aiflow soldermask') +    # 2b. silkscreen is clipped at every mask opening, as the fab clips it (KiCad's STEP export keeps the whole ink, so a+    # pad under a text renders with ink on bare copper: a board no fab makes). The STEP's silk is compressed and cannot be+    # cut here, so the exposed copper is laid again just above the ink inside each opening: what the eye sees is the+    # fab's result, bare copper with the ink stopping at the mask edge. KiCad DRC's silk_over_copper is still the design+    # error to fix (adom-aiflow gate and finish block it); this keeps the render honest while it is open.+    rep['silkClip'] = {}+    for mi_s, ni_s in layers.get('silkscreen', []):+        s_top = fr.z_in_pcb(mi_s, ni_s, 'max'); s_bot = fr.z_in_pcb(mi_s, ni_s, 'min')+        mid = (pcb_top + pcb_bot) / 2+        prims = []+        for s, up in (('F', True), ('B', False)):+            if up and s_top < mid: continue+            if not up and s_bot > mid: continue+            area = L['openings'][s].intersection(L['copper'][s])+            if area.is_empty: continue+            z = (s_top + 0.003e-3) if up else (s_bot - 0.003e-3)+            pos, nrm, idx = triangulate(area, fr, z, up, np.eye(4))+            if idx:+                pr = add_mesh_data(g, bin_, pos, nrm, idx); pr['material'] = cu_mat; prims.append(pr)+                rep['silkClip'][s] = round(area.area, 3)+        if prims:+            g['meshes'].append({'name': f'{L["stem"]}_silkclip', 'primitives': prims,+                                'extras': {'adomBoardLayer': 'silkclip', 'adomGenerated': 'exposed copper above the silkscreen inside every mask opening (the fab clips silk there)'}})+            add_node_like(g, fr.pcb_node, len(g['meshes']) - 1, 'adom-aiflow silk clip')+            rep['changes'].append('silkscreen: clipped at the mask openings (exposed copper laid over the ink, as the fab prints it)')+     # 3. solder where paste exists     n_paste = {'F': 0, 'B': 0}     if args.paste == 'solder':@@ -843,6 +869,10 @@ def check(args):             notes.append('no solder mask in the GLB')         if mask_cov:             notes.append(f'mask open at all {len(exposed)} exposed pad centres' if not covered else '')+    # silkscreen must stop at the mask openings, as the fab prints it+    if layers.get('silkscreen') and not layers.get('silkclip'):+        errors.append('the silkscreen is not clipped at the mask openings: ink over a test point or pad renders on bare copper, a board no fab makes')+        hints.append('Run adom-aiflow board-glb copper: it lays the exposed copper over the ink inside every mask opening. Then fix the design: KiCad DRC silk_over_copper (adom-aiflow gate and finish block it).')     bare = [p['id'] for p in exposed if not p['paste']]     rep = {'gate': 'adom-aiflow board-glb check', 'glb': str(args.glb), 'board': str(args.board), 'finish': fin,            'ok': not errors, 'errors': errors, 'notes': [n for n in notes if n], 'exposedPads': len(exposed), 'barePads': bare}