diff --git a/python/kicad_interface.py b/python/kicad_interface.py index 1e9c40e..a0ec531 100644 --- a/python/kicad_interface.py +++ b/python/kicad_interface.py @@ -588,6 +588,25 @@ class KiCADInterface: "get_net_at_point": self._handle_get_net_at_point, "run_erc": self._handle_run_erc, "export_netlist": self._handle_export_netlist, + "export_gerbers": self._handle_export_gerbers, + "export_drill": self._handle_export_drill, + "export_ipc2581": self._handle_export_ipc2581, + "export_odb": self._handle_export_odb, + "export_ipcd356": self._handle_export_ipcd356, + "export_gencad": self._handle_export_gencad, + "export_pos": self._handle_export_pos, + "export_pcb_pdf": self._handle_export_pcb_pdf, + "export_pcb_svg": self._handle_export_pcb_svg, + "export_pcb_dxf": self._handle_export_pcb_dxf, + "export_gerber_single": self._handle_export_gerber_single, + "export_3d_cli": self._handle_export_3d_cli, + "export_sch_bom": self._handle_export_sch_bom, + "export_sch_pdf": self._handle_export_sch_pdf, + "export_sch_svg": self._handle_export_sch_svg, + "export_sch_dxf": self._handle_export_sch_dxf, + "export_sch_hpgl": self._handle_export_sch_hpgl, + "export_sch_ps": self._handle_export_sch_ps, + "export_sch_python_bom": self._handle_export_sch_python_bom, "generate_netlist": self._handle_generate_netlist, "sync_schematic_to_board": self._handle_sync_schematic_to_board, "list_schematic_libraries": self._handle_list_schematic_libraries, @@ -4499,6 +4518,1596 @@ class KiCADInterface: logger.error(f"Error exporting netlist: {e}") return {"success": False, "message": str(e)} + def _handle_export_gerbers(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Plot multiple Gerbers for a PCB via kicad-cli (`pcb export gerbers`). + + Exposes the full Plot-dialog option set. Reads the board from disk, so it + reflects the last *saved* state of the .kicad_pcb. Pass ``boardPath`` to + target a specific file; otherwise the current board path is used. + """ + import subprocess + + logger.info("Exporting Gerbers via kicad-cli") + try: + board_path = params.get("boardPath") or self._current_board_path() + output_dir = params.get("outputDir") + + if not board_path: + return { + "success": False, + "message": "boardPath is required (no current board could be resolved)", + } + if not Path(board_path).exists(): + return {"success": False, "message": f"Board not found: {board_path}"} + if not output_dir: + return {"success": False, "message": "outputDir is required"} + + kicad_cli = self._find_kicad_cli_static() + if not kicad_cli: + return {"success": False, "message": "kicad-cli not found in PATH"} + + output_dir = str(Path(output_dir).expanduser().resolve()) + Path(output_dir).mkdir(parents=True, exist_ok=True) + + cmd = [kicad_cli, "pcb", "export", "gerbers", "--output", output_dir] + + # Layer selection (accept list or comma string) + layers = params.get("layers") + if layers: + cmd += ["--layers", ",".join(layers) if isinstance(layers, list) else str(layers)] + common_layers = params.get("commonLayers") + if common_layers: + cmd += [ + "--common-layers", + ( + ",".join(common_layers) + if isinstance(common_layers, list) + else str(common_layers) + ), + ] + + if params.get("drawingSheet"): + cmd += ["--drawing-sheet", params["drawingSheet"]] + for kv in params.get("defineVar", []) or []: + cmd += ["--define-var", kv] + + # Boolean flags (omit to leave at kicad-cli default) + flag_map = { + "excludeRefdes": "--exclude-refdes", + "excludeValue": "--exclude-value", + "includeBorderTitle": "--include-border-title", + "sketchPadsOnFabLayers": "--sketch-pads-on-fab-layers", + "hideDnpFootprintsOnFabLayers": "--hide-DNP-footprints-on-fab-layers", + "sketchDnpFootprintsOnFabLayers": "--sketch-DNP-footprints-on-fab-layers", + "crossoutDnpFootprintsOnFabLayers": "--crossout-DNP-footprints-on-fab-layers", + "noX2": "--no-x2", + "noNetlist": "--no-netlist", + "subtractSoldermask": "--subtract-soldermask", + "disableApertureMacros": "--disable-aperture-macros", + "useDrillFileOrigin": "--use-drill-file-origin", + "noProtelExt": "--no-protel-ext", + "boardPlotParams": "--board-plot-params", + } + for key, flag in flag_map.items(): + if params.get(key): + cmd.append(flag) + + precision = params.get("precision") + if precision is not None: + cmd += ["--precision", str(precision)] + + cmd.append(board_path) + + logger.info(f"Running: {' '.join(cmd)}") + result = subprocess.run(cmd, capture_output=True, text=True, timeout=180) + + if result.returncode != 0: + return { + "success": False, + "message": f"kicad-cli failed (exit {result.returncode}): " + f"{result.stderr.strip()}", + } + + files = sorted(p.name for p in Path(output_dir).iterdir() if p.is_file()) + return {"success": True, "outputDir": output_dir, "files": files} + + except FileNotFoundError: + return {"success": False, "message": "kicad-cli not found in PATH"} + except subprocess.TimeoutExpired: + return {"success": False, "message": "kicad-cli timed out after 180 seconds"} + except Exception as e: + logger.error(f"Error exporting Gerbers: {e}") + return {"success": False, "message": str(e)} + + def _handle_export_drill(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Generate drill files via kicad-cli (`pcb export drill`). + + Exposes the full Excellon/Gerber drill option set. Reads the saved + .kicad_pcb on disk. + """ + import subprocess + + logger.info("Exporting drill files via kicad-cli") + try: + board_path = params.get("boardPath") or self._current_board_path() + output_dir = params.get("outputDir") + + if not board_path: + return { + "success": False, + "message": "boardPath is required (no current board could be resolved)", + } + if not Path(board_path).exists(): + return {"success": False, "message": f"Board not found: {board_path}"} + if not output_dir: + return {"success": False, "message": "outputDir is required"} + + kicad_cli = self._find_kicad_cli_static() + if not kicad_cli: + return {"success": False, "message": "kicad-cli not found in PATH"} + + output_dir = str(Path(output_dir).expanduser().resolve()) + # kicad-cli drill requires the output dir path to end with a separator + Path(output_dir).mkdir(parents=True, exist_ok=True) + + cmd = [kicad_cli, "pcb", "export", "drill", "--output", output_dir + os.sep] + + # Valued options (omit to use kicad-cli defaults) + value_map = { + "format": "--format", + "drillOrigin": "--drill-origin", + "excellonZerosFormat": "--excellon-zeros-format", + "excellonOvalFormat": "--excellon-oval-format", + "excellonUnits": "--excellon-units", + "mapFormat": "--map-format", + "gerberPrecision": "--gerber-precision", + } + for key, flag in value_map.items(): + val = params.get(key) + if val is not None: + cmd += [flag, str(val)] + + # Boolean flags + flag_map = { + "excellonMirrorY": "--excellon-mirror-y", + "excellonMinHeader": "--excellon-min-header", + "excellonSeparateTh": "--excellon-separate-th", + "generateMap": "--generate-map", + } + for key, flag in flag_map.items(): + if params.get(key): + cmd.append(flag) + + cmd.append(board_path) + + logger.info(f"Running: {' '.join(cmd)}") + result = subprocess.run(cmd, capture_output=True, text=True, timeout=120) + + if result.returncode != 0: + return { + "success": False, + "message": f"kicad-cli failed (exit {result.returncode}): " + f"{result.stderr.strip()}", + } + + files = sorted(p.name for p in Path(output_dir).iterdir() if p.is_file()) + return {"success": True, "outputDir": output_dir, "files": files} + + except FileNotFoundError: + return {"success": False, "message": "kicad-cli not found in PATH"} + except subprocess.TimeoutExpired: + return {"success": False, "message": "kicad-cli timed out after 120 seconds"} + except Exception as e: + logger.error(f"Error exporting drill files: {e}") + return {"success": False, "message": str(e)} + + def _handle_export_ipc2581(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Export the PCB in IPC-2581 format via kicad-cli (`pcb export ipc2581`). + + IPC-2581 is a single-file MES/CAD interchange format carrying placement, + nets, and BOM part data inline. The bom-col-* params map schematic fields + to the BOM columns embedded in the file. Reads the saved .kicad_pcb. + """ + import subprocess + + logger.info("Exporting IPC-2581 via kicad-cli") + try: + board_path = params.get("boardPath") or self._current_board_path() + output_path = params.get("outputPath") + + if not board_path: + return { + "success": False, + "message": "boardPath is required (no current board could be resolved)", + } + if not Path(board_path).exists(): + return {"success": False, "message": f"Board not found: {board_path}"} + if not output_path: + return {"success": False, "message": "outputPath is required"} + + kicad_cli = self._find_kicad_cli_static() + if not kicad_cli: + return {"success": False, "message": "kicad-cli not found in PATH"} + + output_path = str(Path(output_path).expanduser().resolve()) + Path(output_path).parent.mkdir(parents=True, exist_ok=True) + + cmd = [kicad_cli, "pcb", "export", "ipc2581", "--output", output_path] + + if params.get("drawingSheet"): + cmd += ["--drawing-sheet", params["drawingSheet"]] + for kv in params.get("defineVar", []) or []: + cmd += ["--define-var", kv] + + value_map = { + "precision": "--precision", + "version": "--version", + "units": "--units", + "bomColIntId": "--bom-col-int-id", + "bomColMfgPn": "--bom-col-mfg-pn", + "bomColMfg": "--bom-col-mfg", + "bomColDistPn": "--bom-col-dist-pn", + "bomColDist": "--bom-col-dist", + } + for key, flag in value_map.items(): + val = params.get(key) + if val is not None and val != "": + cmd += [flag, str(val)] + + if params.get("compress"): + cmd.append("--compress") + + cmd.append(board_path) + + logger.info(f"Running: {' '.join(cmd)}") + result = subprocess.run(cmd, capture_output=True, text=True, timeout=180) + + if result.returncode != 0: + return { + "success": False, + "message": f"kicad-cli failed (exit {result.returncode}): " + f"{result.stderr.strip()}", + } + return {"success": True, "outputPath": output_path} + + except FileNotFoundError: + return {"success": False, "message": "kicad-cli not found in PATH"} + except subprocess.TimeoutExpired: + return {"success": False, "message": "kicad-cli timed out after 180 seconds"} + except Exception as e: + logger.error(f"Error exporting IPC-2581: {e}") + return {"success": False, "message": str(e)} + + def _handle_export_odb(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Export the PCB in ODB++ format via kicad-cli (`pcb export odb`). + + ODB++ is a fab/assembly job archive. ``compression`` selects the output + container (zip / tgz / none). Reads the saved .kicad_pcb. + """ + import subprocess + + logger.info("Exporting ODB++ via kicad-cli") + try: + board_path = params.get("boardPath") or self._current_board_path() + output_path = params.get("outputPath") + + if not board_path: + return { + "success": False, + "message": "boardPath is required (no current board could be resolved)", + } + if not Path(board_path).exists(): + return {"success": False, "message": f"Board not found: {board_path}"} + if not output_path: + return {"success": False, "message": "outputPath is required"} + + kicad_cli = self._find_kicad_cli_static() + if not kicad_cli: + return {"success": False, "message": "kicad-cli not found in PATH"} + + output_path = str(Path(output_path).expanduser().resolve()) + parent = Path(output_path).parent + if parent: + Path(parent).mkdir(parents=True, exist_ok=True) + + cmd = [kicad_cli, "pcb", "export", "odb", "--output", output_path] + + if params.get("drawingSheet"): + cmd += ["--drawing-sheet", params["drawingSheet"]] + for kv in params.get("defineVar", []) or []: + cmd += ["--define-var", kv] + for key, flag in { + "precision": "--precision", + "compression": "--compression", + "units": "--units", + }.items(): + val = params.get(key) + if val is not None and val != "": + cmd += [flag, str(val)] + + cmd.append(board_path) + + logger.info(f"Running: {' '.join(cmd)}") + result = subprocess.run(cmd, capture_output=True, text=True, timeout=180) + + if result.returncode != 0: + return { + "success": False, + "message": f"kicad-cli failed (exit {result.returncode}): " + f"{result.stderr.strip()}", + } + return {"success": True, "outputPath": output_path} + + except FileNotFoundError: + return {"success": False, "message": "kicad-cli not found in PATH"} + except subprocess.TimeoutExpired: + return {"success": False, "message": "kicad-cli timed out after 180 seconds"} + except Exception as e: + logger.error(f"Error exporting ODB++: {e}") + return {"success": False, "message": str(e)} + + def _handle_export_ipcd356(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Generate an IPC-D-356 netlist file via kicad-cli (`pcb export ipcd356`). + + IPC-D-356 is a bare-board electrical-test netlist consumed by flying-probe + and bed-of-nails testers. Minimal option set (output + input only). Reads + the saved .kicad_pcb. + """ + import subprocess + + logger.info("Exporting IPC-D-356 netlist via kicad-cli") + try: + board_path = params.get("boardPath") or self._current_board_path() + output_path = params.get("outputPath") + + if not board_path: + return { + "success": False, + "message": "boardPath is required (no current board could be resolved)", + } + if not Path(board_path).exists(): + return {"success": False, "message": f"Board not found: {board_path}"} + if not output_path: + return {"success": False, "message": "outputPath is required"} + + kicad_cli = self._find_kicad_cli_static() + if not kicad_cli: + return {"success": False, "message": "kicad-cli not found in PATH"} + + output_path = str(Path(output_path).expanduser().resolve()) + parent = Path(output_path).parent + if parent: + Path(parent).mkdir(parents=True, exist_ok=True) + + cmd = [kicad_cli, "pcb", "export", "ipcd356", "--output", output_path] + cmd.append(board_path) + + logger.info(f"Running: {' '.join(cmd)}") + result = subprocess.run(cmd, capture_output=True, text=True, timeout=180) + + if result.returncode != 0: + return { + "success": False, + "message": f"kicad-cli failed (exit {result.returncode}): " + f"{result.stderr.strip()}", + } + return {"success": True, "outputPath": output_path} + + except FileNotFoundError: + return {"success": False, "message": "kicad-cli not found in PATH"} + except subprocess.TimeoutExpired: + return {"success": False, "message": "kicad-cli timed out after 180 seconds"} + except Exception as e: + logger.error(f"Error exporting IPC-D-356: {e}") + return {"success": False, "message": str(e)} + + def _handle_export_gencad(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Export the PCB in GenCAD format via kicad-cli (`pcb export gencad`). + + GenCAD is an assembly/test interchange format. Exposes the padstack-flip, + unique-pin/footprint, drill-origin, and store-origin flags. Reads the + saved .kicad_pcb. + """ + import subprocess + + logger.info("Exporting GenCAD via kicad-cli") + try: + board_path = params.get("boardPath") or self._current_board_path() + output_path = params.get("outputPath") + + if not board_path: + return { + "success": False, + "message": "boardPath is required (no current board could be resolved)", + } + if not Path(board_path).exists(): + return {"success": False, "message": f"Board not found: {board_path}"} + if not output_path: + return {"success": False, "message": "outputPath is required"} + + kicad_cli = self._find_kicad_cli_static() + if not kicad_cli: + return {"success": False, "message": "kicad-cli not found in PATH"} + + output_path = str(Path(output_path).expanduser().resolve()) + parent = Path(output_path).parent + if parent: + Path(parent).mkdir(parents=True, exist_ok=True) + + cmd = [kicad_cli, "pcb", "export", "gencad", "--output", output_path] + + for kv in params.get("defineVar", []) or []: + cmd += ["--define-var", kv] + + flag_map = { + "flipBottomPads": "--flip-bottom-pads", + "uniquePins": "--unique-pins", + "uniqueFootprints": "--unique-footprints", + "useDrillOrigin": "--use-drill-origin", + "storeOriginCoord": "--store-origin-coord", + } + for key, flag in flag_map.items(): + if params.get(key): + cmd.append(flag) + + cmd.append(board_path) + + logger.info(f"Running: {' '.join(cmd)}") + result = subprocess.run(cmd, capture_output=True, text=True, timeout=180) + + if result.returncode != 0: + return { + "success": False, + "message": f"kicad-cli failed (exit {result.returncode}): " + f"{result.stderr.strip()}", + } + return {"success": True, "outputPath": output_path} + + except FileNotFoundError: + return {"success": False, "message": "kicad-cli not found in PATH"} + except subprocess.TimeoutExpired: + return {"success": False, "message": "kicad-cli timed out after 180 seconds"} + except Exception as e: + logger.error(f"Error exporting GenCAD: {e}") + return {"success": False, "message": str(e)} + + def _handle_export_pos(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Generate a component placement (position) file via kicad-cli + (`pcb export pos`). + + This is the rich CLI sibling of export_position_file: full option set + (side, format, units, bottom-negate-X, drill origin, SMD-only, TH/DNP + exclusion, gerber board edge). Reads the saved .kicad_pcb. + """ + import subprocess + + logger.info("Exporting position file via kicad-cli") + try: + board_path = params.get("boardPath") or self._current_board_path() + output_path = params.get("outputPath") + + if not board_path: + return { + "success": False, + "message": "boardPath is required (no current board could be resolved)", + } + if not Path(board_path).exists(): + return {"success": False, "message": f"Board not found: {board_path}"} + if not output_path: + return {"success": False, "message": "outputPath is required"} + + kicad_cli = self._find_kicad_cli_static() + if not kicad_cli: + return {"success": False, "message": "kicad-cli not found in PATH"} + + output_path = str(Path(output_path).expanduser().resolve()) + parent = Path(output_path).parent + if parent: + Path(parent).mkdir(parents=True, exist_ok=True) + + cmd = [kicad_cli, "pcb", "export", "pos", "--output", output_path] + + value_map = { + "side": "--side", + "format": "--format", + "units": "--units", + } + for key, flag in value_map.items(): + val = params.get(key) + if val is not None and val != "": + cmd += [flag, str(val)] + + flag_map = { + "bottomNegateX": "--bottom-negate-x", + "useDrillFileOrigin": "--use-drill-file-origin", + "smdOnly": "--smd-only", + "excludeFpTh": "--exclude-fp-th", + "excludeDnp": "--exclude-dnp", + "gerberBoardEdge": "--gerber-board-edge", + } + for key, flag in flag_map.items(): + if params.get(key): + cmd.append(flag) + + cmd.append(board_path) + + logger.info(f"Running: {' '.join(cmd)}") + result = subprocess.run(cmd, capture_output=True, text=True, timeout=180) + + if result.returncode != 0: + return { + "success": False, + "message": f"kicad-cli failed (exit {result.returncode}): " + f"{result.stderr.strip()}", + } + return {"success": True, "outputPath": output_path} + + except FileNotFoundError: + return {"success": False, "message": "kicad-cli not found in PATH"} + except subprocess.TimeoutExpired: + return {"success": False, "message": "kicad-cli timed out after 180 seconds"} + except Exception as e: + logger.error(f"Error exporting position file: {e}") + return {"success": False, "message": str(e)} + + def _handle_export_pcb_pdf(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Plot the PCB to PDF via kicad-cli (`pcb export pdf`). + + Exposes the full layer-plot option set: layer lists, mirror, refdes/value + exclusion, border+title, soldermask subtraction, DNP fab-layer modes, + negative/B&W, theme, drill-shape, and the single/separate/multipage + output modes. Reads the saved .kicad_pcb. + """ + import subprocess + + logger.info("Exporting PCB PDF via kicad-cli") + try: + board_path = params.get("boardPath") or self._current_board_path() + output_path = params.get("outputPath") + + if not board_path: + return { + "success": False, + "message": "boardPath is required (no current board could be resolved)", + } + if not Path(board_path).exists(): + return {"success": False, "message": f"Board not found: {board_path}"} + if not output_path: + return {"success": False, "message": "outputPath is required"} + + kicad_cli = self._find_kicad_cli_static() + if not kicad_cli: + return {"success": False, "message": "kicad-cli not found in PATH"} + + output_path = str(Path(output_path).expanduser().resolve()) + parent = Path(output_path).parent + if parent: + Path(parent).mkdir(parents=True, exist_ok=True) + + cmd = [kicad_cli, "pcb", "export", "pdf", "--output", output_path] + + layers = params.get("layers") + if layers: + cmd += ["--layers", ",".join(layers) if isinstance(layers, list) else str(layers)] + common_layers = params.get("commonLayers") + if common_layers: + cmd += [ + "--common-layers", + ( + ",".join(common_layers) + if isinstance(common_layers, list) + else str(common_layers) + ), + ] + + if params.get("drawingSheet"): + cmd += ["--drawing-sheet", params["drawingSheet"]] + for kv in params.get("defineVar", []) or []: + cmd += ["--define-var", kv] + + value_map = { + "theme": "--theme", + "drillShapeOpt": "--drill-shape-opt", + } + for key, flag in value_map.items(): + val = params.get(key) + if val is not None and val != "": + cmd += [flag, str(val)] + + flag_map = { + "mirror": "--mirror", + "excludeRefdes": "--exclude-refdes", + "excludeValue": "--exclude-value", + "includeBorderTitle": "--include-border-title", + "subtractSoldermask": "--subtract-soldermask", + "sketchPadsOnFabLayers": "--sketch-pads-on-fab-layers", + "hideDnpFootprintsOnFabLayers": "--hide-DNP-footprints-on-fab-layers", + "sketchDnpFootprintsOnFabLayers": "--sketch-DNP-footprints-on-fab-layers", + "crossoutDnpFootprintsOnFabLayers": "--crossout-DNP-footprints-on-fab-layers", + "negative": "--negative", + "blackAndWhite": "--black-and-white", + "modeSingle": "--mode-single", + "modeSeparate": "--mode-separate", + "modeMultipage": "--mode-multipage", + } + for key, flag in flag_map.items(): + if params.get(key): + cmd.append(flag) + + cmd.append(board_path) + + logger.info(f"Running: {' '.join(cmd)}") + result = subprocess.run(cmd, capture_output=True, text=True, timeout=180) + + if result.returncode != 0: + return { + "success": False, + "message": f"kicad-cli failed (exit {result.returncode}): " + f"{result.stderr.strip()}", + } + return {"success": True, "outputPath": output_path} + + except FileNotFoundError: + return {"success": False, "message": "kicad-cli not found in PATH"} + except subprocess.TimeoutExpired: + return {"success": False, "message": "kicad-cli timed out after 180 seconds"} + except Exception as e: + logger.error(f"Error exporting PCB PDF: {e}") + return {"success": False, "message": str(e)} + + def _handle_export_pcb_svg(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Plot the PCB to SVG via kicad-cli (`pcb export svg`). + + Exposes the full layer-plot option set: layer lists, mirror, soldermask + subtraction, negative/B&W, theme, DNP fab-layer modes, page-size mode, + fit-page, drill-shape, and single/multi output modes. Reads the saved + .kicad_pcb. + """ + import subprocess + + logger.info("Exporting PCB SVG via kicad-cli") + try: + board_path = params.get("boardPath") or self._current_board_path() + output_path = params.get("outputPath") + + if not board_path: + return { + "success": False, + "message": "boardPath is required (no current board could be resolved)", + } + if not Path(board_path).exists(): + return {"success": False, "message": f"Board not found: {board_path}"} + if not output_path: + return {"success": False, "message": "outputPath is required"} + + kicad_cli = self._find_kicad_cli_static() + if not kicad_cli: + return {"success": False, "message": "kicad-cli not found in PATH"} + + output_path = str(Path(output_path).expanduser().resolve()) + parent = Path(output_path).parent + if parent: + Path(parent).mkdir(parents=True, exist_ok=True) + + cmd = [kicad_cli, "pcb", "export", "svg", "--output", output_path] + + layers = params.get("layers") + if layers: + cmd += ["--layers", ",".join(layers) if isinstance(layers, list) else str(layers)] + common_layers = params.get("commonLayers") + if common_layers: + cmd += [ + "--common-layers", + ( + ",".join(common_layers) + if isinstance(common_layers, list) + else str(common_layers) + ), + ] + + if params.get("drawingSheet"): + cmd += ["--drawing-sheet", params["drawingSheet"]] + for kv in params.get("defineVar", []) or []: + cmd += ["--define-var", kv] + + value_map = { + "theme": "--theme", + "pageSizeMode": "--page-size-mode", + "drillShapeOpt": "--drill-shape-opt", + } + for key, flag in value_map.items(): + val = params.get(key) + if val is not None and val != "": + cmd += [flag, str(val)] + + flag_map = { + "subtractSoldermask": "--subtract-soldermask", + "mirror": "--mirror", + "negative": "--negative", + "blackAndWhite": "--black-and-white", + "sketchPadsOnFabLayers": "--sketch-pads-on-fab-layers", + "hideDnpFootprintsOnFabLayers": "--hide-DNP-footprints-on-fab-layers", + "sketchDnpFootprintsOnFabLayers": "--sketch-DNP-footprints-on-fab-layers", + "crossoutDnpFootprintsOnFabLayers": "--crossout-DNP-footprints-on-fab-layers", + "fitPageToBoard": "--fit-page-to-board", + "excludeDrawingSheet": "--exclude-drawing-sheet", + "modeSingle": "--mode-single", + "modeMulti": "--mode-multi", + } + for key, flag in flag_map.items(): + if params.get(key): + cmd.append(flag) + + cmd.append(board_path) + + logger.info(f"Running: {' '.join(cmd)}") + result = subprocess.run(cmd, capture_output=True, text=True, timeout=180) + + if result.returncode != 0: + return { + "success": False, + "message": f"kicad-cli failed (exit {result.returncode}): " + f"{result.stderr.strip()}", + } + return {"success": True, "outputPath": output_path} + + except FileNotFoundError: + return {"success": False, "message": "kicad-cli not found in PATH"} + except subprocess.TimeoutExpired: + return {"success": False, "message": "kicad-cli timed out after 180 seconds"} + except Exception as e: + logger.error(f"Error exporting PCB SVG: {e}") + return {"success": False, "message": str(e)} + + def _handle_export_pcb_dxf(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Plot the PCB to DXF via kicad-cli (`pcb export dxf`). + + Exposes the full layer-plot option set: layer lists, refdes/value + exclusion, soldermask subtraction, contours, drill origin, border+title, + output units, DNP fab-layer modes, drill-shape, and single/multi output + modes. Reads the saved .kicad_pcb. + """ + import subprocess + + logger.info("Exporting PCB DXF via kicad-cli") + try: + board_path = params.get("boardPath") or self._current_board_path() + output_path = params.get("outputPath") + + if not board_path: + return { + "success": False, + "message": "boardPath is required (no current board could be resolved)", + } + if not Path(board_path).exists(): + return {"success": False, "message": f"Board not found: {board_path}"} + if not output_path: + return {"success": False, "message": "outputPath is required"} + + kicad_cli = self._find_kicad_cli_static() + if not kicad_cli: + return {"success": False, "message": "kicad-cli not found in PATH"} + + output_path = str(Path(output_path).expanduser().resolve()) + parent = Path(output_path).parent + if parent: + Path(parent).mkdir(parents=True, exist_ok=True) + + cmd = [kicad_cli, "pcb", "export", "dxf", "--output", output_path] + + layers = params.get("layers") + if layers: + cmd += ["--layers", ",".join(layers) if isinstance(layers, list) else str(layers)] + common_layers = params.get("commonLayers") + if common_layers: + cmd += [ + "--common-layers", + ( + ",".join(common_layers) + if isinstance(common_layers, list) + else str(common_layers) + ), + ] + + if params.get("drawingSheet"): + cmd += ["--drawing-sheet", params["drawingSheet"]] + for kv in params.get("defineVar", []) or []: + cmd += ["--define-var", kv] + + value_map = { + "outputUnits": "--output-units", + "drillShapeOpt": "--drill-shape-opt", + } + for key, flag in value_map.items(): + val = params.get(key) + if val is not None and val != "": + cmd += [flag, str(val)] + + flag_map = { + "excludeRefdes": "--exclude-refdes", + "excludeValue": "--exclude-value", + "sketchPadsOnFabLayers": "--sketch-pads-on-fab-layers", + "hideDnpFootprintsOnFabLayers": "--hide-DNP-footprints-on-fab-layers", + "sketchDnpFootprintsOnFabLayers": "--sketch-DNP-footprints-on-fab-layers", + "crossoutDnpFootprintsOnFabLayers": "--crossout-DNP-footprints-on-fab-layers", + "subtractSoldermask": "--subtract-soldermask", + "useContours": "--use-contours", + "useDrillOrigin": "--use-drill-origin", + "includeBorderTitle": "--include-border-title", + "modeSingle": "--mode-single", + "modeMulti": "--mode-multi", + } + for key, flag in flag_map.items(): + if params.get(key): + cmd.append(flag) + + cmd.append(board_path) + + logger.info(f"Running: {' '.join(cmd)}") + result = subprocess.run(cmd, capture_output=True, text=True, timeout=180) + + if result.returncode != 0: + return { + "success": False, + "message": f"kicad-cli failed (exit {result.returncode}): " + f"{result.stderr.strip()}", + } + return {"success": True, "outputPath": output_path} + + except FileNotFoundError: + return {"success": False, "message": "kicad-cli not found in PATH"} + except subprocess.TimeoutExpired: + return {"success": False, "message": "kicad-cli timed out after 180 seconds"} + except Exception as e: + logger.error(f"Error exporting PCB DXF: {e}") + return {"success": False, "message": str(e)} + + def _handle_export_gerber_single(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Plot the given layers to a single Gerber file via kicad-cli + (`pcb export gerber`). + + Singular sibling of export_gerbers: emits ONE Gerber file containing the + selected layers. Exposes the full single-file Plot option set (X2, + netlist attributes, DNP fab-layer modes, soldermask subtraction, aperture + macros, drill-file origin, precision, Protel extension). Reads the saved + .kicad_pcb. + """ + import subprocess + + logger.info("Exporting single Gerber via kicad-cli") + try: + board_path = params.get("boardPath") or self._current_board_path() + output_path = params.get("outputPath") + + if not board_path: + return { + "success": False, + "message": "boardPath is required (no current board could be resolved)", + } + if not Path(board_path).exists(): + return {"success": False, "message": f"Board not found: {board_path}"} + if not output_path: + return {"success": False, "message": "outputPath is required"} + + kicad_cli = self._find_kicad_cli_static() + if not kicad_cli: + return {"success": False, "message": "kicad-cli not found in PATH"} + + output_path = str(Path(output_path).expanduser().resolve()) + parent = Path(output_path).parent + if parent: + Path(parent).mkdir(parents=True, exist_ok=True) + + cmd = [kicad_cli, "pcb", "export", "gerber", "--output", output_path] + + layers = params.get("layers") + if layers: + cmd += ["--layers", ",".join(layers) if isinstance(layers, list) else str(layers)] + common_layers = params.get("commonLayers") + if common_layers: + cmd += [ + "--common-layers", + ( + ",".join(common_layers) + if isinstance(common_layers, list) + else str(common_layers) + ), + ] + + if params.get("drawingSheet"): + cmd += ["--drawing-sheet", params["drawingSheet"]] + for kv in params.get("defineVar", []) or []: + cmd += ["--define-var", kv] + + flag_map = { + "excludeRefdes": "--exclude-refdes", + "excludeValue": "--exclude-value", + "includeBorderTitle": "--include-border-title", + "sketchPadsOnFabLayers": "--sketch-pads-on-fab-layers", + "hideDnpFootprintsOnFabLayers": "--hide-DNP-footprints-on-fab-layers", + "sketchDnpFootprintsOnFabLayers": "--sketch-DNP-footprints-on-fab-layers", + "crossoutDnpFootprintsOnFabLayers": "--crossout-DNP-footprints-on-fab-layers", + "noX2": "--no-x2", + "noNetlist": "--no-netlist", + "subtractSoldermask": "--subtract-soldermask", + "disableApertureMacros": "--disable-aperture-macros", + "useDrillFileOrigin": "--use-drill-file-origin", + "noProtelExt": "--no-protel-ext", + } + for key, flag in flag_map.items(): + if params.get(key): + cmd.append(flag) + + precision = params.get("precision") + if precision is not None: + cmd += ["--precision", str(precision)] + + cmd.append(board_path) + + logger.info(f"Running: {' '.join(cmd)}") + result = subprocess.run(cmd, capture_output=True, text=True, timeout=180) + + if result.returncode != 0: + return { + "success": False, + "message": f"kicad-cli failed (exit {result.returncode}): " + f"{result.stderr.strip()}", + } + return {"success": True, "outputPath": output_path} + + except FileNotFoundError: + return {"success": False, "message": "kicad-cli not found in PATH"} + except subprocess.TimeoutExpired: + return {"success": False, "message": "kicad-cli timed out after 180 seconds"} + except Exception as e: + logger.error(f"Error exporting single Gerber: {e}") + return {"success": False, "message": str(e)} + + def _handle_export_3d_cli(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Export a 3D model of the PCB via kicad-cli (`pcb export `). + + Rich CLI sibling of export_3d / export_vrml. The ``format`` param selects + the subcommand (step, glb, stl, ply, brep, xao, vrml) and only flags valid + for that subcommand are forwarded. STEP/glb/stl/ply/brep/xao share the + geometry/include flag set; vrml uses units + models-dir instead. Reads the + saved .kicad_pcb. + """ + import subprocess + + logger.info("Exporting 3D model via kicad-cli") + try: + board_path = params.get("boardPath") or self._current_board_path() + output_path = params.get("outputPath") + fmt = params.get("format") + + if not board_path: + return { + "success": False, + "message": "boardPath is required (no current board could be resolved)", + } + if not Path(board_path).exists(): + return {"success": False, "message": f"Board not found: {board_path}"} + if not output_path: + return {"success": False, "message": "outputPath is required"} + valid_formats = ("step", "glb", "stl", "ply", "brep", "xao", "vrml") + if fmt not in valid_formats: + return { + "success": False, + "message": f"format must be one of {valid_formats}", + } + + kicad_cli = self._find_kicad_cli_static() + if not kicad_cli: + return {"success": False, "message": "kicad-cli not found in PATH"} + + output_path = str(Path(output_path).expanduser().resolve()) + parent = Path(output_path).parent + if parent: + Path(parent).mkdir(parents=True, exist_ok=True) + + cmd = [kicad_cli, "pcb", "export", fmt, "--output", output_path] + + for kv in params.get("defineVar", []) or []: + cmd += ["--define-var", kv] + + # Flags common to ALL 3D subcommands + common_flags = { + "force": "--force", + "noUnspecified": "--no-unspecified", + "noDnp": "--no-dnp", + } + # Flags shared by step/glb/stl/ply/brep/xao (NOT vrml) + mesh_flags = { + "gridOrigin": "--grid-origin", + "drillOrigin": "--drill-origin", + "substModels": "--subst-models", + "boardOnly": "--board-only", + "cutViasInBody": "--cut-vias-in-body", + "noBoardBody": "--no-board-body", + "noComponents": "--no-components", + "includeTracks": "--include-tracks", + "includePads": "--include-pads", + "includeZones": "--include-zones", + "includeInnerCopper": "--include-inner-copper", + "includeSilkscreen": "--include-silkscreen", + "includeSoldermask": "--include-soldermask", + "fuseShapes": "--fuse-shapes", + "fillAllVias": "--fill-all-vias", + } + # STEP-only flag + step_flags = { + "noOptimizeStep": "--no-optimize-step", + } + + flag_map = dict(common_flags) + if fmt != "vrml": + flag_map.update(mesh_flags) + if fmt == "step": + flag_map.update(step_flags) + for key, flag in flag_map.items(): + if params.get(key): + cmd.append(flag) + + # Valued flags common to all: user-origin + if params.get("userOrigin") is not None and params.get("userOrigin") != "": + cmd += ["--user-origin", str(params["userOrigin"])] + # Component filter + net filter + min distance: mesh subcommands only + if fmt != "vrml": + if params.get("componentFilter") is not None and params["componentFilter"] != "": + cmd += ["--component-filter", str(params["componentFilter"])] + if params.get("netFilter") is not None and params["netFilter"] != "": + cmd += ["--net-filter", str(params["netFilter"])] + if params.get("minDistance") is not None and params["minDistance"] != "": + cmd += ["--min-distance", str(params["minDistance"])] + # VRML-only valued flags + if fmt == "vrml": + if params.get("units") is not None and params["units"] != "": + cmd += ["--units", str(params["units"])] + if params.get("modelsDir") is not None and params["modelsDir"] != "": + cmd += ["--models-dir", str(params["modelsDir"])] + if params.get("modelsRelative"): + cmd.append("--models-relative") + + cmd.append(board_path) + + logger.info(f"Running: {' '.join(cmd)}") + result = subprocess.run(cmd, capture_output=True, text=True, timeout=300) + + if result.returncode != 0: + return { + "success": False, + "message": f"kicad-cli failed (exit {result.returncode}): " + f"{result.stderr.strip()}", + } + return {"success": True, "outputPath": output_path} + + except FileNotFoundError: + return {"success": False, "message": "kicad-cli not found in PATH"} + except subprocess.TimeoutExpired: + return {"success": False, "message": "kicad-cli timed out after 300 seconds"} + except Exception as e: + logger.error(f"Error exporting 3D model: {e}") + return {"success": False, "message": str(e)} + + def _handle_export_sch_bom(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Generate a Bill of Materials from a schematic via kicad-cli + (`sch export bom`). + + Exposes the full BOM option set: presets, field/label lists, grouping, + sorting, filtering, DNP/excluded handling, and the field/string/ref + delimiters. schematicPath is required (no current-schematic resolver). + """ + import subprocess + + logger.info("Exporting schematic BOM via kicad-cli") + try: + schematic_path = params.get("schematicPath") + output_path = params.get("outputPath") + + if not schematic_path: + return {"success": False, "message": "schematicPath is required"} + if not Path(schematic_path).exists(): + return {"success": False, "message": f"Schematic not found: {schematic_path}"} + if not output_path: + return {"success": False, "message": "outputPath is required"} + + kicad_cli = self._find_kicad_cli_static() + if not kicad_cli: + return {"success": False, "message": "kicad-cli not found in PATH"} + + output_path = str(Path(output_path).expanduser().resolve()) + parent = Path(output_path).parent + if parent: + Path(parent).mkdir(parents=True, exist_ok=True) + + cmd = [kicad_cli, "sch", "export", "bom", "--output", output_path] + + value_map = { + "preset": "--preset", + "formatPreset": "--format-preset", + "fields": "--fields", + "labels": "--labels", + "groupBy": "--group-by", + "sortField": "--sort-field", + "sortAsc": "--sort-asc", + "filter": "--filter", + "fieldDelimiter": "--field-delimiter", + "stringDelimiter": "--string-delimiter", + "refDelimiter": "--ref-delimiter", + "refRangeDelimiter": "--ref-range-delimiter", + } + for key, flag in value_map.items(): + val = params.get(key) + if val is not None and val != "": + cmd += [flag, str(val)] + + flag_map = { + "excludeDnp": "--exclude-dnp", + "includeExcludedFromBom": "--include-excluded-from-bom", + "keepTabs": "--keep-tabs", + "keepLineBreaks": "--keep-line-breaks", + } + for key, flag in flag_map.items(): + if params.get(key): + cmd.append(flag) + + cmd.append(schematic_path) + + logger.info(f"Running: {' '.join(cmd)}") + result = subprocess.run(cmd, capture_output=True, text=True, timeout=180) + + if result.returncode != 0: + return { + "success": False, + "message": f"kicad-cli failed (exit {result.returncode}): " + f"{result.stderr.strip()}", + } + return {"success": True, "outputPath": output_path} + + except FileNotFoundError: + return {"success": False, "message": "kicad-cli not found in PATH"} + except subprocess.TimeoutExpired: + return {"success": False, "message": "kicad-cli timed out after 180 seconds"} + except Exception as e: + logger.error(f"Error exporting schematic BOM: {e}") + return {"success": False, "message": str(e)} + + def _handle_export_sch_pdf(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Export a schematic to PDF via kicad-cli (`sch export pdf`). + + Exposes the full option set: drawing-sheet override, theme, B&W, + exclude-drawing-sheet, default-font, the PDF popup/link/metadata + excludes, no-background-color, and page selection. schematicPath is + required. + """ + import subprocess + + logger.info("Exporting schematic PDF via kicad-cli") + try: + schematic_path = params.get("schematicPath") + output_path = params.get("outputPath") + + if not schematic_path: + return {"success": False, "message": "schematicPath is required"} + if not Path(schematic_path).exists(): + return {"success": False, "message": f"Schematic not found: {schematic_path}"} + if not output_path: + return {"success": False, "message": "outputPath is required"} + + kicad_cli = self._find_kicad_cli_static() + if not kicad_cli: + return {"success": False, "message": "kicad-cli not found in PATH"} + + output_path = str(Path(output_path).expanduser().resolve()) + parent = Path(output_path).parent + if parent: + Path(parent).mkdir(parents=True, exist_ok=True) + + cmd = [kicad_cli, "sch", "export", "pdf", "--output", output_path] + + if params.get("drawingSheet"): + cmd += ["--drawing-sheet", params["drawingSheet"]] + for kv in params.get("defineVar", []) or []: + cmd += ["--define-var", kv] + + value_map = { + "theme": "--theme", + "defaultFont": "--default-font", + "pages": "--pages", + } + for key, flag in value_map.items(): + val = params.get(key) + if val is not None and val != "": + cmd += [flag, str(val)] + + flag_map = { + "blackAndWhite": "--black-and-white", + "excludeDrawingSheet": "--exclude-drawing-sheet", + "excludePdfPropertyPopups": "--exclude-pdf-property-popups", + "excludePdfHierarchicalLinks": "--exclude-pdf-hierarchical-links", + "excludePdfMetadata": "--exclude-pdf-metadata", + "noBackgroundColor": "--no-background-color", + } + for key, flag in flag_map.items(): + if params.get(key): + cmd.append(flag) + + cmd.append(schematic_path) + + logger.info(f"Running: {' '.join(cmd)}") + result = subprocess.run(cmd, capture_output=True, text=True, timeout=180) + + if result.returncode != 0: + return { + "success": False, + "message": f"kicad-cli failed (exit {result.returncode}): " + f"{result.stderr.strip()}", + } + return {"success": True, "outputPath": output_path} + + except FileNotFoundError: + return {"success": False, "message": "kicad-cli not found in PATH"} + except subprocess.TimeoutExpired: + return {"success": False, "message": "kicad-cli timed out after 180 seconds"} + except Exception as e: + logger.error(f"Error exporting schematic PDF: {e}") + return {"success": False, "message": str(e)} + + def _handle_export_sch_svg(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Export a schematic to SVG via kicad-cli (`sch export svg`). + + Output is a directory (one SVG per page). Exposes drawing-sheet override, + theme, B&W, exclude-drawing-sheet, default-font, no-background-color, and + page selection. schematicPath is required. + """ + import subprocess + + logger.info("Exporting schematic SVG via kicad-cli") + try: + schematic_path = params.get("schematicPath") + output_dir = params.get("outputDir") + + if not schematic_path: + return {"success": False, "message": "schematicPath is required"} + if not Path(schematic_path).exists(): + return {"success": False, "message": f"Schematic not found: {schematic_path}"} + if not output_dir: + return {"success": False, "message": "outputDir is required"} + + kicad_cli = self._find_kicad_cli_static() + if not kicad_cli: + return {"success": False, "message": "kicad-cli not found in PATH"} + + output_dir = str(Path(output_dir).expanduser().resolve()) + Path(output_dir).mkdir(parents=True, exist_ok=True) + + cmd = [kicad_cli, "sch", "export", "svg", "--output", output_dir] + + if params.get("drawingSheet"): + cmd += ["--drawing-sheet", params["drawingSheet"]] + for kv in params.get("defineVar", []) or []: + cmd += ["--define-var", kv] + + value_map = { + "theme": "--theme", + "defaultFont": "--default-font", + "pages": "--pages", + } + for key, flag in value_map.items(): + val = params.get(key) + if val is not None and val != "": + cmd += [flag, str(val)] + + flag_map = { + "blackAndWhite": "--black-and-white", + "excludeDrawingSheet": "--exclude-drawing-sheet", + "noBackgroundColor": "--no-background-color", + } + for key, flag in flag_map.items(): + if params.get(key): + cmd.append(flag) + + cmd.append(schematic_path) + + logger.info(f"Running: {' '.join(cmd)}") + result = subprocess.run(cmd, capture_output=True, text=True, timeout=180) + + if result.returncode != 0: + return { + "success": False, + "message": f"kicad-cli failed (exit {result.returncode}): " + f"{result.stderr.strip()}", + } + + files = sorted(p.name for p in Path(output_dir).iterdir() if p.is_file()) + return {"success": True, "outputDir": output_dir, "files": files} + + except FileNotFoundError: + return {"success": False, "message": "kicad-cli not found in PATH"} + except subprocess.TimeoutExpired: + return {"success": False, "message": "kicad-cli timed out after 180 seconds"} + except Exception as e: + logger.error(f"Error exporting schematic SVG: {e}") + return {"success": False, "message": str(e)} + + def _handle_export_sch_dxf(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Export a schematic to DXF via kicad-cli (`sch export dxf`). + + Output is a directory (one DXF per page). Exposes drawing-sheet override, + theme, B&W, exclude-drawing-sheet, default-font, and page selection. + schematicPath is required. + """ + import subprocess + + logger.info("Exporting schematic DXF via kicad-cli") + try: + schematic_path = params.get("schematicPath") + output_dir = params.get("outputDir") + + if not schematic_path: + return {"success": False, "message": "schematicPath is required"} + if not Path(schematic_path).exists(): + return {"success": False, "message": f"Schematic not found: {schematic_path}"} + if not output_dir: + return {"success": False, "message": "outputDir is required"} + + kicad_cli = self._find_kicad_cli_static() + if not kicad_cli: + return {"success": False, "message": "kicad-cli not found in PATH"} + + output_dir = str(Path(output_dir).expanduser().resolve()) + Path(output_dir).mkdir(parents=True, exist_ok=True) + + cmd = [kicad_cli, "sch", "export", "dxf", "--output", output_dir] + + if params.get("drawingSheet"): + cmd += ["--drawing-sheet", params["drawingSheet"]] + for kv in params.get("defineVar", []) or []: + cmd += ["--define-var", kv] + + value_map = { + "theme": "--theme", + "defaultFont": "--default-font", + "pages": "--pages", + } + for key, flag in value_map.items(): + val = params.get(key) + if val is not None and val != "": + cmd += [flag, str(val)] + + flag_map = { + "blackAndWhite": "--black-and-white", + "excludeDrawingSheet": "--exclude-drawing-sheet", + } + for key, flag in flag_map.items(): + if params.get(key): + cmd.append(flag) + + cmd.append(schematic_path) + + logger.info(f"Running: {' '.join(cmd)}") + result = subprocess.run(cmd, capture_output=True, text=True, timeout=180) + + if result.returncode != 0: + return { + "success": False, + "message": f"kicad-cli failed (exit {result.returncode}): " + f"{result.stderr.strip()}", + } + + files = sorted(p.name for p in Path(output_dir).iterdir() if p.is_file()) + return {"success": True, "outputDir": output_dir, "files": files} + + except FileNotFoundError: + return {"success": False, "message": "kicad-cli not found in PATH"} + except subprocess.TimeoutExpired: + return {"success": False, "message": "kicad-cli timed out after 180 seconds"} + except Exception as e: + logger.error(f"Error exporting schematic DXF: {e}") + return {"success": False, "message": str(e)} + + def _handle_export_sch_hpgl(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Export a schematic to HPGL via kicad-cli (`sch export hpgl`). + + Output is a directory (one plot per page). Exposes drawing-sheet + override, exclude-drawing-sheet, default-font, page selection, pen size, + and the origin/scale mode. schematicPath is required. + """ + import subprocess + + logger.info("Exporting schematic HPGL via kicad-cli") + try: + schematic_path = params.get("schematicPath") + output_dir = params.get("outputDir") + + if not schematic_path: + return {"success": False, "message": "schematicPath is required"} + if not Path(schematic_path).exists(): + return {"success": False, "message": f"Schematic not found: {schematic_path}"} + if not output_dir: + return {"success": False, "message": "outputDir is required"} + + kicad_cli = self._find_kicad_cli_static() + if not kicad_cli: + return {"success": False, "message": "kicad-cli not found in PATH"} + + output_dir = str(Path(output_dir).expanduser().resolve()) + Path(output_dir).mkdir(parents=True, exist_ok=True) + + cmd = [kicad_cli, "sch", "export", "hpgl", "--output", output_dir] + + if params.get("drawingSheet"): + cmd += ["--drawing-sheet", params["drawingSheet"]] + for kv in params.get("defineVar", []) or []: + cmd += ["--define-var", kv] + + value_map = { + "defaultFont": "--default-font", + "pages": "--pages", + "penSize": "--pen-size", + "origin": "--origin", + } + for key, flag in value_map.items(): + val = params.get(key) + if val is not None and val != "": + cmd += [flag, str(val)] + + if params.get("excludeDrawingSheet"): + cmd.append("--exclude-drawing-sheet") + + cmd.append(schematic_path) + + logger.info(f"Running: {' '.join(cmd)}") + result = subprocess.run(cmd, capture_output=True, text=True, timeout=180) + + if result.returncode != 0: + return { + "success": False, + "message": f"kicad-cli failed (exit {result.returncode}): " + f"{result.stderr.strip()}", + } + + files = sorted(p.name for p in Path(output_dir).iterdir() if p.is_file()) + return {"success": True, "outputDir": output_dir, "files": files} + + except FileNotFoundError: + return {"success": False, "message": "kicad-cli not found in PATH"} + except subprocess.TimeoutExpired: + return {"success": False, "message": "kicad-cli timed out after 180 seconds"} + except Exception as e: + logger.error(f"Error exporting schematic HPGL: {e}") + return {"success": False, "message": str(e)} + + def _handle_export_sch_ps(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Export a schematic to PostScript via kicad-cli (`sch export ps`). + + Output is a directory (one PS per page). Exposes drawing-sheet override, + theme, B&W, exclude-drawing-sheet, default-font, no-background-color, and + page selection. schematicPath is required. + """ + import subprocess + + logger.info("Exporting schematic PostScript via kicad-cli") + try: + schematic_path = params.get("schematicPath") + output_dir = params.get("outputDir") + + if not schematic_path: + return {"success": False, "message": "schematicPath is required"} + if not Path(schematic_path).exists(): + return {"success": False, "message": f"Schematic not found: {schematic_path}"} + if not output_dir: + return {"success": False, "message": "outputDir is required"} + + kicad_cli = self._find_kicad_cli_static() + if not kicad_cli: + return {"success": False, "message": "kicad-cli not found in PATH"} + + output_dir = str(Path(output_dir).expanduser().resolve()) + Path(output_dir).mkdir(parents=True, exist_ok=True) + + cmd = [kicad_cli, "sch", "export", "ps", "--output", output_dir] + + if params.get("drawingSheet"): + cmd += ["--drawing-sheet", params["drawingSheet"]] + for kv in params.get("defineVar", []) or []: + cmd += ["--define-var", kv] + + value_map = { + "theme": "--theme", + "defaultFont": "--default-font", + "pages": "--pages", + } + for key, flag in value_map.items(): + val = params.get(key) + if val is not None and val != "": + cmd += [flag, str(val)] + + flag_map = { + "blackAndWhite": "--black-and-white", + "excludeDrawingSheet": "--exclude-drawing-sheet", + "noBackgroundColor": "--no-background-color", + } + for key, flag in flag_map.items(): + if params.get(key): + cmd.append(flag) + + cmd.append(schematic_path) + + logger.info(f"Running: {' '.join(cmd)}") + result = subprocess.run(cmd, capture_output=True, text=True, timeout=180) + + if result.returncode != 0: + return { + "success": False, + "message": f"kicad-cli failed (exit {result.returncode}): " + f"{result.stderr.strip()}", + } + + files = sorted(p.name for p in Path(output_dir).iterdir() if p.is_file()) + return {"success": True, "outputDir": output_dir, "files": files} + + except FileNotFoundError: + return {"success": False, "message": "kicad-cli not found in PATH"} + except subprocess.TimeoutExpired: + return {"success": False, "message": "kicad-cli timed out after 180 seconds"} + except Exception as e: + logger.error(f"Error exporting schematic PostScript: {e}") + return {"success": False, "message": str(e)} + + def _handle_export_sch_python_bom(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Export the legacy Python-BOM XML from a schematic via kicad-cli + (`sch export python-bom`). + + Emits the legacy intermediate XML netlist consumed by the schematic + editor's Python BOM scripts. Minimal option set (output + input only). + schematicPath is required. + """ + import subprocess + + logger.info("Exporting schematic Python-BOM via kicad-cli") + try: + schematic_path = params.get("schematicPath") + output_path = params.get("outputPath") + + if not schematic_path: + return {"success": False, "message": "schematicPath is required"} + if not Path(schematic_path).exists(): + return {"success": False, "message": f"Schematic not found: {schematic_path}"} + if not output_path: + return {"success": False, "message": "outputPath is required"} + + kicad_cli = self._find_kicad_cli_static() + if not kicad_cli: + return {"success": False, "message": "kicad-cli not found in PATH"} + + output_path = str(Path(output_path).expanduser().resolve()) + parent = Path(output_path).parent + if parent: + Path(parent).mkdir(parents=True, exist_ok=True) + + cmd = [kicad_cli, "sch", "export", "python-bom", "--output", output_path] + cmd.append(schematic_path) + + logger.info(f"Running: {' '.join(cmd)}") + result = subprocess.run(cmd, capture_output=True, text=True, timeout=180) + + if result.returncode != 0: + return { + "success": False, + "message": f"kicad-cli failed (exit {result.returncode}): " + f"{result.stderr.strip()}", + } + return {"success": True, "outputPath": output_path} + + except FileNotFoundError: + return {"success": False, "message": "kicad-cli not found in PATH"} + except subprocess.TimeoutExpired: + return {"success": False, "message": "kicad-cli timed out after 180 seconds"} + except Exception as e: + logger.error(f"Error exporting schematic Python-BOM: {e}") + return {"success": False, "message": str(e)} + def _handle_generate_netlist(self, params: Dict[str, Any]) -> Dict[str, Any]: """Generate netlist from schematic and return structured JSON. diff --git a/src/tools/export.ts b/src/tools/export.ts index 41d3f90..955d1c2 100644 --- a/src/tools/export.ts +++ b/src/tools/export.ts @@ -323,5 +323,1024 @@ export function registerExportTools(server: McpServer, callKicadScript: CommandF }, ); + // ------------------------------------------------------ + // Export Gerbers Tool (kicad-cli, full option set) + // ------------------------------------------------------ + server.tool( + "export_gerbers", + "Plot Gerber files for a PCB via kicad-cli, exposing the full Plot-dialog option set (X2, netlist attributes, DNP handling, soldermask subtraction, precision, drill-file origin, stored board plot settings, etc). Reads the board from disk, so it reflects the last SAVED state of the .kicad_pcb.", + { + outputDir: z.string().describe("Output directory for the Gerber files"), + boardPath: z.string().optional().describe("Path to the .kicad_pcb (default: current board)"), + layers: z + .array(z.string()) + .optional() + .describe("Layers to plot, untranslated names e.g. ['F.Cu','B.Cu','Edge.Cuts']"), + commonLayers: z + .array(z.string()) + .optional() + .describe("Layers to include on every plot (e.g. ['Edge.Cuts'])"), + drawingSheet: z.string().optional().describe("Path to a drawing sheet override"), + defineVar: z + .array(z.string()) + .optional() + .describe("Project variable overrides as 'KEY=VALUE' strings"), + excludeRefdes: z.boolean().optional().describe("Exclude reference designator text"), + excludeValue: z.boolean().optional().describe("Exclude value text"), + includeBorderTitle: z.boolean().optional().describe("Include border and title block"), + sketchPadsOnFabLayers: z + .boolean() + .optional() + .describe("Draw pad outlines and numbers on fab layers"), + hideDnpFootprintsOnFabLayers: z + .boolean() + .optional() + .describe("Don't plot DNP footprint text/graphics on fab layers"), + sketchDnpFootprintsOnFabLayers: z + .boolean() + .optional() + .describe("Plot DNP footprints in sketch mode on fab layers"), + crossoutDnpFootprintsOnFabLayers: z + .boolean() + .optional() + .describe("Plot an 'X' over DNP footprint courtyards and strike out their refdes"), + noX2: z.boolean().optional().describe("Do not use the extended X2 Gerber format"), + noNetlist: z.boolean().optional().describe("Do not generate netlist attributes"), + subtractSoldermask: z.boolean().optional().describe("Subtract soldermask from silkscreen"), + disableApertureMacros: z.boolean().optional().describe("Disable aperture macros"), + useDrillFileOrigin: z.boolean().optional().describe("Use the drill/place file origin"), + noProtelExt: z + .boolean() + .optional() + .describe("Use KiCad Gerber file extensions instead of Protel"), + boardPlotParams: z + .boolean() + .optional() + .describe("Use the Gerber plot settings already stored in the board file"), + precision: z.number().optional().describe("Gerber coordinate precision: 5 or 6 (default 6)"), + }, + async (args) => { + logger.debug(`Exporting Gerbers to: ${args.outputDir}`); + const result = await callKicadScript("export_gerbers", args); + return { + content: [ + { + type: "text", + text: JSON.stringify(result), + }, + ], + }; + }, + ); + + // ------------------------------------------------------ + // Export Drill Files Tool (kicad-cli) + // ------------------------------------------------------ + server.tool( + "export_drill", + "Generate drill files for a PCB via kicad-cli, exposing the full Excellon/Gerber drill option set (format, drill origin, zero suppression, oval format, units, mirror-Y, minimal header, separate PTH/NPTH files, drill map + map format). Reads the last SAVED state of the .kicad_pcb.", + { + outputDir: z.string().describe("Output directory for the drill files"), + boardPath: z.string().optional().describe("Path to the .kicad_pcb (default: current board)"), + format: z + .enum(["excellon", "gerber"]) + .optional() + .describe("Drill file format (default excellon)"), + drillOrigin: z + .enum(["absolute", "plot"]) + .optional() + .describe("Drill coordinate origin (default absolute)"), + excellonZerosFormat: z + .enum(["decimal", "suppressleading", "suppresstrailing", "keep"]) + .optional() + .describe("Excellon zero-suppression format (default decimal)"), + excellonOvalFormat: z + .enum(["route", "alternate"]) + .optional() + .describe("Excellon oval hole format (default alternate)"), + excellonUnits: z.enum(["in", "mm"]).optional().describe("Excellon output units (default mm)"), + excellonMirrorY: z.boolean().optional().describe("Mirror the Y axis (Excellon)"), + excellonMinHeader: z.boolean().optional().describe("Use a minimal Excellon header"), + excellonSeparateTh: z + .boolean() + .optional() + .describe("Generate independent files for NPTH and PTH holes"), + generateMap: z.boolean().optional().describe("Generate a drill map / summary file"), + mapFormat: z + .enum(["pdf", "gerberx2", "ps", "dxf", "svg"]) + .optional() + .describe("Drill map format when generateMap is set (default pdf)"), + gerberPrecision: z + .number() + .optional() + .describe("Gerber coordinate precision (5 or 6) when format=gerber"), + }, + async (args) => { + logger.debug(`Exporting drill files to: ${args.outputDir}`); + const result = await callKicadScript("export_drill", args); + return { + content: [ + { + type: "text", + text: JSON.stringify(result), + }, + ], + }; + }, + ); + + // ------------------------------------------------------ + // Export IPC-2581 Tool (kicad-cli) + // ------------------------------------------------------ + server.tool( + "export_ipc2581", + "Export the PCB in IPC-2581 format via kicad-cli. Single-file MES/CAD interchange carrying placement, nets and BOM part data inline. The bomCol* params map schematic fields to the embedded BOM columns (e.g. internal P/N, manufacturer P/N) — useful for assembly/MES imports. Reads the last SAVED state of the .kicad_pcb.", + { + outputPath: z.string().describe("Output .xml file path"), + boardPath: z.string().optional().describe("Path to the .kicad_pcb (default: current board)"), + drawingSheet: z.string().optional().describe("Path to a drawing sheet override"), + defineVar: z + .array(z.string()) + .optional() + .describe("Project variable overrides as 'KEY=VALUE' strings"), + precision: z.number().optional().describe("Coordinate precision (default 6)"), + compress: z.boolean().optional().describe("Compress the output"), + version: z.string().optional().describe("IPC-2581 standard version (default 'C')"), + units: z.enum(["mm", "in"]).optional().describe("Units (default mm)"), + bomColIntId: z + .string() + .optional() + .describe("Schematic field to use for the BOM Internal Id column"), + bomColMfgPn: z + .string() + .optional() + .describe("Schematic field to use for the BOM Manufacturer Part Number column"), + bomColMfg: z + .string() + .optional() + .describe("Schematic field to use for the BOM Manufacturer column"), + bomColDistPn: z + .string() + .optional() + .describe("Schematic field to use for the BOM Distributor Part Number column"), + bomColDist: z.string().optional().describe("Value to insert into the BOM Distributor column"), + }, + async (args) => { + logger.debug(`Exporting IPC-2581 to: ${args.outputPath}`); + const result = await callKicadScript("export_ipc2581", args); + return { + content: [ + { + type: "text", + text: JSON.stringify(result), + }, + ], + }; + }, + ); + + // ------------------------------------------------------ + // Export ODB++ Tool (kicad-cli) + // ------------------------------------------------------ + server.tool( + "export_odb", + "Export the PCB in ODB++ format via kicad-cli. Single job archive (copper, drill, placement, components, nets, outline) widely used by CAM/MES/assembly. Reads the last SAVED state of the .kicad_pcb.", + { + outputPath: z.string().describe("Output file path (archive or directory per compression)"), + boardPath: z.string().optional().describe("Path to the .kicad_pcb (default: current board)"), + drawingSheet: z.string().optional().describe("Path to a drawing sheet override"), + defineVar: z + .array(z.string()) + .optional() + .describe("Project variable overrides as 'KEY=VALUE' strings"), + precision: z.number().optional().describe("Coordinate precision (default 2)"), + compression: z + .enum(["zip", "tgz", "none"]) + .optional() + .describe("Output container/compression mode (default zip)"), + units: z.enum(["mm", "in"]).optional().describe("Units (default mm)"), + }, + async (args) => { + logger.debug(`Exporting ODB++ to: ${args.outputPath}`); + const result = await callKicadScript("export_odb", args); + return { + content: [ + { + type: "text", + text: JSON.stringify(result), + }, + ], + }; + }, + ); + + // ------------------------------------------------------ + // Export IPC-D-356 Netlist Tool (kicad-cli) + // ------------------------------------------------------ + server.tool( + "export_ipcd356", + "Generate an IPC-D-356 bare-board electrical-test netlist via kicad-cli. Consumed by flying-probe and bed-of-nails testers. Reads the last SAVED state of the .kicad_pcb.", + { + outputPath: z.string().describe("Output .ipc / netlist file path"), + boardPath: z.string().optional().describe("Path to the .kicad_pcb (default: current board)"), + }, + async (args) => { + logger.debug(`Exporting IPC-D-356 netlist to: ${args.outputPath}`); + const result = await callKicadScript("export_ipcd356", args); + return { + content: [ + { + type: "text", + text: JSON.stringify(result), + }, + ], + }; + }, + ); + + // ------------------------------------------------------ + // Export GenCAD Tool (kicad-cli) + // ------------------------------------------------------ + server.tool( + "export_gencad", + "Export the PCB in GenCAD format via kicad-cli. Assembly/test interchange format. Exposes padstack flip, unique pin/footprint shape generation, drill-file origin, and store-origin-coordinate options. Reads the last SAVED state of the .kicad_pcb.", + { + outputPath: z.string().describe("Output .cad file path"), + boardPath: z.string().optional().describe("Path to the .kicad_pcb (default: current board)"), + defineVar: z + .array(z.string()) + .optional() + .describe("Project variable overrides as 'KEY=VALUE' strings"), + flipBottomPads: z.boolean().optional().describe("Flip bottom footprint padstacks"), + uniquePins: z.boolean().optional().describe("Generate unique pin names"), + uniqueFootprints: z + .boolean() + .optional() + .describe("Generate a new shape for each footprint instance (do not reuse shapes)"), + useDrillOrigin: z.boolean().optional().describe("Use drill/place file origin as origin"), + storeOriginCoord: z.boolean().optional().describe("Save the origin coordinates in the file"), + }, + async (args) => { + logger.debug(`Exporting GenCAD to: ${args.outputPath}`); + const result = await callKicadScript("export_gencad", args); + return { + content: [ + { + type: "text", + text: JSON.stringify(result), + }, + ], + }; + }, + ); + + // ------------------------------------------------------ + // Export Position File Tool (kicad-cli, full option set) + // ------------------------------------------------------ + server.tool( + "export_pos", + "Generate a component placement (position / pick-and-place) file via kicad-cli, exposing the full CLI option set (side, format, units, bottom-negate-X, drill-file origin, SMD-only, exclude through-hole / DNP, gerber board edge). Rich CLI sibling of export_position_file. Reads the last SAVED state of the .kicad_pcb.", + { + outputPath: z.string().describe("Output position file path"), + boardPath: z.string().optional().describe("Path to the .kicad_pcb (default: current board)"), + side: z + .enum(["front", "back", "both"]) + .optional() + .describe("Board side (gerber format only supports front or back; default both)"), + format: z + .enum(["ascii", "csv", "gerber"]) + .optional() + .describe("Output format (default ascii)"), + units: z + .enum(["in", "mm"]) + .optional() + .describe("Output units; ascii or csv format only (default in)"), + bottomNegateX: z + .boolean() + .optional() + .describe("Use negative X coordinates for bottom-layer footprints (ascii/csv only)"), + useDrillFileOrigin: z + .boolean() + .optional() + .describe("Use drill/place file origin (ascii/csv only)"), + smdOnly: z.boolean().optional().describe("Include only SMD footprints (ascii/csv only)"), + excludeFpTh: z + .boolean() + .optional() + .describe("Exclude all footprints with through-hole pads (ascii/csv only)"), + excludeDnp: z + .boolean() + .optional() + .describe("Exclude all footprints with the Do Not Populate flag set"), + gerberBoardEdge: z.boolean().optional().describe("Include board edge layer (Gerber only)"), + }, + async (args) => { + logger.debug(`Exporting position file to: ${args.outputPath}`); + const result = await callKicadScript("export_pos", args); + return { + content: [ + { + type: "text", + text: JSON.stringify(result), + }, + ], + }; + }, + ); + + // ------------------------------------------------------ + // Export PCB PDF Tool (kicad-cli, full layer-plot option set) + // ------------------------------------------------------ + server.tool( + "export_pcb_pdf", + "Plot the PCB layout to PDF via kicad-cli, exposing the full layer-plot option set (layer + common-layer lists, mirror, refdes/value exclusion, border+title, soldermask subtraction, DNP fab-layer modes, negative, black-and-white, theme, drill shape, and single/separate/multipage output modes). Rich CLI sibling of export_pdf. Reads the last SAVED state of the .kicad_pcb.", + { + outputPath: z.string().describe("Output PDF file path (or directory in separate mode)"), + boardPath: z.string().optional().describe("Path to the .kicad_pcb (default: current board)"), + layers: z + .array(z.string()) + .optional() + .describe("Layers to plot, untranslated names e.g. ['F.Cu','B.Cu','Edge.Cuts']"), + commonLayers: z + .array(z.string()) + .optional() + .describe("Layers to include on every plot (e.g. ['Edge.Cuts'])"), + drawingSheet: z.string().optional().describe("Path to a drawing sheet override"), + defineVar: z + .array(z.string()) + .optional() + .describe("Project variable overrides as 'KEY=VALUE' strings"), + mirror: z.boolean().optional().describe("Mirror the board (to show bottom layers)"), + excludeRefdes: z.boolean().optional().describe("Exclude reference designator text"), + excludeValue: z.boolean().optional().describe("Exclude value text"), + includeBorderTitle: z.boolean().optional().describe("Include the border and title block"), + subtractSoldermask: z.boolean().optional().describe("Subtract soldermask from silkscreen"), + sketchPadsOnFabLayers: z + .boolean() + .optional() + .describe("Draw pad outlines and numbers on fab layers"), + hideDnpFootprintsOnFabLayers: z + .boolean() + .optional() + .describe("Don't plot DNP footprint text/graphics on fab layers"), + sketchDnpFootprintsOnFabLayers: z + .boolean() + .optional() + .describe("Plot DNP footprints in sketch mode on fab layers"), + crossoutDnpFootprintsOnFabLayers: z + .boolean() + .optional() + .describe("Plot an 'X' over DNP footprint courtyards and strike out their refdes"), + negative: z.boolean().optional().describe("Plot as negative"), + blackAndWhite: z.boolean().optional().describe("Black and white only"), + theme: z.string().optional().describe("Color theme to use (default: PCB editor settings)"), + drillShapeOpt: z + .number() + .optional() + .describe("Pad/via drill shape option (0 none, 1 small, 2 actual; default 2)"), + modeSingle: z + .boolean() + .optional() + .describe("Single file; output path is full path; LAYER_LIST controls all layers"), + modeSeparate: z.boolean().optional().describe("Plot the layers to individual PDF files"), + modeMultipage: z.boolean().optional().describe("Plot the layers to a single multi-page PDF"), + }, + async (args) => { + logger.debug(`Exporting PCB PDF to: ${args.outputPath}`); + const result = await callKicadScript("export_pcb_pdf", args); + return { + content: [ + { + type: "text", + text: JSON.stringify(result), + }, + ], + }; + }, + ); + + // ------------------------------------------------------ + // Export PCB SVG Tool (kicad-cli, full layer-plot option set) + // ------------------------------------------------------ + server.tool( + "export_pcb_svg", + "Plot the PCB layout to SVG via kicad-cli, exposing the full layer-plot option set (layer + common-layer lists, mirror, soldermask subtraction, negative, black-and-white, theme, DNP fab-layer modes, page-size mode, fit-page-to-board, exclude-drawing-sheet, drill shape, single/multi output modes). Rich CLI sibling of export_svg. Reads the last SAVED state of the .kicad_pcb.", + { + outputPath: z.string().describe("Output SVG file path (or directory in multi mode)"), + boardPath: z.string().optional().describe("Path to the .kicad_pcb (default: current board)"), + layers: z + .array(z.string()) + .optional() + .describe("Layers to plot, untranslated names e.g. ['F.Cu','B.Cu','Edge.Cuts']"), + commonLayers: z + .array(z.string()) + .optional() + .describe("Layers to include on every plot (e.g. ['Edge.Cuts'])"), + drawingSheet: z.string().optional().describe("Path to a drawing sheet override"), + defineVar: z + .array(z.string()) + .optional() + .describe("Project variable overrides as 'KEY=VALUE' strings"), + subtractSoldermask: z.boolean().optional().describe("Subtract soldermask from silkscreen"), + mirror: z.boolean().optional().describe("Mirror the board (to show bottom layers)"), + negative: z.boolean().optional().describe("Plot as negative"), + blackAndWhite: z.boolean().optional().describe("Black and white only"), + theme: z.string().optional().describe("Color theme to use (default: PCB editor settings)"), + sketchPadsOnFabLayers: z + .boolean() + .optional() + .describe("Draw pad outlines and numbers on fab layers"), + hideDnpFootprintsOnFabLayers: z + .boolean() + .optional() + .describe("Don't plot DNP footprint text/graphics on fab layers"), + sketchDnpFootprintsOnFabLayers: z + .boolean() + .optional() + .describe("Plot DNP footprints in sketch mode on fab layers"), + crossoutDnpFootprintsOnFabLayers: z + .boolean() + .optional() + .describe("Plot an 'X' over DNP footprint courtyards and strike out their refdes"), + pageSizeMode: z + .number() + .optional() + .describe("Page sizing mode (0 frame+title block, 1 current page size, 2 board area only)"), + fitPageToBoard: z.boolean().optional().describe("Fit the page to the board"), + excludeDrawingSheet: z.boolean().optional().describe("No drawing sheet"), + drillShapeOpt: z + .number() + .optional() + .describe("Pad/via drill shape option (0 none, 1 small, 2 actual; default 2)"), + modeSingle: z + .boolean() + .optional() + .describe("Single file; output path is full path; LAYER_LIST controls all layers"), + modeMulti: z + .boolean() + .optional() + .describe("Multi output; output path is a directory (GUI-like plotting)"), + }, + async (args) => { + logger.debug(`Exporting PCB SVG to: ${args.outputPath}`); + const result = await callKicadScript("export_pcb_svg", args); + return { + content: [ + { + type: "text", + text: JSON.stringify(result), + }, + ], + }; + }, + ); + + // ------------------------------------------------------ + // Export PCB DXF Tool (kicad-cli, full layer-plot option set) + // ------------------------------------------------------ + server.tool( + "export_pcb_dxf", + "Plot the PCB layout to DXF via kicad-cli, exposing the full layer-plot option set (layer + common-layer lists, refdes/value exclusion, soldermask subtraction, use-contours, use-drill-origin, border+title, output units, DNP fab-layer modes, drill shape, single/multi output modes). Reads the last SAVED state of the .kicad_pcb.", + { + outputPath: z.string().describe("Output DXF file path (or directory in multi mode)"), + boardPath: z.string().optional().describe("Path to the .kicad_pcb (default: current board)"), + layers: z + .array(z.string()) + .optional() + .describe("Layers to plot, untranslated names e.g. ['F.Cu','B.Cu','Edge.Cuts']"), + commonLayers: z + .array(z.string()) + .optional() + .describe("Layers to include on every plot (e.g. ['Edge.Cuts'])"), + drawingSheet: z.string().optional().describe("Path to a drawing sheet override"), + defineVar: z + .array(z.string()) + .optional() + .describe("Project variable overrides as 'KEY=VALUE' strings"), + excludeRefdes: z.boolean().optional().describe("Exclude reference designator text"), + excludeValue: z.boolean().optional().describe("Exclude value text"), + sketchPadsOnFabLayers: z + .boolean() + .optional() + .describe("Draw pad outlines and numbers on fab layers"), + hideDnpFootprintsOnFabLayers: z + .boolean() + .optional() + .describe("Don't plot DNP footprint text/graphics on fab layers"), + sketchDnpFootprintsOnFabLayers: z + .boolean() + .optional() + .describe("Plot DNP footprints in sketch mode on fab layers"), + crossoutDnpFootprintsOnFabLayers: z + .boolean() + .optional() + .describe("Plot an 'X' over DNP footprint courtyards and strike out their refdes"), + subtractSoldermask: z.boolean().optional().describe("Subtract soldermask from silkscreen"), + useContours: z.boolean().optional().describe("Plot graphic items using their contours"), + useDrillOrigin: z.boolean().optional().describe("Plot using the drill/place file origin"), + includeBorderTitle: z.boolean().optional().describe("Include the border and title block"), + outputUnits: z.enum(["mm", "in"]).optional().describe("Output units (default in)"), + drillShapeOpt: z + .number() + .optional() + .describe("Pad/via drill shape option (0 none, 1 small, 2 actual; default 2)"), + modeSingle: z + .boolean() + .optional() + .describe("Single file; output path is full path; LAYER_LIST controls all layers"), + modeMulti: z + .boolean() + .optional() + .describe("Multi output; output path is a directory (GUI-like plotting)"), + }, + async (args) => { + logger.debug(`Exporting PCB DXF to: ${args.outputPath}`); + const result = await callKicadScript("export_pcb_dxf", args); + return { + content: [ + { + type: "text", + text: JSON.stringify(result), + }, + ], + }; + }, + ); + + // ------------------------------------------------------ + // Export Single Gerber Tool (kicad-cli, full Plot option set) + // ------------------------------------------------------ + server.tool( + "export_gerber_single", + "Plot the given layers to a SINGLE Gerber file via kicad-cli (`pcb export gerber`). Singular sibling of export_gerbers. Exposes the full single-file Plot option set (X2, netlist attributes, DNP fab-layer modes, soldermask subtraction, aperture macros, drill-file origin, precision, Protel extension). Reads the last SAVED state of the .kicad_pcb.", + { + outputPath: z.string().describe("Output Gerber file path"), + boardPath: z.string().optional().describe("Path to the .kicad_pcb (default: current board)"), + layers: z + .array(z.string()) + .optional() + .describe("Layers to plot, untranslated names e.g. ['F.Cu','B.Cu','Edge.Cuts']"), + commonLayers: z + .array(z.string()) + .optional() + .describe("Layers to include on every plot (e.g. ['Edge.Cuts'])"), + drawingSheet: z.string().optional().describe("Path to a drawing sheet override"), + defineVar: z + .array(z.string()) + .optional() + .describe("Project variable overrides as 'KEY=VALUE' strings"), + excludeRefdes: z.boolean().optional().describe("Exclude reference designator text"), + excludeValue: z.boolean().optional().describe("Exclude value text"), + includeBorderTitle: z.boolean().optional().describe("Include the border and title block"), + sketchPadsOnFabLayers: z + .boolean() + .optional() + .describe("Draw pad outlines and numbers on fab layers"), + hideDnpFootprintsOnFabLayers: z + .boolean() + .optional() + .describe("Don't plot DNP footprint text/graphics on fab layers"), + sketchDnpFootprintsOnFabLayers: z + .boolean() + .optional() + .describe("Plot DNP footprints in sketch mode on fab layers"), + crossoutDnpFootprintsOnFabLayers: z + .boolean() + .optional() + .describe("Plot an 'X' over DNP footprint courtyards and strike out their refdes"), + noX2: z.boolean().optional().describe("Do not use the extended X2 Gerber format"), + noNetlist: z.boolean().optional().describe("Do not generate netlist attributes"), + subtractSoldermask: z.boolean().optional().describe("Subtract soldermask from silkscreen"), + disableApertureMacros: z.boolean().optional().describe("Disable aperture macros"), + useDrillFileOrigin: z.boolean().optional().describe("Use the drill/place file origin"), + noProtelExt: z + .boolean() + .optional() + .describe("Use KiCad Gerber file extensions instead of Protel"), + precision: z.number().optional().describe("Gerber coordinate precision: 5 or 6 (default 6)"), + }, + async (args) => { + logger.debug(`Exporting single Gerber to: ${args.outputPath}`); + const result = await callKicadScript("export_gerber_single", args); + return { + content: [ + { + type: "text", + text: JSON.stringify(result), + }, + ], + }; + }, + ); + + // ------------------------------------------------------ + // Export 3D Model Tool (kicad-cli, format dispatch + full flag union) + // ------------------------------------------------------ + server.tool( + "export_3d_cli", + "Export a 3D model of the PCB via kicad-cli. The `format` param selects the subcommand (step, glb, stl, ply, brep, xao, vrml); only flags valid for that subcommand are forwarded. STEP/glb/stl/ply/brep/xao share the geometry/include flag set (no-board-body, include-tracks/pads/zones/inner-copper/silkscreen/soldermask, fuse-shapes, fill-all-vias, component/net filters, min-distance, etc.; no-optimize-step is STEP-only); vrml uses units + models-dir/models-relative instead. Rich CLI sibling of export_3d and export_vrml. Reads the last SAVED state of the .kicad_pcb.", + { + outputPath: z.string().describe("Output 3D model file path"), + format: z + .enum(["step", "glb", "stl", "ply", "brep", "xao", "vrml"]) + .describe("3D output format (selects the kicad-cli subcommand)"), + boardPath: z.string().optional().describe("Path to the .kicad_pcb (default: current board)"), + defineVar: z + .array(z.string()) + .optional() + .describe("Project variable overrides as 'KEY=VALUE' strings"), + // common to all formats + force: z.boolean().optional().describe("Overwrite output file"), + noUnspecified: z + .boolean() + .optional() + .describe("Exclude 3D models for components with 'Unspecified' footprint type"), + noDnp: z + .boolean() + .optional() + .describe("Exclude 3D models for components with 'Do not populate' attribute"), + userOrigin: z + .string() + .optional() + .describe("User-specified output origin e.g. '1x1in', '25.4x25.4mm' (default unit mm)"), + // mesh subcommands only (step/glb/stl/ply/brep/xao) + gridOrigin: z.boolean().optional().describe("Use Grid Origin for output origin"), + drillOrigin: z.boolean().optional().describe("Use Drill Origin for output origin"), + substModels: z + .boolean() + .optional() + .describe("Substitute STEP/IGS models in place of VRML models"), + boardOnly: z.boolean().optional().describe("Only generate a board with no components"), + cutViasInBody: z + .boolean() + .optional() + .describe("Cut via holes in board body even if conductor layers not exported"), + noBoardBody: z.boolean().optional().describe("Exclude board body"), + noComponents: z.boolean().optional().describe("Exclude 3D models for components"), + componentFilter: z + .string() + .optional() + .describe("Only include component models matching this refdes list (comma, wildcards)"), + includeTracks: z.boolean().optional().describe("Export tracks and vias"), + includePads: z.boolean().optional().describe("Export pads"), + includeZones: z.boolean().optional().describe("Export zones"), + includeInnerCopper: z.boolean().optional().describe("Export elements on inner copper layers"), + includeSilkscreen: z + .boolean() + .optional() + .describe("Export silkscreen graphics as flat faces"), + includeSoldermask: z.boolean().optional().describe("Export soldermask layers as flat faces"), + fuseShapes: z.boolean().optional().describe("Fuse overlapping geometry together"), + fillAllVias: z.boolean().optional().describe("Don't cut via holes in conductor layers"), + minDistance: z + .string() + .optional() + .describe("Min distance between points to treat as separate (default '0.01mm')"), + netFilter: z + .string() + .optional() + .describe("Only include copper items belonging to nets matching this wildcard"), + // STEP only + noOptimizeStep: z + .boolean() + .optional() + .describe("Do not optimize STEP file (enables writing parametric curves; STEP only)"), + // VRML only + units: z + .enum(["mm", "m", "in", "tenths"]) + .optional() + .describe("Output units (VRML only; default in)"), + modelsDir: z + .string() + .optional() + .describe("Folder to store 3D models in (VRML only; empty = embed in main file)"), + modelsRelative: z + .boolean() + .optional() + .describe("Use relative model paths with modelsDir (VRML only)"), + }, + async (args) => { + logger.debug(`Exporting 3D model (${args.format}) to: ${args.outputPath}`); + const result = await callKicadScript("export_3d_cli", args); + return { + content: [ + { + type: "text", + text: JSON.stringify(result), + }, + ], + }; + }, + ); + + // ------------------------------------------------------ + // Export Schematic BOM Tool (kicad-cli, full option set) + // ------------------------------------------------------ + server.tool( + "export_sch_bom", + "Generate a Bill of Materials from a schematic via kicad-cli (`sch export bom`), exposing the full option set (presets, field/label lists, grouping, sorting, filtering, DNP/excluded-from-BOM handling, and field/string/ref delimiters). schematicPath is REQUIRED.", + { + schematicPath: z.string().describe("Path to the .kicad_sch (required)"), + outputPath: z.string().describe("Output BOM file path"), + preset: z + .string() + .optional() + .describe("Named BOM preset from the schematic, e.g. 'Grouped By Value'"), + formatPreset: z + .string() + .optional() + .describe("Named BOM format preset from the schematic, e.g. 'CSV'"), + fields: z + .string() + .optional() + .describe("Ordered comma list of fields to export (supports special substitutions)"), + labels: z + .string() + .optional() + .describe("Ordered comma list of labels to apply to the exported fields"), + groupBy: z + .string() + .optional() + .describe("Fields to group references by when field values match"), + sortField: z.string().optional().describe("Field name to sort by (default Reference)"), + sortAsc: z.string().optional().describe("Sort ascending ('true') or descending ('false')"), + filter: z.string().optional().describe("Filter string to remove output lines"), + excludeDnp: z.boolean().optional().describe("Exclude symbols marked Do-Not-Populate"), + includeExcludedFromBom: z + .boolean() + .optional() + .describe("Include symbols marked 'Exclude from BOM'"), + fieldDelimiter: z + .string() + .optional() + .describe("Separator between output fields/columns (default ',')"), + stringDelimiter: z + .string() + .optional() + .describe("Character to surround fields with (default '\"')"), + refDelimiter: z + .string() + .optional() + .describe("Character between individual references (default ',')"), + refRangeDelimiter: z + .string() + .optional() + .describe("Character for reference ranges; blank disables ranges (default '-')"), + keepTabs: z.boolean().optional().describe("Keep tab characters from input fields"), + keepLineBreaks: z + .boolean() + .optional() + .describe("Keep line break characters from input fields"), + }, + async (args) => { + logger.debug(`Exporting schematic BOM to: ${args.outputPath}`); + const result = await callKicadScript("export_sch_bom", args); + return { + content: [ + { + type: "text", + text: JSON.stringify(result), + }, + ], + }; + }, + ); + + // ------------------------------------------------------ + // Export Schematic PDF Tool (kicad-cli, full option set) + // ------------------------------------------------------ + server.tool( + "export_sch_pdf", + "Export a schematic to PDF via kicad-cli (`sch export pdf`), exposing the full option set (drawing-sheet override, theme, black-and-white, exclude-drawing-sheet, default-font, the PDF property-popup / hierarchical-link / metadata excludes, no-background-color, and page selection). schematicPath is REQUIRED.", + { + schematicPath: z.string().describe("Path to the .kicad_sch (required)"), + outputPath: z.string().describe("Output PDF file path"), + drawingSheet: z.string().optional().describe("Path to a drawing sheet override"), + defineVar: z + .array(z.string()) + .optional() + .describe("Project variable overrides as 'KEY=VALUE' strings"), + theme: z.string().optional().describe("Color theme to use (default: schematic settings)"), + blackAndWhite: z.boolean().optional().describe("Black and white only"), + excludeDrawingSheet: z.boolean().optional().describe("No drawing sheet"), + defaultFont: z.string().optional().describe("Default font name"), + excludePdfPropertyPopups: z + .boolean() + .optional() + .describe("Do not generate property popups in PDF"), + excludePdfHierarchicalLinks: z + .boolean() + .optional() + .describe("Do not generate clickable links for hierarchical elements in PDF"), + excludePdfMetadata: z + .boolean() + .optional() + .describe("Do not generate PDF metadata from AUTHOR and SUBJECT variables"), + noBackgroundColor: z + .boolean() + .optional() + .describe("Avoid setting a background color (regardless of theme)"), + pages: z + .string() + .optional() + .describe("Comma list of page numbers to print (blank = all pages)"), + }, + async (args) => { + logger.debug(`Exporting schematic PDF to: ${args.outputPath}`); + const result = await callKicadScript("export_sch_pdf", args); + return { + content: [ + { + type: "text", + text: JSON.stringify(result), + }, + ], + }; + }, + ); + + // ------------------------------------------------------ + // Export Schematic SVG Tool (kicad-cli, full option set) + // ------------------------------------------------------ + server.tool( + "export_sch_svg", + "Export a schematic to SVG via kicad-cli (`sch export svg`), one SVG per page into a directory. Exposes drawing-sheet override, theme, black-and-white, exclude-drawing-sheet, default-font, no-background-color, and page selection. schematicPath is REQUIRED.", + { + schematicPath: z.string().describe("Path to the .kicad_sch (required)"), + outputDir: z.string().describe("Output directory for the SVG files"), + drawingSheet: z.string().optional().describe("Path to a drawing sheet override"), + defineVar: z + .array(z.string()) + .optional() + .describe("Project variable overrides as 'KEY=VALUE' strings"), + theme: z.string().optional().describe("Color theme to use (default: schematic settings)"), + blackAndWhite: z.boolean().optional().describe("Black and white only"), + excludeDrawingSheet: z.boolean().optional().describe("No drawing sheet"), + defaultFont: z.string().optional().describe("Default font name"), + noBackgroundColor: z + .boolean() + .optional() + .describe("Avoid setting a background color (regardless of theme)"), + pages: z + .string() + .optional() + .describe("Comma list of page numbers to print (blank = all pages)"), + }, + async (args) => { + logger.debug(`Exporting schematic SVG to: ${args.outputDir}`); + const result = await callKicadScript("export_sch_svg", args); + return { + content: [ + { + type: "text", + text: JSON.stringify(result), + }, + ], + }; + }, + ); + + // ------------------------------------------------------ + // Export Schematic DXF Tool (kicad-cli, full option set) + // ------------------------------------------------------ + server.tool( + "export_sch_dxf", + "Export a schematic to DXF via kicad-cli (`sch export dxf`), one DXF per page into a directory. Exposes drawing-sheet override, theme, black-and-white, exclude-drawing-sheet, default-font, and page selection. schematicPath is REQUIRED.", + { + schematicPath: z.string().describe("Path to the .kicad_sch (required)"), + outputDir: z.string().describe("Output directory for the DXF files"), + drawingSheet: z.string().optional().describe("Path to a drawing sheet override"), + defineVar: z + .array(z.string()) + .optional() + .describe("Project variable overrides as 'KEY=VALUE' strings"), + theme: z.string().optional().describe("Color theme to use (default: schematic settings)"), + blackAndWhite: z.boolean().optional().describe("Black and white only"), + excludeDrawingSheet: z.boolean().optional().describe("No drawing sheet"), + defaultFont: z.string().optional().describe("Default font name"), + pages: z + .string() + .optional() + .describe("Comma list of page numbers to print (blank = all pages)"), + }, + async (args) => { + logger.debug(`Exporting schematic DXF to: ${args.outputDir}`); + const result = await callKicadScript("export_sch_dxf", args); + return { + content: [ + { + type: "text", + text: JSON.stringify(result), + }, + ], + }; + }, + ); + + // ------------------------------------------------------ + // Export Schematic HPGL Tool (kicad-cli, full option set) + // ------------------------------------------------------ + server.tool( + "export_sch_hpgl", + "Export a schematic to HPGL via kicad-cli (`sch export hpgl`), one plot per page into a directory. Exposes drawing-sheet override, exclude-drawing-sheet, default-font, page selection, pen size, and the origin/scale mode. schematicPath is REQUIRED.", + { + schematicPath: z.string().describe("Path to the .kicad_sch (required)"), + outputDir: z.string().describe("Output directory for the HPGL files"), + drawingSheet: z.string().optional().describe("Path to a drawing sheet override"), + defineVar: z + .array(z.string()) + .optional() + .describe("Project variable overrides as 'KEY=VALUE' strings"), + excludeDrawingSheet: z.boolean().optional().describe("No drawing sheet"), + defaultFont: z.string().optional().describe("Default font name"), + pages: z + .string() + .optional() + .describe("Comma list of page numbers to print (blank = all pages)"), + penSize: z.number().optional().describe("Pen size in mm (default 0.5)"), + origin: z + .number() + .optional() + .describe("Origin and scale: 0 bottom left, 1 centered, 2 page fit, 3 content fit"), + }, + async (args) => { + logger.debug(`Exporting schematic HPGL to: ${args.outputDir}`); + const result = await callKicadScript("export_sch_hpgl", args); + return { + content: [ + { + type: "text", + text: JSON.stringify(result), + }, + ], + }; + }, + ); + + // ------------------------------------------------------ + // Export Schematic PostScript Tool (kicad-cli, full option set) + // ------------------------------------------------------ + server.tool( + "export_sch_ps", + "Export a schematic to PostScript via kicad-cli (`sch export ps`), one PS per page into a directory. Exposes drawing-sheet override, theme, black-and-white, exclude-drawing-sheet, default-font, no-background-color, and page selection. schematicPath is REQUIRED.", + { + schematicPath: z.string().describe("Path to the .kicad_sch (required)"), + outputDir: z.string().describe("Output directory for the PostScript files"), + drawingSheet: z.string().optional().describe("Path to a drawing sheet override"), + defineVar: z + .array(z.string()) + .optional() + .describe("Project variable overrides as 'KEY=VALUE' strings"), + theme: z.string().optional().describe("Color theme to use (default: schematic settings)"), + blackAndWhite: z.boolean().optional().describe("Black and white only"), + excludeDrawingSheet: z.boolean().optional().describe("No drawing sheet"), + defaultFont: z.string().optional().describe("Default font name"), + noBackgroundColor: z + .boolean() + .optional() + .describe("Avoid setting a background color (regardless of theme)"), + pages: z + .string() + .optional() + .describe("Comma list of page numbers to print (blank = all pages)"), + }, + async (args) => { + logger.debug(`Exporting schematic PostScript to: ${args.outputDir}`); + const result = await callKicadScript("export_sch_ps", args); + return { + content: [ + { + type: "text", + text: JSON.stringify(result), + }, + ], + }; + }, + ); + + // ------------------------------------------------------ + // Export Schematic Python-BOM Tool (kicad-cli) + // ------------------------------------------------------ + server.tool( + "export_sch_python_bom", + "Export the legacy Python-BOM intermediate XML from a schematic via kicad-cli (`sch export python-bom`). This is the XML netlist consumed by the schematic editor's Python BOM scripts. Minimal option set (output + input only). schematicPath is REQUIRED.", + { + schematicPath: z.string().describe("Path to the .kicad_sch (required)"), + outputPath: z.string().describe("Output XML file path"), + }, + async (args) => { + logger.debug(`Exporting schematic Python-BOM to: ${args.outputPath}`); + const result = await callKicadScript("export_sch_python_bom", args); + return { + content: [ + { + type: "text", + text: JSON.stringify(result), + }, + ], + }; + }, + ); + logger.info("Export tools registered"); } diff --git a/src/tools/registry.ts b/src/tools/registry.ts index 6f1f569..b0e76ab 100644 --- a/src/tools/registry.ts +++ b/src/tools/registry.ts @@ -58,6 +58,25 @@ export const toolCategories: ToolCategory[] = [ description: "File export for fabrication and documentation: Gerber, PDF, BOM, 3D models", tools: [ "export_gerber", + "export_gerbers", + "export_drill", + "export_ipc2581", + "export_odb", + "export_ipcd356", + "export_gencad", + "export_pos", + "export_pcb_pdf", + "export_pcb_svg", + "export_pcb_dxf", + "export_gerber_single", + "export_3d_cli", + "export_sch_bom", + "export_sch_pdf", + "export_sch_svg", + "export_sch_dxf", + "export_sch_hpgl", + "export_sch_ps", + "export_sch_python_bom", "export_pdf", "export_svg", "export_3d", diff --git a/tests/test_export_cli.py b/tests/test_export_cli.py new file mode 100644 index 0000000..6b9d248 --- /dev/null +++ b/tests/test_export_cli.py @@ -0,0 +1,176 @@ +"""Unit tests for the kicad-cli-backed export handlers on ``KiCADInterface``. + +These handlers (``_handle_export_*``) shell out to ``kicad-cli``. The tests mock +the subprocess call and the filesystem, then assert command construction, +validation, and error handling. No real ``kicad-cli`` binary, board, or +schematic is touched. + +conftest.py pre-installs a MagicMock for ``pcbnew`` in sys.modules so +kicad_interface imports without a real KiCAD install. +""" + +import sys +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent / "python")) + +pytestmark = pytest.mark.unit + +from kicad_interface import KiCADInterface # noqa: E402 + +# (handler suffix, minimal params) — correct output key per handler, schematicPath for sch tools +PCB_HANDLERS = [ + ("gerbers", {"outputDir": "/out"}), + ("drill", {"outputDir": "/out"}), + ("ipc2581", {"outputPath": "/out/x.xml"}), + ("odb", {"outputPath": "/out/x.zip"}), + ("ipcd356", {"outputPath": "/out/x.d356"}), + ("gencad", {"outputPath": "/out/x.cad"}), + ("pos", {"outputPath": "/out/x.pos"}), + ("pcb_pdf", {"outputPath": "/out/x.pdf"}), + ("pcb_svg", {"outputPath": "/out/x.svg"}), + ("pcb_dxf", {"outputPath": "/out/x.dxf"}), + ("gerber_single", {"outputPath": "/out/x.gbr", "layers": ["F.Cu"]}), + ("3d_cli", {"outputPath": "/out/x.step", "format": "step"}), +] +SCH_HANDLERS = [ + ("sch_bom", {"outputPath": "/out/b.csv", "schematicPath": "/p/x.kicad_sch"}), + ("sch_pdf", {"outputPath": "/out/b.pdf", "schematicPath": "/p/x.kicad_sch"}), + ("sch_python_bom", {"outputPath": "/out/b.xml", "schematicPath": "/p/x.kicad_sch"}), + ("sch_svg", {"outputDir": "/out", "schematicPath": "/p/x.kicad_sch"}), + ("sch_dxf", {"outputDir": "/out", "schematicPath": "/p/x.kicad_sch"}), + ("sch_hpgl", {"outputDir": "/out", "schematicPath": "/p/x.kicad_sch"}), + ("sch_ps", {"outputDir": "/out", "schematicPath": "/p/x.kicad_sch"}), +] +ALL_HANDLERS = PCB_HANDLERS + SCH_HANDLERS + + +def _make_iface(): + """KiCADInterface instance with the cli/board-path helpers mocked.""" + iface = KiCADInterface.__new__(KiCADInterface) + iface.board = None + iface._find_kicad_cli_static = MagicMock(return_value="kicad-cli") + iface._current_board_path = MagicMock(return_value="/proj/board.kicad_pcb") + return iface + + +def _call(iface, suffix, params, rc=0, stderr=""): + """Invoke a handler with subprocess + filesystem mocked. Returns (result, run_mock).""" + method = getattr(iface, f"_handle_export_{suffix}") + fake = SimpleNamespace(returncode=rc, stdout="", stderr=stderr) + with ( + patch("subprocess.run", return_value=fake) as run, + patch("pathlib.Path.exists", return_value=True), + patch("pathlib.Path.mkdir"), + patch("pathlib.Path.iterdir", return_value=[]), + patch("pathlib.Path.is_file", return_value=True), + ): + result = method(dict(params)) + return result, run + + +class TestAllHandlers: + @pytest.mark.parametrize("suffix,params", ALL_HANDLERS) + def test_success_invokes_kicad_cli(self, suffix, params): + iface = _make_iface() + result, run = _call(iface, suffix, params, rc=0) + assert result["success"] is True, result + run.assert_called_once() + cmd = run.call_args.args[0] + assert cmd[0] == "kicad-cli" + assert cmd[1] in ("pcb", "sch") + assert cmd[2] == "export" + + @pytest.mark.parametrize("suffix,params", ALL_HANDLERS) + def test_cli_not_found(self, suffix, params): + iface = _make_iface() + iface._find_kicad_cli_static = MagicMock(return_value=None) + result, _ = _call(iface, suffix, params) + assert result["success"] is False + assert "kicad-cli" in result["message"].lower() + + @pytest.mark.parametrize("suffix,params", ALL_HANDLERS) + def test_subprocess_failure_propagates(self, suffix, params): + iface = _make_iface() + result, _ = _call(iface, suffix, params, rc=1, stderr="boom") + assert result["success"] is False + + +class TestValidation: + def test_missing_output_errors(self): + iface = _make_iface() + result, _ = _call(iface, "gerbers", {}) + assert result["success"] is False + + def test_pcb_no_board_resolvable_errors(self): + iface = _make_iface() + iface._current_board_path = MagicMock(return_value=None) + result, _ = _call(iface, "ipc2581", {"outputPath": "/o/x.xml"}) + assert result["success"] is False + + def test_sch_missing_schematic_errors(self): + iface = _make_iface() + result, _ = _call(iface, "sch_bom", {"outputPath": "/o/b.csv"}) + assert result["success"] is False + + +class TestFlagConstruction: + """Detailed flag mapping for the originally-authored handlers.""" + + def test_gerbers_flags(self): + iface = _make_iface() + _, run = _call( + iface, + "gerbers", + { + "outputDir": "/out", + "layers": ["F.Cu", "B.Cu"], + "subtractSoldermask": True, + "noX2": True, + "precision": 5, + }, + ) + cmd = run.call_args.args[0] + assert "--subtract-soldermask" in cmd + assert "--no-x2" in cmd + assert "--layers" in cmd + assert "F.Cu,B.Cu" in cmd + assert cmd[cmd.index("--precision") + 1] == "5" + + def test_gerbers_omitted_flags_absent(self): + iface = _make_iface() + _, run = _call(iface, "gerbers", {"outputDir": "/out"}) + cmd = run.call_args.args[0] + assert "--no-x2" not in cmd + assert "--subtract-soldermask" not in cmd + + def test_drill_flags(self): + iface = _make_iface() + _, run = _call( + iface, + "drill", + { + "outputDir": "/out", + "format": "gerber", + "excellonSeparateTh": True, + "generateMap": True, + }, + ) + cmd = run.call_args.args[0] + assert "--excellon-separate-th" in cmd + assert "--generate-map" in cmd + assert cmd[cmd.index("--format") + 1] == "gerber" + + def test_ipc2581_bom_column_mapping(self): + iface = _make_iface() + _, run = _call( + iface, + "ipc2581", + {"outputPath": "/o/x.xml", "bomColIntId": "FAST P/N"}, + ) + cmd = run.call_args.args[0] + assert cmd[cmd.index("--bom-col-int-id") + 1] == "FAST P/N"