← 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)

John Lauer ·5313e899ea ·2mo ago ·parent fbdc6b3
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