diff --git a/backend/pinscopex/netlist_bundle.py b/backend/pinscopex/netlist_bundle.py
new file mode 100644
index 0000000..3dd32ce
--- /dev/null
+++ b/backend/pinscopex/netlist_bundle.py
@@ -0,0 +1,233 @@
+"""Unpack a netlist upload: one file, several KiCad sheets, or a zip.
+
+The hierarchical ``.kicad_sch`` parser needs sibling files on disk. A single
+temp file named ``tmpXXXX.kicad_sch`` cannot see ``Sheetfile`` children.
+"""
+
+from __future__ import annotations
+
+import io
+import zipfile
+from dataclasses import dataclass
+from pathlib import Path
+
+from backend.pinscopex.parsers import detect_netlist_format
+
+MAX_BUNDLE_BYTES = 30 * 1024 * 1024
+_MAX_ZIP_MEMBERS = 400
+
+_KIND = str # pads | edif | kicad_* | zip | kicad_pcb | unknown
+
+
+@dataclass
+class NetlistUpload:
+ root: Path
+ work_dir: Path
+ pcb: Path | None
+ extra_sch: list[Path]
+
+
+def sniff_netlist_kind(content: bytes) -> str:
+ if content[:2] == b"PK":
+ return "zip"
+ head = content[:2048].decode("utf-8", errors="replace").lstrip("\ufeff").lstrip()
+ low = head[:40].lower()
+ if low.startswith("(kicad_pcb"):
+ return "kicad_pcb"
+ if low.startswith("(kicad_sch"):
+ return "kicad_sch"
+ if low.startswith("(edif"):
+ return "edif"
+ if low.startswith("(export"):
+ return "kicad_sexp"
+ if low.startswith(" str:
+ rel = name.replace("\\", "/").strip()
+ if not rel or rel.startswith("/") or rel.startswith("\\"):
+ raise ValueError(f"Rejected path: {name}")
+ parts = Path(rel).parts
+ if ".." in parts or (parts and parts[0] == ".."):
+ raise ValueError(f"Rejected path: {name}")
+ return rel
+
+
+_KEEP_SUFFIX = {
+ ".kicad_sch",
+ ".kicad_pcb",
+ ".kicad_pro",
+ ".kicad_net",
+ ".xml",
+ ".edn",
+ ".edif",
+ ".edf",
+ ".asc",
+ ".net",
+}
+
+
+def _keep_zip_member(rel: str) -> bool:
+ parts = Path(rel).parts
+ if any(
+ p.endswith("-backups") or p.endswith(".pretty") or p.lower() in {"3dmodels", "__macosx"}
+ for p in parts
+ ):
+ return False
+ return Path(rel).suffix.lower() in _KEEP_SUFFIX
+
+
+def _extract_zip(data: bytes, dest: Path) -> None:
+ dest.mkdir(parents=True, exist_ok=True)
+ total = 0
+ kept = 0
+ with zipfile.ZipFile(io.BytesIO(data)) as zf:
+ for info in zf.infolist():
+ if info.is_dir():
+ continue
+ rel = _safe_rel(info.filename)
+ if not _keep_zip_member(rel):
+ continue
+ kept += 1
+ if kept > _MAX_ZIP_MEMBERS:
+ raise ValueError("Zip has too many schematic files")
+ total += max(info.file_size, 0)
+ if total > MAX_BUNDLE_BYTES:
+ raise ValueError("Zip is too large")
+ out = dest / rel
+ out.parent.mkdir(parents=True, exist_ok=True)
+ with zf.open(info) as src:
+ payload = src.read()
+ if len(payload) > MAX_BUNDLE_BYTES:
+ raise ValueError("Zip member is too large")
+ out.write_bytes(payload)
+
+
+def _write_named(name: str, data: bytes, dest: Path) -> None:
+ rel = _safe_rel(name)
+ kind = sniff_netlist_kind(data)
+ if kind == "zip":
+ _extract_zip(data, dest)
+ return
+ out = dest / Path(rel).name
+ # Keep a single subdirectory when the client sent webkitRelativePath.
+ if "/" in rel:
+ out = dest / rel
+ out.parent.mkdir(parents=True, exist_ok=True)
+ else:
+ dest.mkdir(parents=True, exist_ok=True)
+ out.write_bytes(data)
+
+
+def find_kicad_pcb(work: Path) -> Path | None:
+ hits = sorted(p for p in work.rglob("*.kicad_pcb") if p.is_file())
+ return hits[0] if hits else None
+
+
+def _sheetfiles_of(path: Path) -> list[str]:
+ from backend.pinscopex.parsers_kicad import _parse_sexp, _sheetfiles, _tag
+
+ text = path.read_text(encoding="utf-8", errors="replace")
+ tree = _parse_sexp(text)
+ if _tag(tree) != "kicad_sch":
+ return []
+ return _sheetfiles(tree)
+
+
+def _pick_root(work: Path) -> Path:
+ schs: list[Path] = []
+ exported: list[Path] = []
+ for p in work.rglob("*"):
+ if not p.is_file():
+ continue
+ kind = sniff_netlist_kind(p.read_bytes()[:2048])
+ if kind == "kicad_sch":
+ schs.append(p)
+ elif kind in ("kicad_xml", "kicad_sexp", "edif", "pads"):
+ exported.append(p)
+
+ if exported:
+ pref = [
+ p for p in exported
+ if sniff_netlist_kind(p.read_bytes()[:2048]) in (
+ "kicad_xml", "kicad_sexp", "edif",
+ )
+ ]
+ return (pref or exported)[0]
+
+ if not schs:
+ raise ValueError(
+ "No schematic found. Drop the KiCad project folder or a netlist."
+ )
+
+ referenced: set[Path] = set()
+ for p in schs:
+ for rel in _sheetfiles_of(p):
+ try:
+ child = (p.parent / rel.replace("\\", "/")).resolve()
+ except ValueError:
+ continue
+ referenced.add(child)
+ roots = [p for p in schs if p.resolve() not in referenced]
+ if not roots:
+ raise ValueError("Cyclic sheet includes — upload an exported KiCad netlist instead.")
+
+ pro = list(work.rglob("*.kicad_pro"))
+ if len(roots) > 1 and pro:
+ stems = {p.stem for p in pro}
+ matched = [r for r in roots if r.stem in stems]
+ if len(matched) == 1:
+ return matched[0]
+ if len(roots) > 1:
+ names = ", ".join(sorted(r.name for r in roots))
+ raise ValueError(
+ f"Multiple root sheets ({names}). Upload a zip of the project, "
+ "or the top-level .kicad_sch together with every Sheetfile child."
+ )
+ return roots[0]
+
+
+def materialize_netlist_upload(
+ files: list[tuple[str, bytes]],
+ dest: Path,
+) -> NetlistUpload:
+ """Write uploaded bytes into ``dest`` and return the file to parse."""
+ if not files:
+ raise ValueError("No netlist file uploaded")
+ dest.mkdir(parents=True, exist_ok=True)
+ total = sum(len(b) for _n, b in files)
+ if total > MAX_BUNDLE_BYTES:
+ raise ValueError("Upload is too large")
+
+ if len(files) == 1:
+ name, data = files[0]
+ kind = sniff_netlist_kind(data)
+ if kind == "kicad_pcb":
+ raise ValueError(
+ "This is a board file. Drop the KiCad project folder, or put "
+ "the .kicad_pcb on the optional board step."
+ )
+ if kind == "unknown" and not name.lower().endswith(".zip"):
+ raise ValueError(
+ "Not a netlist. Drop the KiCad project folder, a zip, or a "
+ "PADS / EDIF / KiCad netlist."
+ )
+
+ for name, data in files:
+ _write_named(name, data, dest)
+
+ root = _pick_root(dest)
+ pcb = find_kicad_pcb(dest)
+ extras = [
+ p for p in work_sch_files(dest)
+ if p.resolve() != root.resolve()
+ ]
+ return NetlistUpload(root=root, work_dir=dest, pcb=pcb, extra_sch=extras)
+
+
+def work_sch_files(dest: Path) -> list[Path]:
+ return sorted(p for p in dest.rglob("*.kicad_sch") if p.is_file())
diff --git a/backend/pinscopex/parsers_kicad.py b/backend/pinscopex/parsers_kicad.py
index ac27a89..36a6d36 100644
--- a/backend/pinscopex/parsers_kicad.py
+++ b/backend/pinscopex/parsers_kicad.py
@@ -556,7 +556,10 @@ def parse_kicad_sch_project(
if path in seen:
raise ValueError(f"Cyclic sheet include: {path.name}")
if not path.is_file():
- raise ValueError(f"Missing sheet file: {path.name}")
+ raise ValueError(
+ f"Missing sheet file: {path.name}. Drop the whole KiCad "
+ "project folder, not a single sheet."
+ )
seen.add(path)
text = path.read_text(encoding="utf-8", errors="replace")
tree = _parse_sexp(text)
diff --git a/backend/routers/projects.py b/backend/routers/projects.py
index 583989c..1a282c9 100644
--- a/backend/routers/projects.py
+++ b/backend/routers/projects.py
@@ -1,7 +1,10 @@
"""Project CRUD and file upload endpoints."""
+import json
+from pathlib import Path
+
import httpx
-from fastapi import APIRouter, HTTPException, Request, UploadFile
+from fastapi import APIRouter, File, Form, HTTPException, Request, UploadFile
from fastapi.responses import JSONResponse, Response
from pydantic import BaseModel
@@ -462,51 +465,80 @@ async def upload_bom(
@router.post("/projects/{project_id}/upload/netlist")
-async def upload_netlist(project_id: str, file: UploadFile, request: Request):
+async def upload_netlist(
+ project_id: str,
+ request: Request,
+ file: UploadFile | None = File(default=None),
+ files: list[UploadFile] | None = File(default=None),
+ paths: str | None = Form(default=None),
+):
storage = get_storage(request)
result = proj_svc.resolve_project_access(storage, get_user_id(request), project_id)
if not result:
raise HTTPException(404, "Project not found")
user_id = result[0] # owner_user_id for storage paths
- data = await file.read()
- if len(data) > MAX_UPLOAD_BYTES:
- raise HTTPException(413, f"File too large (max {MAX_UPLOAD_BYTES // 1024 // 1024} MB)")
- # Auto-detect PADS vs EDIF from the file's first bytes — users don't pick
- # a format, the wizard accepts either.
- from backend.pinscopex.parsers import (
- detect_netlist_format, parse_netlist_any, validate_netlist,
- )
+ from backend.pinscopex.netlist_bundle import materialize_netlist_upload
+ from backend.pinscopex.parsers import parse_netlist_any, validate_netlist
from backend.pinscopex.parsers_edif import list_edif_subdesigns
- import tempfile, os
+ import tempfile
+
+ blobs: list[tuple[str, bytes]] = []
+ seen: set[tuple[str, int]] = set()
+ uploads = list(files or []) if files else ([file] if file is not None else [])
+ rels: list[str] | None = None
+ if paths:
+ try:
+ parsed_paths = json.loads(paths)
+ except json.JSONDecodeError:
+ parsed_paths = None
+ if isinstance(parsed_paths, list) and all(isinstance(x, str) for x in parsed_paths):
+ rels = parsed_paths
+ for i, uf in enumerate(uploads):
+ data = await uf.read()
+ if len(data) > MAX_UPLOAD_BYTES:
+ raise HTTPException(
+ 413,
+ f"File too large (max {MAX_UPLOAD_BYTES // 1024 // 1024} MB)",
+ )
+ name = (
+ rels[i]
+ if rels is not None and i < len(rels)
+ else (uf.filename or "netlist")
+ )
+ mark = (name, len(data))
+ if mark in seen:
+ continue
+ seen.add(mark)
+ blobs.append((name, data))
+ if not blobs:
+ raise HTTPException(400, "No netlist file uploaded")
- fmt = detect_netlist_format(data)
- suffix = {
- "edif": ".edn",
- "kicad_xml": ".xml",
- "kicad_sexp": ".kicad_net",
- "kicad_sch": ".kicad_sch",
- }.get(fmt, ".asc")
sub_designs: list[dict] = []
try:
- tmp = tempfile.NamedTemporaryFile(delete=False, suffix=suffix)
- tmp.write(data)
- tmp.close()
- parts, nets, _ = parse_netlist_any(tmp.name)
- # For EDIF, also surface the sub-design layout so the wizard can
- # decide whether to prompt the user. Cheap second parse — same file.
- if fmt == "edif":
- sub_designs = list_edif_subdesigns(tmp.name)
- os.unlink(tmp.name)
+ with tempfile.TemporaryDirectory() as tmp:
+ parsed = materialize_netlist_upload(blobs, Path(tmp) / "work")
+ parts, nets, fmt = parse_netlist_any(parsed.root)
+ if fmt == "edif":
+ sub_designs = list_edif_subdesigns(parsed.root)
+ issues = validate_netlist(parts, nets)
+ if issues:
+ raise ValueError("; ".join(issues))
+ root_bytes = parsed.root.read_bytes()
+ key = proj_svc.save_netlist(storage, user_id, project_id, root_bytes, fmt=fmt)
+ if fmt == "kicad_sch":
+ proj_svc.save_companion_sheets(
+ storage, user_id, project_id, parsed.root, parsed.extra_sch,
+ )
+ else:
+ proj_svc.clear_companion_sheets(storage, user_id, project_id)
+ if parsed.pcb is not None:
+ proj_svc.save_pcb(storage, user_id, project_id, parsed.pcb.read_bytes())
+ except HTTPException:
+ raise
except Exception as e:
- raise HTTPException(400, f"Invalid netlist: {e}")
- issues = validate_netlist(parts, nets)
- if issues:
- raise HTTPException(400, f"Netlist failed sanity check: {'; '.join(issues)}")
- key = proj_svc.save_netlist(storage, user_id, project_id, data, fmt=fmt)
- # EDIF: emit a designator→pins preview matching the PADS browser-side
- # shape, so the wizard's power-sources step can render its dropdowns
- # without re-parsing the (s-expression-heavy) file in the browser.
+ raise HTTPException(400, f"Netlist failed sanity check: {e}") from e
+
designator_pins: list[dict] = []
if fmt != "pads":
designator_pins = _build_designator_pins(parts, nets)
diff --git a/backend/services/projects.py b/backend/services/projects.py
index db28bc8..03a1bcf 100644
--- a/backend/services/projects.py
+++ b/backend/services/projects.py
@@ -26,6 +26,7 @@ from __future__ import annotations
import logging
import uuid
from datetime import datetime, timezone
+from pathlib import Path
from typing import Any
log = logging.getLogger(__name__)
@@ -663,6 +664,34 @@ def save_netlist(
return key
+def clear_companion_sheets(
+ storage: StorageBackend, user_id: str, project_id: str,
+) -> None:
+ prefix = f"{_project_prefix(user_id, project_id)}/uploads/"
+ for key in storage.list_recursive(prefix):
+ rel = key[len(prefix):]
+ if rel.endswith(".kicad_sch") and rel != "netlist.kicad_sch":
+ storage.delete_key(key)
+
+
+def save_companion_sheets(
+ storage: StorageBackend,
+ user_id: str,
+ project_id: str,
+ root: Path,
+ extras: list[Path],
+) -> None:
+ """Keep Sheetfile children next to ``uploads/netlist.kicad_sch``."""
+ clear_companion_sheets(storage, user_id, project_id)
+ parent = root.parent
+ prefix = f"{_project_prefix(user_id, project_id)}/uploads/"
+ for extra in extras:
+ rel = extra.relative_to(parent).as_posix()
+ if rel == "netlist.kicad_sch":
+ continue
+ storage.write_bytes(prefix + rel, extra.read_bytes())
+
+
def save_pcb(
storage: StorageBackend, user_id: str, project_id: str, data: bytes
) -> str:
diff --git a/frontend/content/changelog.md b/frontend/content/changelog.md
index 44e2da0..5680749 100644
--- a/frontend/content/changelog.md
+++ b/frontend/content/changelog.md
@@ -2,6 +2,14 @@
What's new in Pinscope.
+## 2.26.1 — 2026-09-11 — Ask for the KiCad board
+
+The create-project wizard has a dedicated PCB step (skip still allowed). Report load errors show the API detail instead of a generic fetch failure.
+
+- [New] Wizard step for `.kicad_pcb`. Upload later from Settings or Impedance.
+- [Improved] Report page retries briefly and links back to pipeline progress when `report.json` is missing.
+- [New] Drop the KiCad project folder (or a zip). Extra library junk is ignored; the board is picked up if present.
+
## 2.26.0 — 2026-09-11 — ImpedenceFinder Z0 on PCB nets
A pipeline run with `.kicad_pcb` + stackup samples routed **signal** nets (power/ground skipped). Extra net names can be analyzed from the Impedance tab. Z0 is ImpedenceFinder net_walk/zsolver; no invented εr.
diff --git a/frontend/content/file-guide.md b/frontend/content/file-guide.md
index f21b9cd..478995e 100644
--- a/frontend/content/file-guide.md
+++ b/frontend/content/file-guide.md
@@ -1,9 +1,10 @@
# File Upload Guide
-Pinscope needs two files from your EDA tool to review a design:
+Pinscope needs two files from your EDA tool to review a schematic, and an optional KiCad board for layout:
- A **PADS-PCB ASCII netlist** or an **EDIF 2.0.0 netlist** — the circuit's connectivity. Pinscope accepts `.asc`, `.net`, `.NET`, `.txt` (PADS-PCB) and `.edn`, `.edif`, `.edf` (EDIF); the format is auto-detected from the file's first bytes.
- A **Bill of Materials** (CSV or XLSX) — mapping each reference designator to a manufacturer part number.
+- Optional: a **KiCad PCB** (`.kicad_pcb`) — placement, keepout, pair length, and net Z0. Schematic review still runs without it.
## Example files
@@ -179,4 +180,8 @@ For both EasyEDA Standard and EasyEDA Pro.
- **"No ground net found"** — your netlist has no net named `GND`, `VSS`, `AGND`, `DGND`, or similar. If you exported a sub-sheet, re-export the top sheet instead.
- **Unresolved parts after the pipeline runs** — a BOM row has no MPN, or the MPN wasn't found on DigiKey. Add the MPN, or rely on Pinscope's value fallback (fills in from the `Value` / `Comment` column).
+## The KiCad PCB (optional)
+
+Drop the KiCad **project folder** (or a zip of it) on Schematic — Pinscope takes the sheets and the `.kicad_pcb` if it is there. You can also add the board later.
+
Still stuck? [Contact us](/contact) with your netlist and BOM attached and we'll take a look.
diff --git a/frontend/package.json b/frontend/package.json
index 6947739..c309ec1 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,6 +1,6 @@
{
"name": "frontend",
- "version": "2.26.0",
+ "version": "2.26.1",
"private": true,
"scripts": {
"sync-version": "node scripts/sync-version.mjs",
diff --git a/frontend/src/app/(app)/project/[id]/page.tsx b/frontend/src/app/(app)/project/[id]/page.tsx
index cf06e45..2cfaa2f 100644
--- a/frontend/src/app/(app)/project/[id]/page.tsx
+++ b/frontend/src/app/(app)/project/[id]/page.tsx
@@ -41,6 +41,7 @@ import {
} from "lucide-react";
import { useOptionalUser } from "@/hooks/use-optional-auth";
import { ImpedancePanel } from "@/components/project/impedance-panel";
+import { PcbUploadButton } from "@/components/project/pcb-upload";
export default function ProjectDetailPage({
params,
@@ -190,6 +191,11 @@ export default function ProjectDetailPage({
+ {project.hasPcb
+ ? "A .kicad_pcb is on this project. Replace it here, then re-run the pipeline for layout and net Z0."
+ : "No board yet. Schematic review does not need one. Layout checks and net Z0 do."}
+
+
+
+
diff --git a/frontend/src/app/(app)/project/[id]/progress/page.tsx b/frontend/src/app/(app)/project/[id]/progress/page.tsx
index 0594169..c4e94ba 100644
--- a/frontend/src/app/(app)/project/[id]/progress/page.tsx
+++ b/frontend/src/app/(app)/project/[id]/progress/page.tsx
@@ -20,7 +20,7 @@ import {
import { PipelineStepper } from "@/components/progress/pipeline-stepper";
import { PausedRunBanner } from "@/components/billing/paused-run-banner";
import { usePipelineProgress } from "@/hooks/use-pipeline-progress";
-import { cancelPipeline, fetchProject, resumePipeline, reprocessPipeline } from "@/lib/api";
+import { cancelPipeline, fetchProject, fetchReport, resumePipeline, reprocessPipeline } from "@/lib/api";
import type { PauseCheckpoint } from "@/lib/types";
import {
AlertTriangle,
@@ -107,14 +107,24 @@ export default function ProgressPage({
}
};
- // Auto-navigate to report when pipeline completes successfully
+ // Auto-navigate to report when pipeline completes and the file exists
useEffect(() => {
- if (done && !error && !cancelled && !projectPaused) {
- const timer = setTimeout(() => {
- router.push(`/project/${id}/report`);
- }, 1500);
- return () => clearTimeout(timer);
- }
+ if (!done || error || cancelled || projectPaused) return;
+ let stopped = false;
+ (async () => {
+ for (let i = 0; i < 8; i++) {
+ try {
+ await fetchReport(id);
+ if (!stopped) router.push(`/project/${id}/report`);
+ return;
+ } catch {
+ await new Promise((r) => setTimeout(r, 500));
+ }
+ }
+ })();
+ return () => {
+ stopped = true;
+ };
}, [done, error, cancelled, projectPaused, id, router]);
// Auto-navigate to dashboard when pipeline is cancelled
diff --git a/frontend/src/app/(app)/project/[id]/report/page.tsx b/frontend/src/app/(app)/project/[id]/report/page.tsx
index a367567..3a17003 100644
--- a/frontend/src/app/(app)/project/[id]/report/page.tsx
+++ b/frontend/src/app/(app)/project/[id]/report/page.tsx
@@ -220,8 +220,18 @@ function ReportContent({ projectId }: { projectId: string }) {
if (error || !report || !graph) {
return (
-