Accept a KiCad project drop so hierarchical sheets and the board upload in one step.
A single .kicad_sch was parsed in isolation, so child sheets looked like an empty netlist. Report 404s now surface the real API error instead of a generic fetch failure. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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("<?xml") or low.startswith("<export"):
|
||||
return "kicad_xml"
|
||||
if "*PADS-PCB*" in head.upper() or head.lstrip().startswith("*PART*"):
|
||||
return "pads"
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _safe_rel(name: str) -> 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())
|
||||
@@ -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)
|
||||
|
||||
+65
-33
@@ -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.
|
||||
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(tmp.name)
|
||||
os.unlink(tmp.name)
|
||||
except Exception as e:
|
||||
raise HTTPException(400, f"Invalid netlist: {e}")
|
||||
sub_designs = list_edif_subdesigns(parsed.root)
|
||||
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 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"Netlist failed sanity check: {e}") from e
|
||||
|
||||
designator_pins: list[dict] = []
|
||||
if fmt != "pads":
|
||||
designator_pins = _build_designator_pins(parts, nets)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "2.26.0",
|
||||
"version": "2.26.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"sync-version": "node scripts/sync-version.mjs",
|
||||
|
||||
@@ -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({
|
||||
<h1 className="text-lg font-semibold">{project.name}</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{new Date(project.created).toLocaleDateString()}
|
||||
{project.hasPcb ? (
|
||||
<span className="ml-2">· PCB uploaded</span>
|
||||
) : (
|
||||
<span className="ml-2">· no PCB</span>
|
||||
)}
|
||||
{typeof project.totalCostUsd === "number" && project.totalCostUsd > 0 && (
|
||||
<span className="ml-2 font-mono tabular-nums text-foreground">
|
||||
${project.totalCostUsd.toFixed(4)}
|
||||
@@ -348,7 +354,11 @@ export default function ProjectDetailPage({
|
||||
)}
|
||||
|
||||
{tab === "impedance" && (
|
||||
<ImpedancePanel projectId={id} hasPcb={Boolean(project.hasPcb)} />
|
||||
<ImpedancePanel
|
||||
projectId={id}
|
||||
hasPcb={Boolean(project.hasPcb)}
|
||||
onPcbUploaded={reload}
|
||||
/>
|
||||
)}
|
||||
|
||||
{tab === "logs" && (
|
||||
@@ -357,6 +367,19 @@ export default function ProjectDetailPage({
|
||||
|
||||
{tab === "settings" && (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">KiCad PCB</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{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."}
|
||||
</p>
|
||||
<PcbUploadButton projectId={id} onUploaded={reload} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<CollaboratorsSection projectId={id} />
|
||||
<SkippedComponentsSection skipped={project.skippedComponents} />
|
||||
<ReportVersionSection pinscopeVersion={project.pinscopeVersion} />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -220,8 +220,18 @@ function ReportContent({ projectId }: { projectId: string }) {
|
||||
|
||||
if (error || !report || !graph) {
|
||||
return (
|
||||
<div className="p-6 max-w-5xl mx-auto w-full text-center py-12 text-sm text-muted-foreground">
|
||||
<div className="p-6 max-w-5xl mx-auto w-full text-center py-12 space-y-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{error ?? "Report not found."}
|
||||
</p>
|
||||
<p className="text-sm">
|
||||
<a
|
||||
href={`/project/${projectId}/progress`}
|
||||
className="text-blue-600 dark:text-blue-500 hover:underline"
|
||||
>
|
||||
Open pipeline progress
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -352,6 +352,7 @@ function summarizeLcscModel(model: Record<string, unknown>): string {
|
||||
|
||||
type WizardStep =
|
||||
| "details"
|
||||
| "pcb"
|
||||
| "columns"
|
||||
| "subdesigns"
|
||||
| "lcsc-passives"
|
||||
@@ -361,6 +362,7 @@ type WizardStep =
|
||||
|
||||
const ALL_STEPS: { key: WizardStep; label: string }[] = [
|
||||
{ key: "details", label: "Project Details" },
|
||||
{ key: "pcb", label: "Board (optional)" },
|
||||
{ key: "columns", label: "BOM Columns" },
|
||||
{ key: "subdesigns", label: "Sub-designs" },
|
||||
{ key: "lcsc-passives", label: "Resolving Passive Specs" },
|
||||
@@ -374,6 +376,45 @@ const ALL_STEPS: { key: WizardStep; label: string }[] = [
|
||||
const NULL_SUBDESIGN_KEY = "";
|
||||
const _subKey = (id: string | null): string => id ?? NULL_SUBDESIGN_KEY;
|
||||
|
||||
const _NET_EXT = new Set([
|
||||
".kicad_sch",
|
||||
".kicad_net",
|
||||
".kicad_pro",
|
||||
".asc",
|
||||
".net",
|
||||
".edn",
|
||||
".edif",
|
||||
".edf",
|
||||
".xml",
|
||||
".zip",
|
||||
]);
|
||||
|
||||
function _fileExt(name: string): string {
|
||||
const i = name.lastIndexOf(".");
|
||||
return i >= 0 ? name.slice(i).toLowerCase() : "";
|
||||
}
|
||||
|
||||
function splitProjectFiles(incoming: File[]): {
|
||||
netlist: File[];
|
||||
pcb: File[];
|
||||
bom: File[];
|
||||
} {
|
||||
const netlist: File[] = [];
|
||||
const pcb: File[] = [];
|
||||
const bom: File[] = [];
|
||||
for (const f of incoming) {
|
||||
const rel = (
|
||||
(f as File & { webkitRelativePath?: string }).webkitRelativePath || f.name
|
||||
).toLowerCase();
|
||||
if (rel.includes("-backups/") || rel.includes(".pretty/")) continue;
|
||||
const ext = _fileExt(f.name);
|
||||
if (ext === ".kicad_pcb") pcb.push(f);
|
||||
else if (ext === ".csv" || ext === ".xlsx") bom.push(f);
|
||||
else if (_NET_EXT.has(ext)) netlist.push(f);
|
||||
}
|
||||
return { netlist, pcb, bom };
|
||||
}
|
||||
|
||||
// Concurrency cap for the per-row LCSC passive resolve. Backend charges one
|
||||
// credit-billing API call per request; small batch keeps latency reasonable
|
||||
// without overloading the resolve endpoint.
|
||||
@@ -533,7 +574,7 @@ export function CreateProjectDialog({
|
||||
// Rerun mode: operate on an existing project rather than creating new.
|
||||
const [existingProjectId, setExistingProjectId] = useState<string | null>(null);
|
||||
const [initialBomFile, setInitialBomFile] = useState<File | null>(null);
|
||||
const [initialNetlistFile, setInitialNetlistFile] = useState<File | null>(null);
|
||||
const [initialNetlistFiles, setInitialNetlistFiles] = useState<File[]>([]);
|
||||
const [existingDatasheetStems, setExistingDatasheetStems] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
@@ -548,8 +589,12 @@ export function CreateProjectDialog({
|
||||
// Step 1
|
||||
const [name, setName] = useState("");
|
||||
const [bomFile, setBomFile] = useState<File | null>(null);
|
||||
const [netlistFile, setNetlistFile] = useState<File | null>(null);
|
||||
const [netlistFiles, setNetlistFiles] = useState<File[]>([]);
|
||||
const netlistFile = netlistFiles[0] ?? null;
|
||||
const [pcbFile, setPcbFile] = useState<File | null>(null);
|
||||
const skipPcbStep =
|
||||
Boolean(pcbFile) ||
|
||||
netlistFiles.some((f) => f.name.toLowerCase().endsWith(".zip"));
|
||||
const [netlistNetCount, setNetlistNetCount] = useState<number | null>(null);
|
||||
const [netlistError, setNetlistError] = useState<string | null>(null);
|
||||
|
||||
@@ -840,6 +885,7 @@ export function CreateProjectDialog({
|
||||
// lcsc-passives drains to zero before the auto-advance effect
|
||||
// moves us off the step on the next tick).
|
||||
if (s.key === step) return true;
|
||||
if (s.key === "pcb") return !skipPcbStep;
|
||||
if (s.key === "subdesigns") return hasSubdesignChoice;
|
||||
if (s.key === "lcsc-passives") return hasLcscPassives;
|
||||
if (s.key === "datasheets") return hasIcs;
|
||||
@@ -847,14 +893,15 @@ export function CreateProjectDialog({
|
||||
if (s.key === "passives") return hasPassives;
|
||||
return true;
|
||||
});
|
||||
}, [step, hasSubdesignChoice, hasLcscPassives, hasIcs, hasSimple, hasPassives]);
|
||||
}, [step, skipPcbStep, hasSubdesignChoice, hasLcscPassives, hasIcs, hasSimple, hasPassives]);
|
||||
|
||||
const stepIndex = activeSteps.findIndex((s) => s.key === step);
|
||||
|
||||
// ---- Handlers ----
|
||||
|
||||
const handleBomChange = useCallback((files: File[]) => {
|
||||
const file = files[0] || null;
|
||||
const file =
|
||||
files.find((f) => /\.(csv|xlsx)$/i.test(f.name)) || files[0] || null;
|
||||
setBomFile(file);
|
||||
if (!file) {
|
||||
setCsvData(null);
|
||||
@@ -880,9 +927,13 @@ export function CreateProjectDialog({
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleNetlistChange = useCallback((files: File[]) => {
|
||||
const handleNetlistChange = useCallback((incoming: File[]) => {
|
||||
const { netlist, pcb, bom } = splitProjectFiles(incoming);
|
||||
if (pcb[0]) setPcbFile(pcb[0]);
|
||||
if (bom[0]) handleBomChange([bom[0]]);
|
||||
const files = netlist;
|
||||
const file = files[0] || null;
|
||||
setNetlistFile(file);
|
||||
setNetlistFiles(files);
|
||||
setNetlistNetCount(null);
|
||||
setNetlistError(null);
|
||||
// Invalidate any cached netlist preview, since it was relative to the
|
||||
@@ -895,6 +946,11 @@ export function CreateProjectDialog({
|
||||
setNetlistUploadedEarly(false);
|
||||
setNetlistIsEdif(false);
|
||||
if (!file) return;
|
||||
const lower = file.name.toLowerCase();
|
||||
if (lower.endsWith(".zip") || files.length > 1) {
|
||||
setNetlistPreview([]);
|
||||
return;
|
||||
}
|
||||
file.text().then((text) => {
|
||||
// EDIF: skip browser-side preview — the net-count badge will populate
|
||||
// after the server upload parses the file.
|
||||
@@ -920,14 +976,15 @@ export function CreateProjectDialog({
|
||||
);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
}, [handleBomChange]);
|
||||
|
||||
const resetAndClose = useCallback(() => {
|
||||
setOpen(false);
|
||||
setStep("details");
|
||||
setName("");
|
||||
setBomFile(null);
|
||||
setNetlistFile(null);
|
||||
setNetlistFiles([]);
|
||||
setPcbFile(null);
|
||||
setNetlistNetCount(null);
|
||||
setNetlistError(null);
|
||||
setCsvData(null);
|
||||
@@ -954,7 +1011,7 @@ export function CreateProjectDialog({
|
||||
setError(null);
|
||||
setExistingProjectId(null);
|
||||
setInitialBomFile(null);
|
||||
setInitialNetlistFile(null);
|
||||
setInitialNetlistFiles([]);
|
||||
setExistingDatasheetStems(new Set());
|
||||
setPrefillLoading(false);
|
||||
setPrefillError(null);
|
||||
@@ -1049,8 +1106,8 @@ export function CreateProjectDialog({
|
||||
if (netlist) {
|
||||
const netlistText = await netlist.text();
|
||||
if (cancelled) return;
|
||||
setNetlistFile(netlist);
|
||||
setInitialNetlistFile(netlist);
|
||||
setNetlistFiles([netlist]);
|
||||
setInitialNetlistFiles([netlist]);
|
||||
if (isEdifNetlist(netlistText)) {
|
||||
setNetlistIsEdif(true);
|
||||
// EDIF: PADS-shape browser preview doesn't apply.
|
||||
@@ -1135,7 +1192,7 @@ export function CreateProjectDialog({
|
||||
if (netlist) {
|
||||
const netlistText = await netlist.text();
|
||||
if (cancelled) return;
|
||||
setNetlistFile(netlist);
|
||||
setNetlistFiles([netlist]);
|
||||
if (isEdifNetlist(netlistText)) {
|
||||
setNetlistIsEdif(true);
|
||||
setNetlistNetCount(null);
|
||||
@@ -1488,7 +1545,7 @@ export function CreateProjectDialog({
|
||||
setBomUploadedEarly(true);
|
||||
setInitialBomFile(bomFile);
|
||||
// Treat the netlist as fresh so it still uploads in handleCreate.
|
||||
setInitialNetlistFile(null);
|
||||
setInitialNetlistFiles([]);
|
||||
const map = uploadRes.lcsc_to_mpn ?? {};
|
||||
if (Object.keys(map).length > 0) setLcscToMpn(map);
|
||||
if (fullProj.lcscPayloads && Object.keys(fullProj.lcscPayloads).length > 0) {
|
||||
@@ -1547,13 +1604,13 @@ export function CreateProjectDialog({
|
||||
createdProjectIdHere = proj.id;
|
||||
setExistingProjectId(proj.id);
|
||||
}
|
||||
const result = await uploadNetlist(projectId, netlistFile);
|
||||
const result = await uploadNetlist(projectId, netlistFiles);
|
||||
setEdifSubDesigns(result.sub_designs);
|
||||
if (result.designator_pins.length > 0) {
|
||||
setNetlistPreview(result.designator_pins);
|
||||
}
|
||||
setNetlistUploadedEarly(true);
|
||||
setInitialNetlistFile(netlistFile);
|
||||
setInitialNetlistFiles(netlistFiles);
|
||||
return { ok: true, subDesigns: result.sub_designs };
|
||||
} catch (e) {
|
||||
// Only delete the project if we created it just now — leave LCSC's
|
||||
@@ -1573,7 +1630,7 @@ export function CreateProjectDialog({
|
||||
} finally {
|
||||
setEarlyUploading(false);
|
||||
}
|
||||
}, [netlistFile, existingProjectId, name]);
|
||||
}, [netlistFile, netlistFiles, existingProjectId, name]);
|
||||
|
||||
// ---- LCSC per-row passive resolve ----
|
||||
//
|
||||
@@ -1689,6 +1746,7 @@ export function CreateProjectDialog({
|
||||
|
||||
const canAdvance = (): boolean => {
|
||||
if (step === "details") return !!(name.trim() && bomFile && netlistFile && !netlistError);
|
||||
if (step === "pcb") return true;
|
||||
if (step === "columns") return !!(refCol && mpnCol);
|
||||
if (step === "subdesigns")
|
||||
return !!(selectedSubdesignIds && selectedSubdesignIds.size > 0);
|
||||
@@ -1729,6 +1787,9 @@ export function CreateProjectDialog({
|
||||
const res = await runEarlyNetlistUpload();
|
||||
if (!res.ok) return;
|
||||
}
|
||||
setStep(skipPcbStep ? "columns" : "pcb");
|
||||
}
|
||||
else if (step === "pcb") {
|
||||
setStep("columns");
|
||||
}
|
||||
else if (step === "columns") {
|
||||
@@ -1806,7 +1867,8 @@ export function CreateProjectDialog({
|
||||
|
||||
const goBack = () => {
|
||||
setError(null);
|
||||
if (step === "columns") setStep("details");
|
||||
if (step === "pcb") setStep("details");
|
||||
else if (step === "columns") setStep(skipPcbStep ? "details" : "pcb");
|
||||
else if (step === "subdesigns") setStep("columns");
|
||||
else if (step === "lcsc-passives") {
|
||||
if (hasSubdesignChoice) setStep("subdesigns");
|
||||
@@ -1877,9 +1939,9 @@ export function CreateProjectDialog({
|
||||
);
|
||||
}
|
||||
|
||||
if (!projectAlreadyExists || netlistFile !== initialNetlistFile) {
|
||||
if (!projectAlreadyExists || netlistFiles !== initialNetlistFiles) {
|
||||
setProgress("Uploading netlist...");
|
||||
await uploadNetlist(project.id, netlistFile!);
|
||||
await uploadNetlist(project.id, netlistFiles);
|
||||
}
|
||||
if (pcbFile) {
|
||||
setProgress("Uploading PCB...");
|
||||
@@ -2065,14 +2127,13 @@ export function CreateProjectDialog({
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<FileUploadZone
|
||||
label="BOM (.csv, .xlsx)"
|
||||
label="BOM"
|
||||
accept=".csv,.xlsx"
|
||||
files={bomFile ? [bomFile] : []}
|
||||
onFilesChange={handleBomChange}
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground leading-tight px-1">
|
||||
Requires columns: Designator, Manufacturer Part Number, and
|
||||
Comment (for passive values / mismatch detection).{" "}
|
||||
CSV or Excel.{" "}
|
||||
<a
|
||||
href="/file-guide#the-bom"
|
||||
target="_blank"
|
||||
@@ -2085,9 +2146,10 @@ export function CreateProjectDialog({
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<FileUploadZone
|
||||
label="Netlist (.asc / .net / .edn / .kicad_sch)"
|
||||
accept=".asc,.net,.NET,.txt,.edn,.edif,.edf,.xml,.kicad_sch,.kicad_net"
|
||||
files={netlistFile ? [netlistFile] : []}
|
||||
label="Schematic"
|
||||
accept=".asc,.net,.NET,.txt,.edn,.edif,.edf,.xml,.kicad_sch,.kicad_net,.zip"
|
||||
multiple
|
||||
files={netlistFiles}
|
||||
onFilesChange={handleNetlistChange}
|
||||
/>
|
||||
{netlistError ? (
|
||||
@@ -2099,13 +2161,17 @@ export function CreateProjectDialog({
|
||||
<p className="text-[11px] text-muted-foreground leading-tight px-1">
|
||||
{netlistNetCount} nets detected
|
||||
</p>
|
||||
) : netlistFile ? (
|
||||
) : netlistFiles.length > 0 ? (
|
||||
<p className="text-[11px] text-muted-foreground leading-tight px-1">
|
||||
Net count will appear after upload.
|
||||
{pcbFile
|
||||
? "Board included."
|
||||
: netlistFiles.length > 1
|
||||
? `${netlistFiles.length} schematic files.`
|
||||
: "Ready."}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-[11px] text-muted-foreground leading-tight px-1">
|
||||
PADS-PCB, EDIF, or KiCad netlist / .kicad_sch.{" "}
|
||||
Drop the KiCad folder, or one netlist.{" "}
|
||||
<a
|
||||
href="/file-guide#the-netlist"
|
||||
target="_blank"
|
||||
@@ -2118,19 +2184,20 @@ export function CreateProjectDialog({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "pcb" && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Optional. Skip if you only want schematic review.
|
||||
</p>
|
||||
<FileUploadZone
|
||||
label="PCB (.kicad_pcb, optional)"
|
||||
label="KiCad board (.kicad_pcb)"
|
||||
accept=".kicad_pcb"
|
||||
files={pcbFile ? [pcbFile] : []}
|
||||
onFilesChange={(files) => setPcbFile(files[0] ?? null)}
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground leading-tight px-1">
|
||||
Optional. Layout checks (trace width, 3W, placement mm) stay
|
||||
off until a board is present. Schema review still runs
|
||||
without it.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
computeImpedance,
|
||||
fetchImpedanceNets,
|
||||
} from "@/lib/api";
|
||||
import { PcbUploadButton } from "@/components/project/pcb-upload";
|
||||
import type {
|
||||
ImpedanceKind,
|
||||
ImpedanceNetsReport,
|
||||
@@ -29,9 +30,11 @@ function fmt(n: number | null | undefined, digits = 2): string {
|
||||
export function ImpedancePanel({
|
||||
projectId,
|
||||
hasPcb,
|
||||
onPcbUploaded,
|
||||
}: {
|
||||
projectId: string;
|
||||
hasPcb: boolean;
|
||||
onPcbUploaded?: () => void;
|
||||
}) {
|
||||
const [kind, setKind] = useState<ImpedanceKind>("microstrip");
|
||||
const [h, setH] = useState("0.20");
|
||||
@@ -225,10 +228,13 @@ export function ImpedancePanel({
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{!hasPcb && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Upload a `.kicad_pcb` and run analysis. Power/ground nets are
|
||||
skipped; signal traces with stackup εr/h are sampled.
|
||||
Upload a `.kicad_pcb`, then re-run the pipeline. Power/ground
|
||||
nets are skipped; signal traces with stackup εr/h are sampled.
|
||||
</p>
|
||||
<PcbUploadButton projectId={projectId} onUploaded={onPcbUploaded} />
|
||||
</div>
|
||||
)}
|
||||
{hasPcb && boardNets?.skipped && (
|
||||
<p className="text-sm text-muted-foreground">{boardNets.skipped}</p>
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Upload, Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { uploadPcb } from "@/lib/api";
|
||||
|
||||
export function PcbUploadButton({
|
||||
projectId,
|
||||
onUploaded,
|
||||
}: {
|
||||
projectId: string;
|
||||
onUploaded?: () => void;
|
||||
}) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<label className="inline-flex">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={busy}
|
||||
nativeButton={false}
|
||||
render={<span />}
|
||||
>
|
||||
{busy ? (
|
||||
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
|
||||
) : (
|
||||
<Upload className="h-4 w-4 mr-1" />
|
||||
)}
|
||||
{busy ? "Uploading…" : "Upload .kicad_pcb"}
|
||||
</Button>
|
||||
<input
|
||||
type="file"
|
||||
accept=".kicad_pcb"
|
||||
className="hidden"
|
||||
disabled={busy}
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = "";
|
||||
if (!file) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await uploadPcb(projectId, file);
|
||||
onUploaded?.();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Upload failed");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,80 @@ interface FileUploadZoneProps {
|
||||
preloaded?: string[];
|
||||
}
|
||||
|
||||
function withRelativePath(file: File, rel: string): File {
|
||||
if ((file as File & { webkitRelativePath?: string }).webkitRelativePath === rel) {
|
||||
return file;
|
||||
}
|
||||
try {
|
||||
Object.defineProperty(file, "webkitRelativePath", {
|
||||
value: rel,
|
||||
configurable: true,
|
||||
});
|
||||
} catch {
|
||||
/* ignore — some File objects are sealed */
|
||||
}
|
||||
return file;
|
||||
}
|
||||
|
||||
function readDirEntries(
|
||||
reader: FileSystemDirectoryReader,
|
||||
): Promise<FileSystemEntry[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const acc: FileSystemEntry[] = [];
|
||||
const pump = () => {
|
||||
reader.readEntries((chunk) => {
|
||||
if (chunk.length === 0) resolve(acc);
|
||||
else {
|
||||
acc.push(...chunk);
|
||||
pump();
|
||||
}
|
||||
}, reject);
|
||||
};
|
||||
pump();
|
||||
});
|
||||
}
|
||||
|
||||
async function filesFromEntry(
|
||||
entry: FileSystemEntry,
|
||||
prefix: string,
|
||||
): Promise<File[]> {
|
||||
if (entry.isFile) {
|
||||
const file = await new Promise<File>((resolve, reject) => {
|
||||
(entry as FileSystemFileEntry).file(resolve, reject);
|
||||
});
|
||||
return [withRelativePath(file, prefix + file.name)];
|
||||
}
|
||||
if (entry.isDirectory) {
|
||||
const skip = /(-backups|\.pretty|3dmodels|__macosx)$/i.test(entry.name);
|
||||
if (skip) return [];
|
||||
const reader = (entry as FileSystemDirectoryEntry).createReader();
|
||||
const children = await readDirEntries(reader);
|
||||
const nested: File[] = [];
|
||||
for (const child of children) {
|
||||
nested.push(...(await filesFromEntry(child, prefix + entry.name + "/")));
|
||||
}
|
||||
return nested;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
async function filesFromDrop(e: React.DragEvent): Promise<File[]> {
|
||||
const items = e.dataTransfer?.items;
|
||||
if (items && items.length > 0) {
|
||||
const out: File[] = [];
|
||||
for (const item of Array.from(items)) {
|
||||
const entry = item.webkitGetAsEntry?.();
|
||||
if (entry) out.push(...(await filesFromEntry(entry, "")));
|
||||
else {
|
||||
const f = item.getAsFile();
|
||||
if (f) out.push(f);
|
||||
}
|
||||
}
|
||||
if (out.length > 0) return out;
|
||||
}
|
||||
return Array.from(e.dataTransfer?.files ?? []);
|
||||
}
|
||||
|
||||
export function FileUploadZone({
|
||||
label,
|
||||
accept,
|
||||
@@ -27,10 +101,11 @@ export function FileUploadZone({
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
const dropped = Array.from(e.dataTransfer.files);
|
||||
void filesFromDrop(e).then((dropped) => {
|
||||
onFilesChange(multiple ? [...files, ...dropped] : dropped.slice(0, 1));
|
||||
});
|
||||
},
|
||||
[files, multiple, onFilesChange]
|
||||
[files, multiple, onFilesChange],
|
||||
);
|
||||
|
||||
const handleChange = useCallback(
|
||||
@@ -38,7 +113,7 @@ export function FileUploadZone({
|
||||
const selected = Array.from(e.target.files ?? []);
|
||||
onFilesChange(multiple ? [...files, ...selected] : selected.slice(0, 1));
|
||||
},
|
||||
[files, multiple, onFilesChange]
|
||||
[files, multiple, onFilesChange],
|
||||
);
|
||||
|
||||
const hasFiles = files.length > 0 || (preloaded && preloaded.length > 0);
|
||||
@@ -48,9 +123,12 @@ export function FileUploadZone({
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed p-6 cursor-pointer transition-colors",
|
||||
dragOver ? "border-blue-500 bg-blue-500/5" : "border-border hover:border-foreground/20",
|
||||
hasFiles && "border-emerald-500/40 bg-emerald-500/5"
|
||||
hasFiles && "border-emerald-500/40 bg-emerald-500/5",
|
||||
)}
|
||||
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setDragOver(true);
|
||||
}}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
@@ -69,12 +147,27 @@ export function FileUploadZone({
|
||||
)}
|
||||
{files.length > 0 && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{files.map((f) => (
|
||||
<span key={f.name} className="font-mono block">{f.name}</span>
|
||||
))}
|
||||
{files.length <= 2 ? (
|
||||
files.map((f) => {
|
||||
const rel =
|
||||
(f as File & { webkitRelativePath?: string }).webkitRelativePath ||
|
||||
f.name;
|
||||
return (
|
||||
<span key={rel} className="font-mono block">{rel.split("/").pop()}</span>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<span>{files.length} files</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<input type="file" accept={accept} multiple={multiple} className="hidden" onChange={handleChange} />
|
||||
<input
|
||||
type="file"
|
||||
accept={accept}
|
||||
multiple={multiple}
|
||||
className="hidden"
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,13 @@ import { useState, useEffect } from "react";
|
||||
import type { ValidationReport, DesignGraph } from "@/lib/types";
|
||||
import { fetchReport, fetchGraph } from "@/lib/api";
|
||||
|
||||
function describeLoadError(e: unknown): string {
|
||||
if (e instanceof TypeError) {
|
||||
return "Could not reach the Pinscope API. Check that the backend is running and that /api is proxied to it.";
|
||||
}
|
||||
return e instanceof Error ? e.message : "Failed to load report";
|
||||
}
|
||||
|
||||
export function useReport(projectId: string) {
|
||||
const [report, setReport] = useState<ValidationReport | null>(null);
|
||||
const [graph, setGraph] = useState<DesignGraph | null>(null);
|
||||
@@ -11,14 +18,39 @@ export function useReport(projectId: string) {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
Promise.all([fetchReport(projectId), fetchGraph(projectId)])
|
||||
.then(([r, g]) => {
|
||||
setError(null);
|
||||
|
||||
(async () => {
|
||||
let lastErr: unknown;
|
||||
for (let attempt = 0; attempt < 4; attempt++) {
|
||||
try {
|
||||
const [r, g] = await Promise.all([
|
||||
fetchReport(projectId),
|
||||
fetchGraph(projectId),
|
||||
]);
|
||||
if (!cancelled) {
|
||||
setReport(r);
|
||||
setGraph(g);
|
||||
})
|
||||
.catch((e) => setError(e.message))
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
return;
|
||||
} catch (e) {
|
||||
lastErr = e;
|
||||
const msg = e instanceof Error ? e.message : "";
|
||||
const retryable = msg.includes("not found") || e instanceof TypeError;
|
||||
if (!retryable || attempt === 3) break;
|
||||
await new Promise((r) => setTimeout(r, 400 * (attempt + 1)));
|
||||
}
|
||||
}
|
||||
if (!cancelled) setError(describeLoadError(lastErr));
|
||||
})().finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [projectId]);
|
||||
|
||||
return { report, graph, loading, error };
|
||||
|
||||
+40
-4
@@ -56,6 +56,19 @@ async function authFetch(url: string, init?: RequestInit): Promise<Response> {
|
||||
return fetch(url, { ...init, headers });
|
||||
}
|
||||
|
||||
async function throwHttpError(res: Response, fallback: string): Promise<never> {
|
||||
let detail: unknown;
|
||||
try {
|
||||
const body = await res.json();
|
||||
detail = (body as { detail?: unknown; error?: unknown }).detail
|
||||
?? (body as { error?: unknown }).error;
|
||||
} catch {
|
||||
detail = undefined;
|
||||
}
|
||||
const msg = typeof detail === "string" && detail.trim() ? detail : fallback;
|
||||
throw new Error(msg);
|
||||
}
|
||||
|
||||
// --- Projects ---
|
||||
|
||||
function mapProject(p: Record<string, unknown>): Project {
|
||||
@@ -301,10 +314,19 @@ export async function uploadPcb(
|
||||
}
|
||||
|
||||
export async function uploadNetlist(
|
||||
projectId: string, file: File,
|
||||
projectId: string, files: File | File[],
|
||||
): Promise<UploadNetlistResult> {
|
||||
const list = Array.isArray(files) ? files : [files];
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
const rels = list.map((f) => {
|
||||
const rel = (f as File & { webkitRelativePath?: string }).webkitRelativePath;
|
||||
return rel && rel.length > 0 ? rel : f.name;
|
||||
});
|
||||
if (list[0]) form.append("file", list[0], list[0].name);
|
||||
if (list.length > 1) {
|
||||
for (const f of list) form.append("files", f, f.name);
|
||||
form.append("paths", JSON.stringify(rels));
|
||||
}
|
||||
const res = await authFetch(
|
||||
`${BASE}/api/projects/${projectId}/upload/netlist`,
|
||||
{ method: "POST", body: form },
|
||||
@@ -651,7 +673,14 @@ export async function fetchReport(
|
||||
projectId: string,
|
||||
): Promise<ValidationReport> {
|
||||
const res = await authFetch(`${BASE}/api/report/${projectId}`);
|
||||
if (!res.ok) throw new Error("Failed to fetch report");
|
||||
if (!res.ok) {
|
||||
await throwHttpError(
|
||||
res,
|
||||
res.status === 404
|
||||
? "Report not found — the pipeline has not finished, or it failed before writing a report."
|
||||
: "Failed to fetch report",
|
||||
);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
@@ -723,7 +752,14 @@ export async function deleteComment(
|
||||
|
||||
export async function fetchGraph(projectId: string): Promise<DesignGraph> {
|
||||
const res = await authFetch(`${BASE}/api/graph/${projectId}`);
|
||||
if (!res.ok) throw new Error("Failed to fetch graph");
|
||||
if (!res.ok) {
|
||||
await throwHttpError(
|
||||
res,
|
||||
res.status === 404
|
||||
? "Design graph not found — the pipeline has not finished, or it failed during graph build."
|
||||
: "Failed to fetch graph",
|
||||
);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED by scripts/sync-version.mjs from content/changelog.md.
|
||||
// Do not edit by hand — change the top "## X.Y.Z" heading in the changelog.
|
||||
export const APP_VERSION = "2.26.0";
|
||||
export const APP_VERSION = "2.26.1";
|
||||
export const APP_VERSION_DATE = "2026-09-11";
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
"""Multi-file / zip KiCad schematic ingest."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.pinscopex.netlist_bundle import (
|
||||
find_kicad_pcb,
|
||||
materialize_netlist_upload,
|
||||
sniff_netlist_kind,
|
||||
)
|
||||
from backend.pinscopex.parsers import parse_netlist_any, validate_netlist
|
||||
|
||||
_LIB_R = """
|
||||
(lib_symbols
|
||||
(symbol "Device:R"
|
||||
(pin passive (at 0 3.81 90) (length 2.54)
|
||||
(name "~" (effects (font (size 1.27 1.27))))
|
||||
(number "1" (effects (font (size 1.27 1.27))))
|
||||
)
|
||||
(pin passive (at 0 -3.81 90) (length 2.54)
|
||||
(name "~" (effects (font (size 1.27 1.27))))
|
||||
(number "2" (effects (font (size 1.27 1.27))))
|
||||
)
|
||||
)
|
||||
)
|
||||
"""
|
||||
|
||||
|
||||
def _resistor(ref: str, value: str) -> str:
|
||||
uid = "aaaaaaaa-aaaa-aaaa-aaaa-" + ref.encode().hex()[:12].ljust(12, "0")
|
||||
return f"""
|
||||
(symbol
|
||||
(lib_id "Device:R")
|
||||
(at 0 0 0)
|
||||
(unit 1)
|
||||
(uuid "{uid}")
|
||||
(property "Reference" "{ref}" (at 0 0 0) (effects (font (size 1.27 1.27))))
|
||||
(property "Value" "{value}" (at 0 0 0) (effects (font (size 1.27 1.27))))
|
||||
(pin "1" (uuid "p1"))
|
||||
(pin "2" (uuid "p2"))
|
||||
)
|
||||
"""
|
||||
|
||||
|
||||
def _sch(*body: str) -> str:
|
||||
return (
|
||||
"(kicad_sch (version 20250114) (uuid \"11111111-1111-1111-1111-111111111111\")"
|
||||
+ _LIB_R
|
||||
+ "".join(body)
|
||||
+ "\n)\n"
|
||||
)
|
||||
|
||||
|
||||
def _root_with_child() -> tuple[str, str]:
|
||||
child = _sch(
|
||||
_resistor("C1", "100n"),
|
||||
"""
|
||||
(global_label "GND" (at 0 3.81 0) (uuid "cccccccccccccccccccccccccccccccccccc"))
|
||||
""",
|
||||
)
|
||||
root = _sch(
|
||||
_resistor("R1", "10k"),
|
||||
"""
|
||||
(global_label "GND" (at 0 3.81 0) (uuid "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"))
|
||||
(sheet
|
||||
(at 50 0)
|
||||
(size 20 20)
|
||||
(property "Sheetname" "Child" (at 50 0 0) (effects (font (size 1.27 1.27))))
|
||||
(property "Sheetfile" "child.kicad_sch" (at 50 0 0) (effects (font (size 1.27 1.27))))
|
||||
)
|
||||
""",
|
||||
)
|
||||
return root, child
|
||||
|
||||
|
||||
def test_sniff_rejects_pcb_as_netlist():
|
||||
assert sniff_netlist_kind(b"(kicad_pcb (version 1)\n") == "kicad_pcb"
|
||||
assert sniff_netlist_kind(b"PK\x03\x04rest") == "zip"
|
||||
assert sniff_netlist_kind(b"(kicad_sch (version 1)") == "kicad_sch"
|
||||
assert sniff_netlist_kind(b"*PADS-PCB*\n*PART*\n") == "pads"
|
||||
|
||||
|
||||
def test_root_alone_missing_child_explains_multi_file(tmp_path: Path):
|
||||
from backend.pinscopex.parsers_kicad import parse_kicad
|
||||
|
||||
root, _child = _root_with_child()
|
||||
parsed = materialize_netlist_upload(
|
||||
[("root.kicad_sch", root.encode())],
|
||||
tmp_path / "work",
|
||||
)
|
||||
with pytest.raises(ValueError, match="child.kicad_sch"):
|
||||
parse_kicad(parsed.root)
|
||||
|
||||
|
||||
def test_multiple_sch_files_parse(tmp_path: Path):
|
||||
root, child = _root_with_child()
|
||||
parsed = materialize_netlist_upload(
|
||||
[
|
||||
("root.kicad_sch", root.encode()),
|
||||
("child.kicad_sch", child.encode()),
|
||||
],
|
||||
tmp_path / "work",
|
||||
)
|
||||
parts, nets, fmt = parse_netlist_any(parsed.root)
|
||||
assert fmt == "kicad_sch"
|
||||
assert "R1" in parts and "C1" in parts
|
||||
assert ("R1", "1") in nets["GND"] and ("C1", "1") in nets["GND"]
|
||||
assert validate_netlist(parts, nets) == []
|
||||
assert parsed.pcb is None
|
||||
|
||||
|
||||
def test_zip_with_nested_folder_and_pcb(tmp_path: Path):
|
||||
root, child = _root_with_child()
|
||||
pcb = b'(kicad_pcb (version 20240108) (generator pcbnew)\n (net 0 "")\n)\n'
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w") as zf:
|
||||
zf.writestr("board/root.kicad_sch", root)
|
||||
zf.writestr("board/child.kicad_sch", child)
|
||||
zf.writestr("board/board.kicad_pcb", pcb)
|
||||
parsed = materialize_netlist_upload(
|
||||
[("board.zip", buf.getvalue())],
|
||||
tmp_path / "work",
|
||||
)
|
||||
parts, nets, _fmt = parse_netlist_any(parsed.root)
|
||||
assert "R1" in parts and "C1" in parts
|
||||
assert parsed.pcb is not None
|
||||
assert find_kicad_pcb(parsed.work_dir) is not None
|
||||
|
||||
|
||||
def test_zip_slip_is_rejected(tmp_path: Path):
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w") as zf:
|
||||
zf.writestr("../escape.kicad_sch", _sch(_resistor("R1", "1k")))
|
||||
with pytest.raises(ValueError, match="Rejected"):
|
||||
materialize_netlist_upload(
|
||||
[("bad.zip", buf.getvalue())],
|
||||
tmp_path / "work",
|
||||
)
|
||||
|
||||
|
||||
def test_api_accepts_zip_and_companion_sheets(tmp_path: Path):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from backend.main import app
|
||||
from backend.services.storage import LocalStorageBackend
|
||||
|
||||
app.state.storage = LocalStorageBackend(tmp_path)
|
||||
client = TestClient(app)
|
||||
pid = client.post("/api/projects", json={"name": "kicad"}).json()["id"]
|
||||
root, child = _root_with_child()
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w") as zf:
|
||||
zf.writestr("root.kicad_sch", root)
|
||||
zf.writestr("child.kicad_sch", child)
|
||||
resp = client.post(
|
||||
f"/api/projects/{pid}/upload/netlist",
|
||||
files={"file": ("board.zip", buf.getvalue(), "application/zip")},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["parts"] == 2
|
||||
assert body["format"] == "kicad_sch"
|
||||
storage = client.app.state.storage
|
||||
prefix = f"users/local/projects/{pid}/uploads/"
|
||||
assert storage.exists(prefix + "netlist.kicad_sch")
|
||||
assert storage.exists(prefix + "child.kicad_sch")
|
||||
|
||||
|
||||
def test_api_accepts_multiple_sch_files(tmp_path: Path):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from backend.main import app
|
||||
from backend.services.storage import LocalStorageBackend
|
||||
|
||||
app.state.storage = LocalStorageBackend(tmp_path)
|
||||
client = TestClient(app)
|
||||
pid = client.post("/api/projects", json={"name": "multi"}).json()["id"]
|
||||
root, child = _root_with_child()
|
||||
resp = client.post(
|
||||
f"/api/projects/{pid}/upload/netlist",
|
||||
files=[
|
||||
("files", ("root.kicad_sch", root.encode(), "text/plain")),
|
||||
("files", ("child.kicad_sch", child.encode(), "text/plain")),
|
||||
],
|
||||
data={"paths": '["root.kicad_sch", "child.kicad_sch"]'},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["parts"] == 2
|
||||
|
||||
|
||||
|
||||
def test_kicad_pcb_bytes_are_not_parsed_as_pads(tmp_path: Path):
|
||||
with pytest.raises(ValueError, match="board"):
|
||||
materialize_netlist_upload(
|
||||
[("board.kicad_pcb", b"(kicad_pcb (version 1)\n")],
|
||||
tmp_path / "work",
|
||||
)
|
||||
Reference in New Issue
Block a user