"""Top-level orchestration for the KiCad-launched action. Flow: connect -> load the config named "default" (or the board-specific one) -> derive BOTH modes' terminals (classic: selection / marker rectangles / config refs; PDN: per-rectangle marker scan, or the config's terminal set) -> gather fills -> dialog with a Classic/PDN mode selector (classic: the two-contact form; PDN: editable per-role terminal tables) -> extract vias -> solve -> figures + report. The dialog's "Load config…" button loops back to the derivation with the picked file, so a run can be set up from any saved config. Every failure is reported twice: on stdout (lands in the KiCad status-bar warning list) and as a matplotlib error figure, so it cannot be missed. """ from __future__ import annotations import sys import traceback from pathlib import Path from . import config, pipeline, progress, report from .errors import ConfigError, SelectionError, UserFacingError from .geometry import Terminal def _fail(message: str, outdir) -> None: print(f"ERROR: {message}") try: if outdir is None: # A failure before the run has an output directory (a broken # plugin environment throws on import) would otherwise save # no PNG - and with no GUI toolkit, plots falls back to # opening the saved PNGs, so the figure would never be shown # either. Exactly the case the docstring promises to cover. import tempfile from pathlib import Path outdir = Path(tempfile.gettempdir()) / "fill-resistance-error" from . import plots fig = plots.fig_error(message) plots.save_and_show([(fig, "error")], outdir) except Exception: # reporting must not mask the fault traceback.print_exc() sys.exit(1) # config globals a config file may override; "Load config…" re-derives # from a fresh baseline so one file's physics/marker layers never leak # into the next _CFG_GLOBALS = ("RHO_CU_OHM_M", "COPPER_THICKNESS_UM", "VIA_PLATING_UM", "ELECTRODE_POS_LAYER", "ELECTRODE_NEG_LAYER", "ELECTRODE_PDN_LAYER") def main() -> None: outdir = None try: try: from kipy.errors import ApiError from . import board_io, configfile, dialog except ImportError as e: if "cannot open shared object file" not in str(e): raise # pip's Linux wheels link against FHS system libraries; # on NixOS those paths don't exist and PySide6/pynng die # exactly like this. Nothing inside the venv can fix it. raise UserFacingError( f"A compiled dependency cannot load its system " f"libraries: {e}\nThe plugin venv is built from pip " f"wheels, which expect standard (FHS) library paths. " f"On NixOS, run KiCad inside an FHS environment " f"(buildFHSEnv wrapper, or steam-run for a quick " f"test) - see docs/NIXOS.md in the plugin repo." ) try: kicad, board = board_io.connect() stackup = board_io.get_stackup_info(board) except ApiError as e: raise UserFacingError( f"KiCad API error: {e}\nIf KiCad is showing a dialog, close " f"it and run again." ) cfg_path = configfile.find_config( board_io.board_dir(board), getattr(board, "name", "") or "") base_globals = {name: getattr(config, name) for name in _CFG_GLOBALS} while True: for name, value in base_globals.items(): setattr(config, name, value) try: cfg = configfile.load_config(cfg_path) if cfg_path else None # a config with a terminals section is the PDN source; # cfg.mode is only the STARTING mode - nothing is # pinned, the dialog switches modes and nets freely pdn_cfg = cfg is not None and bool(cfg.terminals) if cfg is not None: print(f"using config {cfg_path.name} ({cfg.mode} mode)") # before any geometry: the marker layers steer # get_electrodes, the physics steers build_problem configfile.apply_physics(cfg) # BOTH terminal derivations always run; a failure only # disables that mode's radio (with the reason shown) - the # launch dies only when neither mode is possible classic_reason = pdn_reason = None es1: list = [] es2: list = [] net_hint = None terminals: list = [] # resolved config terminals marker_terms = None # live-scan MarkerTerminal list new_terms: list = [] # rects not in the config yet merge_note = "" pdn_groups: list = [] # electrode groups, either way pdn_hints: list = [] # per-terminal Component text term_nets: list = [] # per-terminal overlapped nets has_selection = bool(list(board.get_selection())) try: if cfg is not None and cfg.pos_parts is not None: es1, es2 = board_io.resolve_classic_parts( board, stackup, cfg.pos_parts, cfg.neg_parts, cfg.net) net_hint = cfg.net else: es1, es2, net_hint = board_io.get_electrodes( board, stackup) except SelectionError as e: classic_reason = str(e) try: if pdn_cfg: # config refs resolve against run.net; a broken # ref disables PDN mode instead of killing the # launch (classic may still work) if not cfg.net: raise ConfigError( f"{cfg_path.name}: run.net is required " f"to resolve the config terminals") terminals = board_io.resolve_terminal_specs( board, stackup, cfg.terminals, cfg.net) # rectangles drawn since the save become NEW # terminals - the file freezes nothing. A scan # problem only forfeits the new ones, never # the config set try: new_terms = board_io.new_marker_terminals( cfg.terminals, board_io.scan_marker_terminals( board, require_both=False)) except (SelectionError, ConfigError) as e: merge_note = (f"rectangle scan failed ({e})" f" - new rectangles not " f"offered") print(f"note: {merge_note}") pdn_groups = ( [t.electrodes for t in terminals] + [mt.electrodes for mt in new_terms]) else: marker_terms = board_io.scan_marker_terminals( board) pdn_groups = [mt.electrodes for mt in marker_terms] pdn_hints = board_io.component_hints(board, pdn_groups) except (SelectionError, ConfigError) as e: pdn_reason = str(e) if board_io.any_zone_unfilled(board) or config.ALWAYS_REFILL: board_io.refill(board) fills = board_io.gather_net_fills(board) tracks = board_io.gather_net_tracks(board) copper = board_io.merge_copper( fills, board_io.tracks_as_polygons(tracks)) classic_nets: list = [] pdn_nets: list = [] if classic_reason is None: classic_nets = board_io.nets_overlapping( copper, es1, es2) if not classic_nets: classic_reason = ( "No copper (zone fill or trace) overlaps " "both contacts. Check that both sit over " "copper of the same net and that the fills " "are up to date (press B in the board " "editor).") if pdn_reason is None: # per-terminal net sets drive BOTH the candidate # list (a net qualifies with >= 1 supply and >= 1 # load terminal on it) and the dialog's row filter # (only terminals on the selected net are shown # and solved) - config and live sources alike term_nets = board_io.group_nets(copper, pdn_groups) roles = (([t.role for t in terminals] + [mt.role for mt in new_terms]) if pdn_cfg else [mt.role for mt in marker_terms]) sup_nets: set = set() load_nets: set = set() for role, tn in zip(roles, term_nets): (sup_nets if role == "supply" else load_nets).update(tn) pdn_nets = sorted(sup_nets & load_nets) if not pdn_nets: pdn_reason = ( "no net's copper overlaps at least one " "supply and one load " + (f"terminal of {cfg_path.name}" if pdn_cfg else "rectangle")) elif pdn_cfg and cfg.net not in pdn_nets: print(f"note: run.net '{cfg.net}' has no " f"workable supply+load copper; PDN " f"candidates: {', '.join(pdn_nets)}") if classic_reason is not None and pdn_reason is not None: raise SelectionError( f"{classic_reason}\n(PDN mode is also " f"unavailable: {pdn_reason})") buildups = board_io.gather_mask_buildups(board) except ApiError as e: raise UserFacingError( f"KiCad API error: {e}\nIf KiCad is showing a dialog, " f"close it and run again." ) def group_label(parts): names = [p.label for p in parts[:3]] more = f" +{len(parts) - 3}" if len(parts) > 3 else "" return f"{len(parts)}× " + ", ".join(names) + more def group_contact(parts): contacts = {p.contact for p in parts} return contacts.pop() if len(contacts) == 1 else "auto" def rect_desc(e): r = e.rect return (f"rect ({r.x0 / 1e6:.1f}, {r.y0 / 1e6:.1f}).." f"({r.x1 / 1e6:.1f}, {r.y1 / 1e6:.1f}) mm") def marker_desc(mt): if len(mt.electrodes) == 1: return rect_desc(mt.electrodes[0]) # same-named rectangles grouped into one terminal (the # Bonded checkbox shows/controls the lug behavior) return (f"{len(mt.electrodes)}× " f"{rect_desc(mt.electrodes[0])} …") def live_row(mt, hint, tn): return dialog.PdnTerminalRow( name=mt.name, role=mt.role, resolved=marker_desc(mt), component=hint, bonded=mt.bonded, nets=tn) defaults = configfile.dialog_defaults(cfg) pdn_setup = None if pdn_cfg and pdn_reason is None: n_cfg = len(terminals) rows = [dialog.PdnTerminalRow( name=t.label, role=t.role, resolved=group_label(t.electrodes), component=hint, i_draw_a=(t.i_draw_a if t.role == "load" else None), r_out_ohm=(t.r_out_ohm if t.role == "supply" else None), v_oc=t.v_oc, bonded=t.bonded, contact=spec.contact or "auto", active=spec.active, comment=spec.comment, nets=tn, from_config=True) for t, spec, hint, tn in zip( terminals, cfg.terminals, pdn_hints[:n_cfg], term_nets[:n_cfg])] # newly drawn rectangles append as live rows: a save # writes them into the config alongside the file's set rows += [live_row(mt, hint, tn) for mt, hint, tn in zip(new_terms, pdn_hints[n_cfg:], term_nets[n_cfg:])] notes = [] if new_terms: notes.append(f"{len(new_terms)} new rectangle(s) " f"not in {cfg_path.name} yet - " f"Save config… adds them") if merge_note: notes.append(merge_note) pdn_setup = dialog.PdnSetup( rows=rows, source=cfg_path.name, from_config=True, note="; ".join(notes)) elif pdn_reason is None: note = "" if has_selection: note = ("board selection ignored in PDN mode - " "terminals are the marker rectangles") print(f"note: {note}") pdn_setup = dialog.PdnSetup( rows=[live_row(mt, hint, tn) for mt, hint, tn in zip(marker_terms, pdn_hints, term_nets)], source=(f"marker rectangles on " f"{config.ELECTRODE_POS_LAYER}/" f"{config.ELECTRODE_NEG_LAYER}"), note=note) # cfg.mode is only the starting radio - never a pin start_pdn = ((cfg is not None and cfg.mode == "pdn" and pdn_reason is None) or classic_reason is not None) if start_pdn: default_net = (defaults.net if defaults.net in pdn_nets else (pdn_nets[0] if pdn_nets else "")) else: default_net = (defaults.net if defaults.net in classic_nets else net_hint if net_hint in classic_nets else classic_nets[0]) def rect_infos(terms): # unlabeled terminals are always single rectangles, so # freezing the first rect's coordinates is exact return [(mt.labeled, (mt.electrodes[0].rect.x0 / 1e6, mt.electrodes[0].rect.y0 / 1e6, mt.electrodes[0].rect.x1 / 1e6, mt.electrodes[0].rect.y1 / 1e6)) for mt in terms] def save_cb(sel, target, cfg=cfg, cfg_path=cfg_path, pdn_cfg=pdn_cfg, marker_terms=marker_terms, new_terms=new_terms): if sel.mode == "classic": configfile.save_classic_config(target, sel) elif pdn_cfg: # the config rows update positionally; newly drawn # rectangles append as fresh terminal entries n = len(cfg.raw["terminals"]) tj = configfile.updated_terminals_json( cfg.raw["terminals"], sel.pdn_rows[:n]) if sel.pdn_rows[n:]: tj += configfile.rect_terminals_json( sel.pdn_rows[n:], rect_infos(new_terms)) print(f"note: {len(sel.pdn_rows[n:])} new " f"terminal(s) added to the config") configfile.save_pdn_config(target, sel, tj) else: # EVERY row is saved - off-net ones arrive from the # dialog as active: false (nothing drawn on the # board is lost by a save); labeled (possibly # grouped) rectangles save as rect:NAME configfile.save_pdn_config( target, sel, configfile.rect_terminals_json( sel.pdn_rows, rect_infos(marker_terms))) print("note: the saved config now provides the " "terminal set on later launches - labeled " "rectangles stay live (rect:NAME), unlabeled " "ones were frozen as coordinates; remove the " "terminals section (or the file) to return " "to the live rectangle scan") print(f"config saved to {target}") # only "default" (or its legacy plain spelling) and the # board-stem name load on launch; other names need the # Load config… button - say so before it surprises auto = {config.CONFIG_FILENAME, configfile.named_config_filename("default")} stem = Path(getattr(board, "name", "") or "").stem if stem: auto.add(f"{stem}.{config.CONFIG_FILENAME}") if target.name not in auto: print("note: this name does not load automatically " "- pull it in with Load config…") return target.name selection = dialog.ask( candidates={n: list(copper[n].keys()) for n in classic_nets}, layer_order=stackup.names, default_net=default_net, e1_label=(group_label(es1) if es1 else ""), e2_label=(group_label(es2) if es2 else ""), contact1=((defaults.contact1 or group_contact(es1)) if es1 else "auto"), contact2=((defaults.contact2 or group_contact(es2)) if es2 else "auto"), buildup_layers=sorted(buildups.keys()), defaults=defaults, pdn=pdn_setup, pdn_candidates={n: list(copper[n].keys()) for n in pdn_nets}, classic_reason=classic_reason, pdn_reason=pdn_reason, save_callback=save_cb, save_target=(cfg_path if cfg_path is not None else board_io.board_dir(board) / config.CONFIG_FILENAME), load_dir=board_io.board_dir(board), start_mode=("pdn" if start_pdn else "classic"), ) if isinstance(selection, dialog.LoadRequest): # re-derive everything from the picked file; its validity # was already checked by the dialog before it closed cfg_path = selection.path continue break if selection is None: print("cancelled") return # the solve owns the thread from here; without this the plugin # looks like it did nothing until the figures appear progress.start() run_pdn = selection.mode == "pdn" if run_pdn: def live_terminal(mt, row): if row.contact not in ("", "all", "auto"): # dialog Layer pick: this terminal's rectangles # contact only that copper layer for e in mt.electrodes: e.contact = row.contact return Terminal( role=row.role, electrodes=mt.electrodes, label=row.name, i_draw_a=(row.i_draw_a if row.i_draw_a is not None else 0.0), r_out_ohm=(row.r_out_ohm if row.r_out_ohm is not None else 0.0), v_oc=row.v_oc, bonded=row.bonded, component=row.component, comment=row.comment) if pdn_cfg: cfg_rows = selection.pdn_rows[:len(terminals)] new_rows = selection.pdn_rows[len(terminals):] # a changed Layer scope is geometry: push it onto the # specs and re-resolve (part-level contacts inside the # file still win, exactly as the schema promises) changed = False for spec, row in zip(cfg.terminals, cfg_rows): if (row.contact or "auto") != (spec.contact or "auto"): spec.contact = row.contact changed = True if changed: try: terminals = board_io.resolve_terminal_specs( board, stackup, cfg.terminals, cfg.net) except ApiError as e: raise UserFacingError(f"KiCad API error: {e}") # dialog value edits win for the run: write them back # onto the resolved terminals (positional, same order), # then drop the unchecked ones - they stay in the file # but take no part in the solve; newly drawn # rectangles run as live terminals for t, row in zip(terminals, cfg_rows): if t.role == "load": t.i_draw_a = row.i_draw_a else: t.r_out_ohm = row.r_out_ohm t.v_oc = row.v_oc t.bonded = row.bonded t.component = row.component t.comment = row.comment terminals = ( [t for t, row in zip(terminals, cfg_rows) if row.active] + [live_terminal(mt, row) for mt, row in zip(new_terms, new_rows) if row.active]) else: terminals = [live_terminal(mt, row) for mt, row in zip(marker_terms, selection.pdn_rows) if row.active] else: if selection.contact1 != "auto": for e in es1: e.contact = selection.contact1 if selection.contact2 != "auto": for e in es2: e.contact = selection.contact2 if selection.cell_um is not None: config.CELL_UM_OVERRIDE = selection.cell_um config.ADAPTIVE_CELLS = selection.adaptive try: problem = board_io.build_problem( board, selection.net, selection.layers, ([] if run_pdn else es1), ([] if run_pdn else es2), stackup, fills, buildups=(buildups if selection.include_buildup else None), extra_cu_um=selection.extra_cu_um, tracks=(tracks if selection.include_tracks else None), vias_capped=selection.vias_capped, cap_max_drill_mm=selection.cap_max_drill_mm, terminals=(terminals if run_pdn else None)) outdir = report.make_output_dir(board_io.board_dir(board)) except ApiError as e: raise UserFacingError(f"KiCad API error: {e}") report.write_geometry_dump(outdir, problem) overlay_cb = None if selection.push_overlays: def overlay_cb(stack, result): board_io.push_result_overlays(board, stack, result) trim_cb = None trim_pct = trim_abs = None if selection.trim_enabled: def trim_cb(tr): board_io.push_trim_polygons(board, tr) if selection.trim_mode == "abs": trim_abs = selection.trim_value else: trim_pct = selection.trim_value pipeline.run(problem, outdir, show=True, i_test=(None if run_pdn else selection.current_a), freq_hz=selection.freq_hz, contact_model=(None if run_pdn else selection.contact_model), overlay=overlay_cb, trim_pct=trim_pct, trim_abs=trim_abs, trim_push=trim_cb, v_nominal=(selection.v_nominal if run_pdn else None)) except progress.Cancelled: print("cancelled") # user's own doing: no error figure except UserFacingError as e: _fail(str(e), outdir) except Exception: _fail(traceback.format_exc(), outdir) finally: progress.done() # also on the error paths if __name__ == "__main__": main()