← Commit history

Fusion lane (#74): finish reads the Fusion silkscreen proof; silkscreen keeps its draw layers visible; nameless smashed parts print as text; fusion schematic --layout (wired); thermal vias clear pad corners

John Lauer ·c227c5cb61 · ·parent 66fec80
3 files changed +84−6
crates/adom-aiflow/src/main.rs+14−1
@@ -3607,7 +3607,20 @@ fn main() {                 if !f.errors.is_empty() { missing.push(format!("molecule check ({})", f.errors.join("; "))); }             }             {-                let (labels, fps) = provenance::silk_labels(&board_now());+                let (mut labels, fps) = provenance::silk_labels(&board_now());+                // On the Fusion lane the board checked here is the KiCad copy converted from the Fusion .brd,+                // and the converter carries no silkscreen text (issue #74: 44 texts in the .brd, 0 in the copy),+                // so every Fusion board failed. There the count comes from `fusion silkscreen`'s own proof.+                if aiflow_bridge::eda::active().id() == "fusion" {+                    match std::fs::read_to_string(dir.join("proofs").join("silkscreen.json")).ok().and_then(|t| serde_json::from_str::<Value>(&t).ok()) {+                        Some(p) => {+                            labels = labels.max(p["data"]["labels"].as_u64().unwrap_or(0) as usize);+                            let failed = p["data"]["failed"].as_u64().unwrap_or(0);+                            if failed > 0 { missing.push(format!("silkscreen: {failed} step(s) of `adom-aiflow fusion silkscreen` failed; run it again (skill aiflow-silkscreen)")); }+                        }+                        None => missing.push("silkscreen: not drawn yet on the Fusion board; run `adom-aiflow fusion silkscreen` (skill aiflow-silkscreen)".to_string()),+                    }+                }                 if fps > 0 && (labels as f64) < provenance::MIN_SILK_PER_FOOTPRINT * fps as f64 {                     missing.push(format!("silkscreen: {labels} visible label(s) for {fps} footprints (finished boards carry at least {:.1} per footprint): references, values and contact labels belong on the silkscreen (skill aiflow-silkscreen)", provenance::MIN_SILK_PER_FOOTPRINT));                 }
crates/adom-aiflow/src/thermalvias.rs+3−1
@@ -127,7 +127,9 @@ pub fn plan_with_acknowledgements(spec: &Value, b: &Board, sw_nets: &[String], a             let on = b.outline.len();             if on > 0 && (!point_in(&b.outline, c) || (0..on).any(|k| seg_dist(c, b.outline[k], b.outline[(k + 1) % on]) < rr + edge)) { continue; }             // clear of other nets' pads and tracks, of every via, and of every part's pads (no via in a pad)-            if b.pads.iter().any(|q| (q.x - c.0).hypot(q.y - c.1) < q.half_long + rr + clearance) { continue; }+            // a pad reaches its corners, not just half its long side: the half_long circle let a via sit+            // 0.145 mm from a pad corner on a 0.15 mm board, which Fusion then refused (issue #74)+            if b.pads.iter().any(|q| (q.x - c.0).hypot(q.y - c.1) < q.half_long.hypot(q.half_short) + rr + clearance) { continue; }             if tracks.iter().any(|t| t.net != net && seg_dist(c, t.a, t.b) < t.width / 2.0 + rr + clearance) { continue; }             if vias.iter().chain([].iter()).any(|v| (v.x - c.0).hypot(v.y - c.1) < v.size / 2.0 + rr + clearance) { continue; }             if placed.iter().any(|q| (q.0 - c.0).hypot(q.1 - c.1) < size + 0.25) { continue; }
tools/fusion-project.py+67−4
@@ -795,6 +795,55 @@ def cmd_reference(a):  # ---------------------------------------------------------------- schematic +def wired_groups(layout, lib, by_ref, values, libname, fixed_refs):+    """A wired sheet from a hand layout: every part where the layout puts it and every net drawn as wires+    from pin to pin, so the sheet reads as a circuit rather than a list of parts with net names.+    layout: {"groups": [{"name", "text": [x, y]?, "parts": {ref: [x, y, rot]}, "nets": {net: [[point, ...], ...]},+    "labels": [[net, x, y]]}]}; a point is "REF.PIN" (pad or pin name) or [x, y] in mm. The fixed parts the+    seed drew are reached by coordinates (their stub ends). Points are checked: every "REF.PIN" must exist."""+    syms = {s.get('name'): s for s in lib.iter('symbol')}+    pins_of = {}  # deviceset -> {pad or pin name: (x, y)}+    for d in lib.iter('deviceset'):+        g = next(d.iter('gate')); s = syms[g.get('symbol')]+        pin_xy = {p.get('name'): (float(p.get('x')), float(p.get('y'))) for p in s.iter('pin')}+        m = dict(pin_xy)+        for dv in d.iter('device'):+            for c in dv.iter('connect'):+                for pad in c.get('pad', '').split(): m.setdefault(pad, pin_xy[c.get('pin')])+        pins_of[d.get('name').upper()] = m+    placed = {}+    for g in layout['groups']:+        for r, (x, y, rot) in g.get('parts', {}).items(): placed[r] = (float(x), float(y), rot or 'R0')+    def pt(p):+        if isinstance(p, (list, tuple)): return (float(p[0]), float(p[1]))+        r, pin = p.split('.', 1)+        if r not in placed: err('layout: %s is not placed (only the parts in "parts" can be reached by pin)' % p)+        x, y, rot = placed[r]+        px, py = pins_of[by_ref[r].upper()].get(pin) or err('layout: %s has no pin %s' % (by_ref[r], pin))+        k = int(rot.lstrip('MR') or 0) // 90 % 4+        for _ in range(k): px, py = -py, px+        if rot.startswith('M'): px = -px+        return (round(x + px, 3), round(y + py, 3))+    f = lambda q: '(%.3fmm %.3fmm)' % q+    missing = [r for r in by_ref if r not in placed and r not in fixed_refs]+    if missing: err('layout: no place for %s' % ', '.join(missing), ['Every part that is not fixed needs an entry in a group\'s "parts".'])+    groups = []+    for g in layout['groups']:+        cmds = []+        if g.get('text'):+            cmds += ['LAYER 97;', 'CHANGE SIZE 2.54mm;', "TEXT '%s' R0 %s;" % (g['name'], f(tuple(g['text']))), 'CHANGE SIZE 1.778mm;']+        cmds.append('LAYER 91;')+        for r, (x, y, rot) in g.get('parts', {}).items():+            cmds += ["ADD '%s@%s' '%s' %s %s;" % (by_ref[r], libname, r, rot or 'R0', f((float(x), float(y)))), "VALUE '%s' '%s';" % (r, values.get(r) or by_ref[r])]+        for n, paths in g.get('nets', {}).items():+            for path in paths:+                cmds.append("NET '%s' %s;" % (n, ' '.join(f(pt(p)) for p in path)))+        for n, x, y in g.get('labels', []):+            cmds.append('LABEL %s %s;' % (f((x, y)), f((x, y))))+        groups.append({'group': g['name'], 'parts': list(g.get('parts', {})), 'commands': cmds})+    return groups++ def cmd_schematic(a):     st = load_state()     if not st.get('libraryPath'): err('no project library yet', ['Run `adom-aiflow fusion library --parts <parts>` first.'])@@ -843,9 +892,12 @@ def cmd_schematic(a):     ab('fusion_schematic', {'action': 'show'}); time.sleep(3)     fixed_refs = set(fx['fixed'])     parts = [{'ref': c['ref'], 'deviceset': by_ref[c['ref']], 'value': c.get('value'), 'block': c.get('block')} for c in nl['components'] if c['ref'] not in fixed_refs]-    plan = ab('fusion_schematic_plan', {'libraryPath': st['libraryPath'], 'parts': parts, 'nets': {n: [m for m in ms if m.split('.')[0] not in fixed_refs] for n, ms in nets.items()}})+    if a.layout:+        plan = {'groups': wired_groups(json.loads(Path(a.layout).read_text()), lib, by_ref, {c['ref']: c.get('value') for c in nl['components']}, Path(st['libraryPath'].replace('\\', '/')).stem, fixed_refs)}+    else:+        plan = ab('fusion_schematic_plan', {'libraryPath': st['libraryPath'], 'parts': parts, 'nets': {n: [m for m in ms if m.split('.')[0] not in fixed_refs] for n, ms in nets.items()}})     if not plan.get('groups'): err('schematic plan: %s' % plan.get('error'))-    if fixed_refs:+    if fixed_refs and not a.layout:         # the seed draws the fixed parts (a molecule's pins and contacts) as a column at the sheet's left edge;         # the planner starts its groups at that same x, so move every group clear of the column         shift = 45.72@@ -1109,6 +1161,7 @@ def cmd_silkscreen(a):         src = ab('fusion_board_source', {'expectDocument': name}); text = (src.get('data') or src).get('source') or src.get('source') or ''         if '<element ' not in text: err('could not read the board %s (%s)' % (name, src.get('error')), ['Show the 2D board (fusion_show_2d_board) and run this again.'])     els, outline, vias, texts = board_model(text)+    nameless = {m.group(1) for m in re.finditer(r'<element name="([^"]+)"[^>]*smashed="yes"[^>]*>(.*?)</element>', text, re.S) if 'name="NAME"' not in m.group(2)}     contacts = [e for e in els if is_contact(e)]     fresh_cmds = []     if a.fresh:@@ -1133,7 +1186,7 @@ def cmd_silkscreen(a):             err('this board already carries %d silkscreen lines from an earlier pass; --fresh will not delete them (a DELETE by point can remove a whole part)' % len(dels),                 ['Restore the board version from before the earlier pass: `fusion_aps_versions {query}` lists them; promote the clean one (Fusion API DataFile.versions[n].promote()), close the open copy without saving, reopen, then run this again.'])         texts = [t for t in texts if t['layer'] != 'wire21' and not t.get('_gone')]-        if fresh_cmds: fresh_cmds += ['DISPLAY NONE %s;' % (a.display or ' '.join(vis or ['1', '16', '17', '18', '20', '21', '25']))]+        if fresh_cmds: fresh_cmds += ['DISPLAY NONE %s;' % (a.display or ' '.join(sorted(set(vis or ['1', '16']) | {'17', '18', '20', '21', '22', '25'}, key=int)))]     # the corner pins' shape is chosen with the columns: each corner variant in turn (beside the pin, two     # lines over or under it, one line), keeping whichever frames the most contacts     tries = [plan_frames(els, outline, vias, corner_orients=(o,)) for o in ('h', 'v', 'v1')]@@ -1262,6 +1315,10 @@ def cmd_silkscreen(a):             hx, hy = e['homeName']             fresh_cmds += ["SMASH %s;" % e['name'], "ROTATE =R%d '%s>NAME';" % (int(round(e['rot'])) % 360, e['name']), "MOVE '%s>NAME' (%.3f %.3f);" % (e['name'], hx, hy)]     vis_now = re.findall(r'<layer number="(\d+)"[^>]*visible="yes"', text) or ['1', '16', '17', '18', '20', '21', '25']+    # the restore after each name removal must keep every layer this step draws on visible: with an odd set+    # (only 23 24 25 on, issue #74) each later CHANGE LAYER 21 failed 'Unavailable layer' after the NAME was+    # already deleted. So: the board's own visible set plus pads, vias, outline, tPlace, bPlace and tNames.+    vis_now = sorted(set(vis_now) | {'17', '18', '20', '21', '22', '25'}, key=int)     parked = [0]     def remove_name(ref):         """a smashed name off the print, safely: MOVE it by name (lands) to an empty spot off the board, then@@ -1284,7 +1341,12 @@ def cmd_silkscreen(a):         if L['kind'] == 'name':             ax, ay = anchor(L['box'], aligns.get(L['ref'], 'bottom-left'), L.get('rot', 'R0'))             L['anchor'] = [round(ax, 3), round(ay, 3)]-            if abs(L['size'] - 0.8) < 1e-6:+            if L['ref'] in nameless:+                # a smashed part whose NAME text is gone (an earlier, interrupted pass deleted it): there is no+                # name to move or remove, so the reference is printed as plain text at the planned spot+                tx, ty = (x0, y0) if L.get('rot', 'R0') == 'R0' else (L['box'][2], L['box'][1])+                cmds += ['CHANGE LAYER 21;', 'CHANGE SIZE %s;' % L['size'], 'CHANGE RATIO 15;', 'CHANGE ALIGN bottom-left;', "TEXT '%s' %s (%.3f %.3f);" % (L['text'], L.get('rot', 'R0'), tx, ty)]+            elif abs(L['size'] - 0.8) < 1e-6:                 cmds += ["SMASH %s;" % L['ref'], "ROTATE =%s '%s>NAME';" % (L.get('rot', 'R0'), L['ref']), "MOVE '%s>NAME' (%.3f %.3f);" % (L['ref'], ax, ay)]             else:                 # CHANGE SIZE does not land in Fusion (by point or by name): the smashed name comes off at its@@ -1550,6 +1612,7 @@ def main():     p = sub.add_parser('schematic'); p.add_argument('--netlist', required=True); p.add_argument('--spec'); p.add_argument('--fixed'); p.add_argument('--outline', nargs=2, type=float)     p.add_argument('--mpns'); p.add_argument('--name', required=True); p.add_argument('--project', default='Main'); p.add_argument('--folder', default='Adom AI Flow')     p.add_argument('--board-out', default='board.brd'); p.add_argument('--pause-ms', type=int, default=60)+    p.add_argument('--layout', help='wired.json: parts placed by hand and every net drawn as wires pin to pin (a sheet an engineer can read), instead of the planner\'s labelled stubs')     p = sub.add_parser('reset'); p.add_argument('--design')     p = sub.add_parser('export'); p.add_argument('--design'); p.add_argument('--out-dir', default=str(RUN / 'manufacturing'))     p = sub.add_parser('view3d', help='the board in 3D: a still to check and a live orbit'); p.add_argument('--design'); p.add_argument('--steps', type=int, default=24); p.add_argument('--pause', type=float, default=0.6); p.add_argument('--settle', type=float, default=8)