app
KiCad - the KiCad Bridge
Public Made by Adomby adom
Reference implementation of the KiCad bridge: multi-instance Python server, forward path via kicad-cli, reverse path via in-process plugin. Most complex of the three bundled bridges.
← Commit history
v0.9.108: kicad_make_part_project (generate pro/sch/pcb around an installed part, for the Web Control per-part surfaces) + filePath support on install_symbol/install_footprint/model (big assets via send_files, no base64 CLI limit)
8 files changed
+373−40
BRIDGE_VERSION+1−1@@ -1 +1 @@-0.9.107+0.9.108
adom-bridge-kicad-manifest.json+5−5@@ -1,15 +1,15 @@ { "manifest_version": 1, "name": "kicad",- "version": "0.9.107",- "url": "https://wiki.adom.inc/download/adom/kicad-bridge/0.9.107/adom-bridge-kicad-v0.9.107.zip",- "sha256": "ec2619331181d5a912a7f6e80674215f0695af32002a116346324877f536830b",- "size": 397599,+ "version": "0.9.108",+ "url": "https://wiki.adom.inc/download/adom/kicad-bridge/0.9.108/adom-bridge-kicad-v0.9.108.zip",+ "sha256": "648be128ecc2fd57b36dec6385b01a76efe96207ae18b59d1aee6c7c0d735545",+ "size": 403548, "verbPrefixes": [ "kicad_" ], "healthEndpoint": "/status",- "released_at": "2026-08-15T10:56:53Z",+ "released_at": "2026-08-15T11:26:35Z", "hero": "https://wiki.adom.inc/blob/app/kicad-bridge/kicad-hero.png", "languages": [ "Python",
adom-bridge-kicad-v0.9.108.zipadded⋯ 1 unchanged line ⋯
bridge.json+1−1@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "kicad", "displayName": "KiCad EDA",- "version": "0.9.107",+ "version": "0.9.108", "description": "Reverse bridge for KiCad \u2014 board/schematic introspection, lint via kicad-cli, plugin install, multi-instance probe, in-process DRC.", "homepage": "https://wiki.adom.inc/adom/adom-bridge", "author": "Adom Inc.",
handlers/install_footprint.py+47−20@@ -31,16 +31,20 @@ def handle_install_footprint(kicad_info: dict, args: dict) -> dict: Args: kicad_info: KiCad detection info dict. args: Dict with:- - fileName (required): Original filename (e.g. "BMI423.kicad_mod")- - fileContent (required): Base64-encoded .kicad_mod file content+ - fileName (required unless filePath): Original filename (e.g. "BMI423.kicad_mod")+ - fileContent (required unless filePath): Base64-encoded .kicad_mod file content+ - filePath (alternative): absolute Windows path to a .kicad_mod already+ on this machine (0.9.108) — avoids base64/CLI size limits - footprintName (optional): Specific footprint to open in the editor - openEditor (optional): Whether to open Footprint Editor (default: true)- - model (optional): {fileName, fileContent} — a 3D model (.step/.wrl,- base64, binary-safe) installed to Documents/KiCad/<ver>/3dmodels/Adom/+ - model (optional): {fileName, fileContent} OR {filePath} — a 3D model+ (.step/.wrl, binary-safe) installed to Documents/KiCad/<ver>/3dmodels/Adom/ and wired into the footprint's (model ...) reference (injected if the footprint has none), so kicad_show_3d_chip renders the part. Wiki component pages ship symbol+footprint+step; without this the step- was dropped and the 3D view came up empty.+ was dropped and the 3D view came up empty. Big steps (>90KB) overflow+ the CLI arg limit as base64 — push with ab send_files, then pass+ model.filePath (0.9.108). Returns: Dict with:@@ -65,18 +69,31 @@ def handle_install_footprint(kicad_info: dict, args: dict) -> dict: quiet_install = args.get("quietInstall", False) open_editor = False if quiet_install else args.get("openEditor", True) - if not file_name:+ file_path = (args.get("filePath") or "").strip()+ if not file_path and not file_name: return {"success": False, "error": "No fileName specified"}- if not file_content:- return {"success": False, "error": "No fileContent specified"}+ if not file_path and not file_content:+ return {"success": False, "error": "No fileContent specified (or pass filePath)"} - # --- Step 1: Decode the file ---+ # --- Step 1: Read the file (disk path or base64) --- - try:- decoded = base64.b64decode(file_content)- footprint_text = decoded.decode("utf-8")- except Exception as e:- return {"success": False, "error": f"Failed to decode fileContent: {e}"}+ if file_path:+ p = Path(file_path)+ if not p.exists():+ return {"success": False, "error": f"filePath not found: {file_path}",+ "_hint": "Absolute Windows path on THIS machine — push it first (ab send_files) if it lives elsewhere."}+ try:+ footprint_text = p.read_text(encoding="utf-8")+ except Exception as e:+ return {"success": False, "error": f"Failed to read filePath: {e}"}+ if not file_name:+ file_name = p.name+ else:+ try:+ decoded = base64.b64decode(file_content)+ footprint_text = decoded.decode("utf-8")+ except Exception as e:+ return {"success": False, "error": f"Failed to decode fileContent: {e}"} # Extract footprint name from file content if not provided if not footprint_name:@@ -111,14 +128,24 @@ def handle_install_footprint(kicad_info: dict, args: dict) -> dict: model = args.get("model") or {} model_installed = ""- if model.get("fileName") and model.get("fileContent"):- try:- model_bytes = base64.b64decode(model["fileContent"])- except Exception as e:- return {"success": False, "error": f"Failed to decode model.fileContent: {e}"}+ model_src_path = (model.get("filePath") or "").strip()+ if model_src_path or (model.get("fileName") and model.get("fileContent")):+ if model_src_path:+ mp = Path(model_src_path)+ if not mp.exists():+ return {"success": False, "error": f"model.filePath not found: {model_src_path}",+ "_hint": "Absolute Windows path on THIS machine — push it first (ab send_files)."}+ model_bytes = mp.read_bytes()+ model_file_name = mp.name+ else:+ try:+ model_bytes = base64.b64decode(model["fileContent"])+ except Exception as e:+ return {"success": False, "error": f"Failed to decode model.fileContent: {e}"}+ model_file_name = model["fileName"] models_dir = Path(user_dir) / "3dmodels" / "Adom" models_dir.mkdir(parents=True, exist_ok=True)- model_path = models_dir / Path(model["fileName"]).name+ model_path = models_dir / Path(model_file_name).name model_path.write_bytes(model_bytes) model_installed = str(model_path) model_uri = str(model_path).replace("\\", "/")
handlers/install_symbol.py+27−13@@ -32,8 +32,11 @@ def handle_install_symbol(kicad_info: dict, args: dict) -> dict: Args: kicad_info: KiCad detection info dict. args: Dict with:- - fileName (required): Original filename (e.g. "BQ76952.kicad_sym")- - fileContent (required): Base64-encoded .kicad_sym file content+ - fileName (required unless filePath): Original filename (e.g. "BQ76952.kicad_sym")+ - fileContent (required unless filePath): Base64-encoded .kicad_sym file content+ - filePath (alternative): absolute Windows path to a .kicad_sym already+ on this machine (e.g. pushed via ab send_files) — avoids base64/CLI+ size limits for callers that stage files on disk (0.9.108) - symbolName (optional): Specific symbol to open in the editor - openEditor (optional): Whether to open Symbol Editor (default: true) @@ -60,18 +63,29 @@ def handle_install_symbol(kicad_info: dict, args: dict) -> dict: quiet_install = args.get("quietInstall", False) open_editor = False if quiet_install else args.get("openEditor", True) - if not file_name:+ file_path = (args.get("filePath") or "").strip()+ if not file_path and not file_name: return {"success": False, "error": "No fileName specified"}- if not file_content:- return {"success": False, "error": "No fileContent specified"}-- # --- Step 1: Decode the file ----- try:- decoded = base64.b64decode(file_content)- symbol_text = decoded.decode("utf-8")- except Exception as e:- return {"success": False, "error": f"Failed to decode fileContent: {e}"}+ if not file_path and not file_content:+ return {"success": False, "error": "No fileContent specified (or pass filePath)"}++ # --- Step 1: Read the file (disk path or base64) ---++ if file_path:+ p = Path(file_path)+ if not p.exists():+ return {"success": False, "error": f"filePath not found: {file_path}",+ "_hint": "Absolute Windows path on THIS machine — push it first (ab send_files) if it lives elsewhere."}+ try:+ symbol_text = p.read_text(encoding="utf-8")+ except Exception as e:+ return {"success": False, "error": f"Failed to read filePath: {e}"}+ else:+ try:+ decoded = base64.b64decode(file_content)+ symbol_text = decoded.decode("utf-8")+ except Exception as e:+ return {"success": False, "error": f"Failed to decode fileContent: {e}"} # --- Step 2: Merge into Adom.kicad_sym ---
handlers/make_part_project.pyadded+286@@ -0,0 +1,286 @@+"""make_part_project.py — kicad_make_part_project: generate a small, real KiCad+project AROUND an installed Adom-library part, so every project-level surface+(project / schematic / 2D board / 3D board) has something true to show for a+single component.++Why this exists (John, 2026-08-15, the Web Control demo): the demo buttons for+symbol / footprint / 3D chip / library map 1:1 onto show_* verbs, but schematic,+2D board, 3D board and project need a DESIGN that uses the part — and in the+electronics world you only get to a 3D board by having the library, schematic+and layout in place first. Callers (ab, web demos, AIs) should not hand-assemble+KiCad file formats; this verb generates them from the installed part:++ kicad_install_footprint {..., model:{...}} (part into the Adom library)+ kicad_install_symbol {...}+ kicad_make_part_project {"partName":"IN-S42ATR"}+ kicad_show_project / show_schematic / show_2d_board / show_3d_board {...}++Generated (formats identical to the proven kicad_demo generators):+ <out>/<project>.kicad_pro — minimal project+ <out>/<project>.kicad_sch — the part as D1 wired to a 330R series resistor+ (lib_symbols embedded => self-contained sheet)+ <out>/<project>.kicad_pcb — the part's footprint + an 0805 R placed inside+ edge cuts, with copper stubs; the footprint+ keeps its (model ...) so the 3D board shows the+ real part model.+"""+import math+import os+import re+import sys+from pathlib import Path++sys.path.insert(0, str(Path(__file__).parent.parent))++IS_WINDOWS = sys.platform == "win32"+++def _uuid(n: int) -> str:+ return f"00000000-0000-4000-8000-{n:012d}"+++def _find_symbol_block(lib_text: str, name: str) -> str | None:+ import adom_library+ for n, block in adom_library._extract_top_level_symbols(lib_text):+ if n == name:+ return block+ return None+++def _pins(sym_block: str):+ """[(x, y, angle_deg), ...] — pin (at ...) is the CONNECTION point."""+ return [(float(a), float(b), float(c)) for a, b, c in+ re.findall(r'\(pin\s+\S+\s+\S+\s*\n?\s*\(at\s+([-\d.]+)\s+([-\d.]+)\s+([-\d.]+)\)',+ sym_block)]+++def _pads(fp_text: str):+ """[(x, y), ...] for (pad "N" ... (at x y ...))."""+ return [(float(a), float(b)) for a, b in+ re.findall(r'\(pad\s+"[^"]*"[^()]*\(at\s+([-\d.]+)\s+([-\d.]+)', fp_text)]+++_R_LIB_ENTRY = ''' (symbol "Adom:R_330"+ (pin_numbers (hide yes)) (pin_names (offset 0) (hide yes))+ (exclude_from_sim no) (in_bom yes) (on_board yes)+ (property "Reference" "R" (at 2.54 1.27 0) (effects (font (size 1.27 1.27)) (justify left)))+ (property "Value" "330" (at 2.54 -1.27 0) (effects (font (size 1.27 1.27)) (justify left)))+ (property "Footprint" "Resistor_SMD:R_0805_2012Metric" (at 0 0 0) (effects (font (size 1.27 1.27)) (hide yes)))+ (property "Datasheet" "~" (at 0 0 0) (effects (font (size 1.27 1.27)) (hide yes)))+ (symbol "R_330_0_1"+ (rectangle (start -1.016 2.54) (end 1.016 -2.54)+ (stroke (width 0.254) (type default)) (fill (type none)))+ )+ (symbol "R_330_1_1"+ (pin passive line (at 0 3.81 270) (length 1.27) (name "~") (number "1"))+ (pin passive line (at 0 -3.81 90) (length 1.27) (name "~") (number "2"))+ )+ )+'''++_R_FP = '''(footprint "Resistor_SMD:R_0805_2012Metric"+ (version 20240108) (generator "adom-part-project") (layer "F.Cu")+ (at {rx} {ry})+ (attr smd)+ (property "Reference" "R1" (at 0 -1.6 0) (layer "F.SilkS") (effects (font (size 0.6 0.6) (thickness 0.1))))+ (property "Value" "330" (at 0 1.6 0) (layer "F.Fab") (effects (font (size 0.6 0.6) (thickness 0.1))))+ (fp_rect (start -1.7 -0.9) (end 1.7 0.9) (stroke (width 0.05) (type solid)) (fill none) (layer "F.CrtYd"))+ (pad "1" smd roundrect (at -0.9125 0) (size 1.025 1.4) (layers "F.Cu" "F.Paste" "F.Mask") (roundrect_rratio 0.25))+ (pad "2" smd roundrect (at 0.9125 0) (size 1.025 1.4) (layers "F.Cu" "F.Paste" "F.Mask") (roundrect_rratio 0.25))+)+'''+++def handle_make_part_project(kicad_info: dict, args: dict) -> dict:+ """Generate <project>.kicad_pro/.kicad_sch/.kicad_pcb around an installed part.++ Args:+ partName (required) — symbol name in the Adom library (install it first).+ footprintName — footprint in Adom.pretty (default: partName).+ projectName — default "<partname-lower>-demo".+ outputDir — default Documents/<projectName>. Overwritten if present.+ """+ part = (args.get("partName") or args.get("symbolName") or "").strip()+ if not part:+ return {"success": False, "error": "partName required",+ "_hint": "The symbol's name in the Adom library — kicad_install_symbol first."}+ fp_name = (args.get("footprintName") or part).strip()+ proj = (args.get("projectName") or "").strip() or \+ re.sub(r"[^a-z0-9-]+", "-", part.lower()).strip("-") + "-demo"++ user_dir = kicad_info.get("user_dir")+ if not user_dir:+ return {"success": False, "error": "KiCad user directory not found",+ "_hint": "kicad_status reports install state; run once with KiCad installed."}++ sym_lib = Path(user_dir) / "symbols" / "Adom.kicad_sym"+ if not sym_lib.exists():+ return {"success": False, "error": f"Adom library not found at {sym_lib}",+ "_hint": "kicad_install_symbol installs the part into the Adom library first."}+ block = _find_symbol_block(sym_lib.read_text(encoding="utf-8"), part)+ if not block:+ return {"success": False, "error": f"Symbol '{part}' not in the Adom library",+ "_hint": "kicad_install_symbol with the part's .kicad_sym first (wiki part pages ship it)."}++ fp_path = Path(user_dir) / "footprints" / "Adom.pretty" / f"{fp_name}.kicad_mod"+ if not fp_path.exists():+ return {"success": False, "error": f"Footprint '{fp_name}' not in Adom.pretty",+ "_hint": "kicad_install_footprint (with model:{...} for the 3D step) first."}+ fp_text = fp_path.read_text(encoding="utf-8")++ out_dir = Path(args.get("outputDir") or (Path(os.environ.get("USERPROFILE", str(Path.home())))+ / "Documents" / proj))+ out_dir.mkdir(parents=True, exist_ok=True)++ # ---------------- schematic ----------------+ sheet = _uuid(1)+ ax, ay = 127.0, 80.0 # part anchor on the sheet (y-down)+ lib_entry = " " + block.replace(f'(symbol "{part}"', f'(symbol "Adom:{part}"', 1)++ pins = _pins(block)+ wires, r_inst = "", ""+ r_connected = False+ wire_n = 300+ for px, py, pa in pins[:2]:+ cx, cy = ax + px, ay - py # symbol y-up -> sheet y-down+ # outward = away from the body = opposite the pin's pointing direction+ dx = -math.cos(math.radians(pa))+ dy = math.sin(math.radians(pa)) # y flip folded in+ ex, ey = round(cx + 8 * dx, 3), round(cy + 8 * dy, 3)+ if not r_connected and abs(dy) < 0.01 and dx > 0.5:+ # rightward pin: run this wire into R1's bottom pin. A pin's (at) is+ # its CONNECTION point (the line extends toward the body by length),+ # so R_330's bottom pin (at 0 -3.81) connects 3.81 below the anchor.+ ex = 145.0+ r_y = round(ey - 3.81, 3) # R center so its bottom pin lands on the wire end+ r_inst = f''' (symbol (lib_id "Adom:R_330") (at 145 {r_y} 0) (unit 1)+ (exclude_from_sim no) (in_bom yes) (on_board yes) (dnp no) (uuid "{_uuid(202)}")+ (property "Reference" "R1" (at 147.5 {r_y - 1.3} 0) (effects (font (size 1.27 1.27)) (justify left)))+ (property "Value" "330" (at 147.5 {r_y + 1.5} 0) (effects (font (size 1.27 1.27)) (justify left)))+ (instances (project "{proj}" (path "/{sheet}" (reference "R1") (unit 1))))+ )+'''+ r_connected = True+ wires += (f' (wire (pts (xy {round(cx, 3)} {round(cy, 3)}) (xy {ex} {ey})) '+ f'(stroke (width 0) (type default)) (uuid "{_uuid(wire_n)}"))\n')+ wire_n += 1+ if not r_inst: # no rightward pin found — place R1 unwired (demo scaffold)+ r_inst = f''' (symbol (lib_id "Adom:R_330") (at 145 74.92 0) (unit 1)+ (exclude_from_sim no) (in_bom yes) (on_board yes) (dnp no) (uuid "{_uuid(202)}")+ (property "Reference" "R1" (at 147.5 73.5 0) (effects (font (size 1.27 1.27)) (justify left)))+ (property "Value" "330" (at 147.5 76.4 0) (effects (font (size 1.27 1.27)) (justify left)))+ (instances (project "{proj}" (path "/{sheet}" (reference "R1") (unit 1))))+ )+'''++ schematic = f'''(kicad_sch (version 20231120) (generator "eeschema") (generator_version "8.0")+ (uuid "{sheet}") (paper "A4")+ (title_block (title "{part} demo - generated by the Adom KiCad Bridge") (company "Adom"))+ (lib_symbols+{lib_entry}+{_R_LIB_ENTRY} )+{wires} (symbol (lib_id "Adom:{part}") (at {ax} {ay} 0) (unit 1)+ (exclude_from_sim no) (in_bom yes) (on_board yes) (dnp no) (uuid "{_uuid(201)}")+ (property "Reference" "D1" (at {ax} {ay - 7.5} 0) (effects (font (size 1.27 1.27))))+ (property "Value" "{part}" (at {ax} {ay + 7} 0) (effects (font (size 1.27 1.27))))+ (property "Footprint" "Adom:{fp_name}" (at {ax} {ay} 0) (effects (font (size 1.27 1.27)) (hide yes)))+ (instances (project "{proj}" (path "/{sheet}" (reference "D1") (unit 1))))+ )+{r_inst} (sheet_instances (path "/" (page "1")))+)+'''++ # ---------------- board ----------------+ part_at = (108.0, 68.0)+ r_at = (116.0, 68.0)+ fp_placed = fp_text.replace(f'(footprint "{fp_name}"', f'(footprint "Adom:{fp_name}"', 1)+ fp_placed = fp_placed.replace('(layer "F.Cu")', f'(layer "F.Cu")\n (at {part_at[0]} {part_at[1]})', 1)+ fp_placed = fp_placed.replace('(fp_text reference "REF**"', '(fp_text reference "D1"', 1)+ fp_placed = fp_placed.replace('(property "Reference" "REF**"', '(property "Reference" "D1"', 1)++ # board outline sized from the part's coordinates (pads/graphics), with margin+ coords = [(float(a), float(b)) for a, b in+ re.findall(r'\((?:start|end|at|center)\s+([-\d.]+)\s+([-\d.]+)', fp_text)]+ half_w = max([abs(x) for x, _ in coords] + [2.0])+ half_h = max([abs(y) for _, y in coords] + [2.0])+ x1 = round(part_at[0] - half_w - 6, 2)+ y1 = round(part_at[1] - half_h - 6, 2)+ x2 = round(r_at[0] + 8, 2)+ y2 = round(part_at[1] + half_h + 6, 2)+ edge = ""+ pts = [(x1, y1), (x2, y1), (x2, y2), (x1, y2), (x1, y1)]+ for i in range(4):+ (ex1, ey1), (ex2, ey2) = pts[i], pts[i + 1]+ edge += (f' (gr_line (start {ex1} {ey1}) (end {ex2} {ey2}) '+ f'(stroke (width 0.15) (type solid)) (layer "Edge.Cuts") (uuid "{_uuid(400 + i)}"))\n')++ segments = ""+ pads = _pads(fp_text)+ if len(pads) >= 2 and abs(pads[0][1] - pads[1][1]) < 0.01:+ left, right = sorted(pads[:2])+ py_abs = part_at[1] + left[1]+ segments = (f' (segment (start {round(part_at[0] + right[0], 3)} {round(py_abs, 3)}) '+ f'(end {round(r_at[0] - 0.9125, 3)} {round(r_at[1], 3)}) (width 0.3) (layer "F.Cu"))\n'+ f' (segment (start {round(part_at[0] + left[0], 3)} {round(py_abs, 3)}) '+ f'(end {round(x1 + 2, 3)} {round(py_abs, 3)}) (width 0.3) (layer "F.Cu"))\n'+ f' (segment (start {round(r_at[0] + 0.9125, 3)} {round(r_at[1], 3)}) '+ f'(end {round(x2 - 2, 3)} {round(r_at[1], 3)}) (width 0.3) (layer "F.Cu"))\n')++ fps = (" " + fp_placed.rstrip().replace("\n", "\n ") + "\n "+ + _R_FP.format(rx=r_at[0], ry=r_at[1]).rstrip().replace("\n", "\n "))++ board = f'''(kicad_pcb (version 20240108) (generator "pcbnew") (generator_version "8.0")+ (general (thickness 1.6)) (paper "A4")+ (layers+ (0 "F.Cu" signal) (31 "B.Cu" signal)+ (36 "B.SilkS" user "B.Silkscreen") (37 "F.SilkS" user "F.Silkscreen")+ (38 "B.Mask" user) (39 "F.Mask" user) (40 "B.Paste" user) (41 "F.Paste" user)+ (44 "Edge.Cuts" user) (45 "Margin" user) (49 "F.Fab" user) (50 "B.Fab" user)+ (46 "B.CrtYd" user "B.Courtyard") (47 "F.CrtYd" user "F.Courtyard")+ )+ (setup (pad_to_mask_clearance 0))+{edge}{fps}+{segments})+'''++ pro = ('{"board":{},"meta":{"filename":"%s.kicad_pro","version":1},"schematic":{},"sheets":[]}'+ % proj)++ files = {}+ try:+ for name, content in ((f"{proj}.kicad_pro", pro),+ (f"{proj}.kicad_sch", schematic),+ (f"{proj}.kicad_pcb", board)):+ p = out_dir / name+ p.write_text(content, encoding="utf-8")+ files[name.rsplit(".", 1)[-1]] = str(p)+ # stale single-instance locks from a previous open of the same project+ for lck in out_dir.glob("*.lck"):+ try:+ lck.unlink()+ except OSError:+ pass+ except Exception as e:+ return {"success": False, "error": f"Failed to write project files: {e}"}++ for name, content in ((f"{proj}.kicad_sch", schematic), (f"{proj}.kicad_pcb", board)):+ if content.count("(") != content.count(")"):+ return {"success": False, "error": f"internal: unbalanced s-expression in {name}"}++ return {+ "success": True,+ "projectName": proj,+ "projectFile": files["kicad_pro"],+ "schematicFile": files["kicad_sch"],+ "boardFile": files["kicad_pcb"],+ "partName": part,+ "footprintName": fp_name,+ "resistorWired": r_connected,+ "output": (f"Generated {proj}: the part as D1 with a 330R series resistor: "+ f"schematic wired={r_connected}, board with edge cuts, copper and the part's 3D model."),+ "_hint": ("Project surfaces now have something real to show: kicad_show_project {filePath: "+ "projectFile}, kicad_show_schematic {filePath: schematicFile}, kicad_show_2d_board / "+ "kicad_show_3d_board {filePath: boardFile}. The footprint keeps its (model ...) "+ "reference, so the 3D board renders the actual part. Regenerating overwrites in place."),+ "_next": ["kicad_show_project", "kicad_show_schematic", "kicad_show_2d_board", "kicad_show_3d_board"],+ }
server.py+6@@ -66,6 +66,7 @@ from handlers.export import ( from handlers.export_molecule import handle_export_molecule # v0.9.63+: first-class showcase tour (Bridge demos this bridge during Hydrogen install). from handlers.demo import handle_demo+from handlers.make_part_project import handle_make_part_project # v0.9.10+: check/upgrade KiCad via the OFFICIAL downloads.kicad.org installer # (NOT winget, which lags the real release). check_for_updates is read-only; # upgrade silent-installs the official signed NSIS build.@@ -365,6 +366,10 @@ _VERB_CATALOG = { "export_step": {"summary": "Export a STEP 3D model from a board.", "long": False, "hint": "Absolute board path + output; exports a STEP assembly via kicad-cli.", "related": ["kicad_open_3d_viewer", "kicad_export_gerber"], "pitfalls": ["only components with 3D models attached appear in the STEP"]},+ "make_part_project": {"summary": "Generate a small real project (pro/sch/pcb) AROUND an installed Adom-library part — gives the project/schematic/2D-board/3D-board surfaces something true to show for one component.", "long": False,+ "hint": "kicad_make_part_project {\"partName\":\"IN-S42ATR\"} after install_symbol/install_footprint. Returns projectFile/schematicFile/boardFile for the show_* verbs. Board keeps the footprint's (model ...) so show_3d_board renders the real part.",+ "related": ["kicad_show_project", "kicad_show_schematic", "kicad_show_2d_board", "kicad_show_3d_board"],+ "pitfalls": ["the part must already be in the Adom library (symbol AND footprint)", "regenerating overwrites the project in place"]}, "demo": {"summary": "Six-beat KiCad showcase tour — staged, or a background job with structured progress (percent/ETA) and a kicad_state final payload", "long": True, "hint": "The demo verb — STAGED + RESUMABLE, same contract as fusion_demo (stage/done/narrate/screenshots/steps). Speak narrate, show screenshots[{label,path}] (captured for you), call data.nextCall until done:true. Generates its own parts + project (ships no sample files), installs them into the user's real KiCad, and opens each view. Every step returns `say` (speak it), `pointOut` (what's on screen) and `window` (match in kicad_screenshot_all). Pass {\"step\":\"symbol\"} to pace it one beat at a time; no args runs all six.", "related": ["kicad_screenshot_all", "kicad_close", "kicad_upgrade"], "pitfalls": ["if KiCad isn't installed it returns an actionable error — call kicad_upgrade, don't send the user to a download page", "windows open in the BACKGROUND; screenshot them instead of stealing focus", "a bulk run opens all six windows at once — step-by-step narrates better"]},@@ -537,6 +542,7 @@ COMMAND_HANDLERS = { "export_step": handle_export_step, "export_molecule": handle_export_molecule, "demo": handle_demo,+ "make_part_project": handle_make_part_project, "export_bom_csv": handle_export_bom_csv, # v0.9.10+: KiCad version check + upgrade via the official downloads.kicad.org # installer (read-only check; silent /S install). Exposed as kicad_check_for_updates