Add auto-schematic generation from SPICE netlists

Parse netlists into component graphs and render circuit diagrams via
SchemDraw. Two layout strategies: loop layout for simple 2-terminal
circuits (RC, RL, voltage divider) and labeled grid for complex
circuits with active devices (BJT amplifiers, MOSFET).

Backend: netlist parser, schematic engine, POST API endpoint.
Frontend: SchematicViewer with zoom/download, stacked cell layout
showing schematic + SPICE editor + waveform simultaneously.
This commit is contained in:
Ryan Malloy 2026-02-13 06:07:30 -07:00
parent 42f4428295
commit de7a29c69e
11 changed files with 944 additions and 16 deletions

View File

@ -11,6 +11,7 @@ dependencies = [
"pydantic>=2.0", "pydantic>=2.0",
"numpy>=1.24.0", "numpy>=1.24.0",
"websockets>=12.0", "websockets>=12.0",
"schemdraw>=0.19",
] ]
[project.optional-dependencies] [project.optional-dependencies]

View File

@ -0,0 +1,636 @@
"""Auto-generate circuit schematics from SPICE netlists using SchemDraw.
Pipeline: netlist text parse component list node graph layout SVG
"""
import logging
from dataclasses import dataclass, field
logger = logging.getLogger(__name__)
# ── Data Structures ─────────────────────────────────────────────
@dataclass
class SpiceComponent:
"""A parsed SPICE netlist component."""
name: str
prefix: str
nodes: list[str]
value: str = ""
model: str = ""
@dataclass
class ParsedNetlist:
"""Parsed representation of a SPICE netlist."""
title: str = ""
components: list[SpiceComponent] = field(default_factory=list)
models: dict[str, str] = field(default_factory=dict)
# ── Ground / Supply Detection ───────────────────────────────────
GROUND_NAMES = {"0", "gnd"}
SUPPLY_NAMES = {"vcc", "vdd", "vee", "vss"}
def _is_ground(node: str) -> bool:
return node.lower() in GROUND_NAMES
def _is_supply(node: str) -> bool:
return node.lower() in SUPPLY_NAMES
# ── SPICE Netlist Parser ───────────────────────────────────────
def parse_netlist(text: str) -> ParsedNetlist:
"""Parse SPICE netlist text into structured components."""
lines = text.split("\n")
if not lines:
return ParsedNetlist()
# First non-blank, non-directive line is the title (SPICE convention)
title = ""
first_content = 0
for i, line in enumerate(lines):
stripped = line.strip()
if stripped and not stripped.startswith(("*", ";", ".")):
title = stripped
first_content = i + 1
break
# Pre-scan for .model statements to determine BJT/MOSFET polarity
models: dict[str, str] = {}
for line in lines:
stripped = line.strip()
if stripped.lower().startswith(".model"):
parts = stripped.split()
if len(parts) >= 3:
model_name = parts[1]
model_type = parts[2].split("(")[0].strip().upper()
models[model_name] = model_type
# Merge continuation lines (SPICE + prefix)
merged: list[str] = []
for line in lines[first_content:]:
stripped = line.strip()
if not stripped:
continue
if stripped.startswith("+") and merged:
merged[-1] += " " + stripped[1:].strip()
else:
merged.append(stripped)
# Parse each line into components
components: list[SpiceComponent] = []
for line in merged:
if line.startswith(("*", ";", "$")):
continue
if line.startswith("."):
continue
comp = _parse_component_line(line, models)
if comp is not None:
components.append(comp)
return ParsedNetlist(title=title, components=components, models=models)
def _parse_component_line(line: str, models: dict[str, str]) -> SpiceComponent | None:
"""Parse a single SPICE component line."""
parts = line.split()
if not parts:
return None
name = parts[0]
prefix = name[0].upper()
parsers = {
"R": _parse_rcl,
"C": _parse_rcl,
"L": _parse_rcl,
"V": _parse_source,
"I": _parse_source,
"D": _parse_diode,
"Q": _parse_bjt,
"M": _parse_mosfet,
"X": _parse_subcircuit,
"E": _parse_four_node,
"G": _parse_four_node,
"S": _parse_four_node,
"F": _parse_ccxx,
"H": _parse_ccxx,
"B": _parse_behavioral,
}
parser = parsers.get(prefix)
if parser is None:
return None
if prefix in ("Q", "M"):
return parser(parts, models)
return parser(parts)
def _parse_rcl(parts: list[str]) -> SpiceComponent | None:
"""Parse R, C, L: NAME N+ N- VALUE."""
if len(parts) < 4:
return None
return SpiceComponent(
name=parts[0],
prefix=parts[0][0].upper(),
nodes=[parts[1], parts[2]],
value=parts[3],
)
def _parse_source(parts: list[str]) -> SpiceComponent | None:
"""Parse V, I: NAME N+ N- [DC val] [AC val] [transient spec]."""
if len(parts) < 3:
return None
return SpiceComponent(
name=parts[0],
prefix=parts[0][0].upper(),
nodes=[parts[1], parts[2]],
value=" ".join(parts[3:]) if len(parts) > 3 else "",
)
def _parse_diode(parts: list[str]) -> SpiceComponent | None:
"""Parse D: NAME N+ N- MODEL."""
if len(parts) < 4:
return None
return SpiceComponent(
name=parts[0], prefix="D",
nodes=[parts[1], parts[2]],
model=parts[3],
)
def _parse_bjt(parts: list[str], models: dict[str, str]) -> SpiceComponent | None:
"""Parse Q: NAME NC NB NE [NS] MODEL."""
if len(parts) < 5:
return None
name = parts[0]
# Check if parts[4] is a known model → 3-node form
if parts[4] in models:
return SpiceComponent(
name=name, prefix="Q",
nodes=[parts[1], parts[2], parts[3]],
model=parts[4],
)
# 4-node form (with substrate)
if len(parts) >= 6:
return SpiceComponent(
name=name, prefix="Q",
nodes=[parts[1], parts[2], parts[3], parts[4]],
model=parts[5],
)
# Default: 3-node
return SpiceComponent(
name=name, prefix="Q",
nodes=[parts[1], parts[2], parts[3]],
model=parts[4],
)
def _parse_mosfet(parts: list[str], models: dict[str, str]) -> SpiceComponent | None:
"""Parse M: NAME ND NG NS [NB] MODEL."""
if len(parts) < 5:
return None
name = parts[0]
# 4-node + model (standard)
if len(parts) >= 6 and not any(c in parts[5] for c in "="):
return SpiceComponent(
name=name, prefix="M",
nodes=[parts[1], parts[2], parts[3], parts[4]],
model=parts[5],
)
# 3-node + model
return SpiceComponent(
name=name, prefix="M",
nodes=[parts[1], parts[2], parts[3]],
model=parts[4],
)
def _parse_subcircuit(parts: list[str]) -> SpiceComponent | None:
"""Parse X: NAME N1 N2 ... SUBCKT_NAME [params]."""
if len(parts) < 3:
return None
# Find where key=value params start
param_start = len(parts)
for i in range(len(parts) - 1, 0, -1):
if "=" in parts[i]:
param_start = i
else:
break
tokens = parts[1:param_start]
if not tokens:
return None
return SpiceComponent(
name=parts[0], prefix="X",
nodes=tokens[:-1] if len(tokens) > 1 else [],
model=tokens[-1],
)
def _parse_four_node(parts: list[str]) -> SpiceComponent | None:
"""Parse E, G, S: NAME N+ N- NC+ NC- VALUE/MODEL."""
if len(parts) < 6:
return None
return SpiceComponent(
name=parts[0], prefix=parts[0][0].upper(),
nodes=[parts[1], parts[2], parts[3], parts[4]],
value=" ".join(parts[5:]),
)
def _parse_ccxx(parts: list[str]) -> SpiceComponent | None:
"""Parse F, H: NAME N+ N- VNAME GAIN."""
if len(parts) < 5:
return None
return SpiceComponent(
name=parts[0], prefix=parts[0][0].upper(),
nodes=[parts[1], parts[2]],
value=" ".join(parts[3:]),
)
def _parse_behavioral(parts: list[str]) -> SpiceComponent | None:
"""Parse B: NAME N+ N- V=expr or I=expr."""
if len(parts) < 4:
return None
return SpiceComponent(
name=parts[0], prefix="B",
nodes=[parts[1], parts[2]],
value=" ".join(parts[3:]),
)
# ── Node Graph ─────────────────────────────────────────────────
def _build_node_map(
components: list[SpiceComponent],
) -> dict[str, list[tuple[SpiceComponent, int]]]:
"""Build node_name → [(component, terminal_index)] adjacency map."""
node_map: dict[str, list[tuple[SpiceComponent, int]]] = {}
for comp in components:
for i, node in enumerate(comp.nodes):
node_map.setdefault(node, []).append((comp, i))
return node_map
# ── Main Loop Finder ───────────────────────────────────────────
def _find_main_loop(
components: list[SpiceComponent],
node_map: dict[str, list[tuple[SpiceComponent, int]]],
) -> list[SpiceComponent] | None:
"""Find the main signal loop starting from a voltage/current source.
Traces through 2-terminal components from the source's positive terminal
back to its negative terminal. Returns the ordered component list, or
None if no clear loop exists.
"""
source = next((c for c in components if c.prefix == "V"), None)
if source is None:
source = next((c for c in components if c.prefix == "I"), None)
if source is None:
return None
start_node = source.nodes[0] # positive terminal
end_node = source.nodes[1] # negative terminal (usually ground)
visited = {source.name}
path = [source]
current = start_node
for _ in range(len(components) + 1):
# Reached the negative terminal?
if current == end_node:
return path
if _is_ground(current) and _is_ground(end_node):
return path
# Follow the next unvisited 2-terminal component
found = False
for comp, term_idx in node_map.get(current, []):
if comp.name in visited:
continue
if len(comp.nodes) != 2:
continue
other_idx = 1 - term_idx
visited.add(comp.name)
path.append(comp)
current = comp.nodes[other_idx]
found = True
break
if not found:
break
# Did we end at ground?
if _is_ground(current) and _is_ground(end_node):
return path
return path if len(path) > 1 else None
# ── Value Formatting ───────────────────────────────────────────
def _format_value(value: str) -> str:
"""Clean up a SPICE value for schematic display."""
if not value:
return ""
v = value.strip()
# Simplify source specs for display
for prefix in ("DC ", "AC ", "dc ", "ac "):
if v.startswith(prefix):
v = v[len(prefix):]
break
# Truncate transient specs (PULSE, SIN, etc.)
if len(v) > 20:
paren = v.find("(")
if paren > 0:
v = v[:paren].strip()
else:
v = v[:20] + "..."
return v
def _component_label(comp: SpiceComponent) -> str:
"""Build a display label: name + value or model."""
val = _format_value(comp.value)
if val:
return f"{comp.name}\n{val}"
if comp.model:
return f"{comp.name}\n{comp.model}"
return comp.name
# ── SchemDraw Element Mapping ──────────────────────────────────
def _get_element(comp: SpiceComponent, models: dict[str, str]):
"""Map a SPICE component prefix to the corresponding SchemDraw element."""
import schemdraw.elements as elm
prefix = comp.prefix
if prefix == "R":
return elm.Resistor()
elif prefix == "C":
return elm.Capacitor()
elif prefix == "L":
return elm.Inductor2()
elif prefix == "V":
return elm.SourceV()
elif prefix == "I":
return elm.SourceI()
elif prefix == "D":
return elm.Diode()
elif prefix == "Q":
model_type = models.get(comp.model, "").upper()
if "PNP" in model_type:
return elm.BjtPnp()
return elm.BjtNpn()
elif prefix == "M":
model_type = models.get(comp.model, "").upper()
if "PMOS" in model_type or model_type == "P":
return elm.PFet()
return elm.NFet()
elif prefix in ("E", "G"):
return elm.SourceControlledV()
elif prefix in ("F", "H"):
return elm.SourceControlledI()
elif prefix == "S":
return elm.Switch()
else:
return elm.RBox()
# ── Loop Layout Renderer ──────────────────────────────────────
def _render_loop(parsed: ParsedNetlist, loop: list[SpiceComponent]) -> str:
"""Render a simple circuit loop as a clean rectangular schematic.
Layout: source on the left going up, components across the top going
right, last component going down on the right, return wire along the
bottom with a ground symbol.
"""
import schemdraw
import schemdraw.elements as elm
d = schemdraw.Drawing(fontsize=12)
source = loop[0]
path_comps = loop[1:]
if not path_comps:
# Standalone source
d.add(
_get_element(source, parsed.models)
.up()
.label(_component_label(source), loc="left")
)
d.add(elm.Ground())
return d.get_imagedata("svg").decode()
# Source on left side, going up
src = d.add(
_get_element(source, parsed.models)
.up()
.label(_component_label(source), loc="left")
)
# All components except the last go right across the top
for comp in path_comps[:-1]:
d.add(
_get_element(comp, parsed.models)
.right()
.label(_component_label(comp))
)
# Last component goes down, ending at the same y-level as the source start
last_comp = path_comps[-1]
d.add(
_get_element(last_comp, parsed.models)
.down()
.toy(src.start)
.label(_component_label(last_comp), loc="right")
)
# Return wire along the bottom back to source start
d.add(elm.Line().to(src.start))
# Ground symbol at the source's negative terminal
d.add(elm.Ground().at(src.start))
# Node label at source positive terminal
node_label = source.nodes[0]
if not _is_ground(node_label):
d.add(
elm.Dot(open=True)
.at(src.end)
.label(node_label, loc="left", fontsize=9)
)
# Node label at junction between top components and the last (shunt) component
if len(path_comps) >= 2:
junction_node = path_comps[-2].nodes[1] if len(path_comps[-2].nodes) >= 2 else ""
if junction_node and not _is_ground(junction_node):
# Find the end of the second-to-last component (the junction point)
# SchemDraw doesn't give us this easily, so skip for now
pass
return d.get_imagedata("svg").decode()
# ── Grid Layout Renderer ──────────────────────────────────────
def _render_grid(parsed: ParsedNetlist) -> str:
"""Render components in a labeled grid layout.
Used for complex circuits where topological layout isn't feasible.
Components are arranged in columns by type with terminal node labels.
"""
import schemdraw
d = schemdraw.Drawing(fontsize=11)
# Group by role for logical ordering
sources = [c for c in parsed.components if c.prefix in ("V", "I")]
passives = [c for c in parsed.components if c.prefix in ("R", "C", "L")]
active = [c for c in parsed.components if c.prefix in ("D", "Q", "M")]
other = [
c for c in parsed.components
if c.prefix not in ("V", "I", "R", "C", "L", "D", "Q", "M")
]
ordered = sources + passives + active + other
cols = min(4, max(2, len(ordered)))
x_spacing = 8
y_spacing = 5
for i, comp in enumerate(ordered):
row = i // cols
col = i % cols
x = col * x_spacing
y = -row * y_spacing
elem = _get_element(comp, parsed.models)
# 3+ terminal devices (BJT, MOSFET) need special handling
if comp.prefix in ("Q", "M") and len(comp.nodes) >= 3:
placed = d.add(elem.at((x, y)).label(_component_label(comp)))
_label_multiterminal(d, placed, comp)
else:
placed = d.add(
elem.at((x, y)).right().label(_component_label(comp))
)
_label_two_terminal(d, placed, comp)
return d.get_imagedata("svg").decode()
def _label_two_terminal(d, placed, comp: SpiceComponent) -> None:
"""Add node-name labels to a placed 2-terminal element."""
import schemdraw.elements as elm
if len(comp.nodes) >= 1:
d.add(elm.Dot(radius=0.08).at(placed.start))
d.add(
elm.Label()
.at(placed.start)
.label(comp.nodes[0], loc="left", fontsize=8)
)
if len(comp.nodes) >= 2:
d.add(elm.Dot(radius=0.08).at(placed.end))
d.add(
elm.Label()
.at(placed.end)
.label(comp.nodes[1], loc="right", fontsize=8)
)
def _label_multiterminal(d, placed, comp: SpiceComponent) -> None:
"""Add node-name labels to a placed multi-terminal element (BJT/MOSFET)."""
import schemdraw.elements as elm
terminal_names = {
"Q": ["C", "B", "E"],
"M": ["D", "G", "S"],
}
anchor_map_q = ["collector", "base", "emitter"]
anchor_map_m = ["drain", "gate", "source"]
anchors = anchor_map_q if comp.prefix == "Q" else anchor_map_m
labels = terminal_names.get(comp.prefix, [])
for j, node in enumerate(comp.nodes[:3]):
if j < len(anchors) and hasattr(placed, anchors[j]):
pos = getattr(placed, anchors[j])
d.add(elm.Dot(radius=0.08).at(pos))
label_text = f"{labels[j]}:{node}" if j < len(labels) else node
d.add(
elm.Label()
.at(pos)
.label(label_text, fontsize=8)
)
# ── Public API ─────────────────────────────────────────────────
def netlist_to_svg(netlist_text: str) -> str:
"""Convert a SPICE netlist to an SVG schematic diagram.
Tries a clean loop layout for simple circuits (<=12 two-terminal
components with a clear main path), falls back to a labeled grid
for complex circuits with active devices.
Returns:
SVG string.
Raises:
ValueError: If no components could be parsed from the netlist.
RuntimeError: If schemdraw is not installed.
"""
try:
import schemdraw # noqa: F401
except ImportError:
raise RuntimeError(
"schemdraw is required for schematic generation. "
"Install with: pip install 'schemdraw>=0.19'"
)
parsed = parse_netlist(netlist_text)
if not parsed.components:
raise ValueError("No components found in netlist")
node_map = _build_node_map(parsed.components)
# Try loop layout for simple circuits with a clear main path
two_terminal_only = all(len(c.nodes) == 2 for c in parsed.components)
if two_terminal_only and len(parsed.components) <= 12:
loop = _find_main_loop(parsed.components, node_map)
if loop and len(loop) >= 2:
try:
return _render_loop(parsed, loop)
except Exception as exc:
logger.warning("Loop layout failed, using grid: %s", exc)
return _render_grid(parsed)

View File

@ -9,7 +9,7 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from spicebook.config import settings from spicebook.config import settings
from spicebook.routers import notebooks, simulation, waveforms from spicebook.routers import notebooks, schematics, simulation, waveforms
logger = logging.getLogger("spicebook") logger = logging.getLogger("spicebook")
@ -27,9 +27,11 @@ def create_app() -> FastAPI:
allow_origins=[ allow_origins=[
"http://localhost:4321", "http://localhost:4321",
"http://localhost:4322", "http://localhost:4322",
"http://localhost:4326",
"http://localhost:3000", "http://localhost:3000",
"http://127.0.0.1:4321", "http://127.0.0.1:4321",
"http://127.0.0.1:4322", "http://127.0.0.1:4322",
"http://127.0.0.1:4326",
"http://127.0.0.1:3000", "http://127.0.0.1:3000",
], ],
allow_credentials=True, allow_credentials=True,
@ -38,6 +40,7 @@ def create_app() -> FastAPI:
) )
application.include_router(notebooks.router) application.include_router(notebooks.router)
application.include_router(schematics.router)
application.include_router(simulation.router) application.include_router(simulation.router)
application.include_router(waveforms.router) application.include_router(waveforms.router)

View File

@ -0,0 +1,66 @@
"""Schematic generation endpoints."""
from datetime import datetime, timezone
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from spicebook.config import settings
from spicebook.engine.schematic import netlist_to_svg
from spicebook.models.notebook import CellOutput, CellType
from spicebook.storage.filesystem import load_notebook, save_notebook
router = APIRouter(prefix="/api", tags=["schematics"])
class SchematicResponse(BaseModel):
svg: str | None = None
success: bool
error: str | None = None
@router.post(
"/notebooks/{notebook_id}/cells/{cell_id}/schematic",
response_model=SchematicResponse,
)
async def generate_schematic(notebook_id: str, cell_id: str):
"""Generate an SVG schematic from a SPICE cell's netlist."""
nb = load_notebook(settings.notebook_dir, notebook_id)
if nb is None:
raise HTTPException(status_code=404, detail=f"Notebook '{notebook_id}' not found")
cell = next((c for c in nb.cells if c.id == cell_id), None)
if cell is None:
raise HTTPException(status_code=404, detail=f"Cell '{cell_id}' not found")
if cell.type != CellType.SPICE:
raise HTTPException(
status_code=400,
detail=f"Cell is type '{cell.type.value}', not 'spice'",
)
if not cell.source.strip():
raise HTTPException(status_code=400, detail="Cell source is empty")
try:
svg = netlist_to_svg(cell.source)
except (ValueError, RuntimeError) as exc:
return SchematicResponse(success=False, error=str(exc))
except Exception as exc:
return SchematicResponse(success=False, error=f"Schematic generation failed: {exc}")
# Save schematic output to cell (additive — preserve simulation outputs)
now_iso = datetime.now(timezone.utc).isoformat()
schematic_output = CellOutput(
output_type="schematic",
data={"svg": svg, "success": True},
timestamp=now_iso,
)
# Remove any previous schematic output, keep everything else
cell.outputs = [o for o in cell.outputs if o.output_type != "schematic"]
cell.outputs.append(schematic_output)
save_notebook(settings.notebook_dir, notebook_id, nb)
return SchematicResponse(svg=svg, success=True)

View File

@ -66,10 +66,10 @@ async def run_cell(notebook_id: str, cell_id: str):
with tempfile.TemporaryDirectory(prefix="spicebook-sim-") as tmpdir: with tempfile.TemporaryDirectory(prefix="spicebook-sim-") as tmpdir:
result = await engine.run(cell.source, Path(tmpdir)) result = await engine.run(cell.source, Path(tmpdir))
# Save output to the cell in same format the frontend expects # Save output to the cell (preserve schematic outputs across re-runs)
now_iso = datetime.now(timezone.utc).isoformat() now_iso = datetime.now(timezone.utc).isoformat()
cell.outputs = [CellOutput( sim_output = CellOutput(
output_type="simulation_result" if result.success else "error", output_type="simulation_result" if result.success else "error",
data={ data={
"success": result.success, "success": result.success,
@ -79,7 +79,9 @@ async def run_cell(notebook_id: str, cell_id: str):
"elapsed_seconds": result.elapsed_seconds, "elapsed_seconds": result.elapsed_seconds,
}, },
timestamp=now_iso, timestamp=now_iso,
)] )
preserved = [o for o in cell.outputs if o.output_type == "schematic"]
cell.outputs = [sim_output] + preserved
save_notebook(settings.notebook_dir, notebook_id, nb) save_notebook(settings.notebook_dir, notebook_id, nb)

11
backend/uv.lock generated
View File

@ -446,6 +446,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/07/5bda6a85b220c64c65686bc85bd0bbb23b29c62b3a9f9433fa55f17cda93/ruff-0.15.1-py3-none-win_arm64.whl", hash = "sha256:5ff7d5f0f88567850f45081fac8f4ec212be8d0b963e385c3f7d0d2eb4899416", size = 10874604, upload-time = "2026-02-12T23:09:05.515Z" }, { url = "https://files.pythonhosted.org/packages/2a/07/5bda6a85b220c64c65686bc85bd0bbb23b29c62b3a9f9433fa55f17cda93/ruff-0.15.1-py3-none-win_arm64.whl", hash = "sha256:5ff7d5f0f88567850f45081fac8f4ec212be8d0b963e385c3f7d0d2eb4899416", size = 10874604, upload-time = "2026-02-12T23:09:05.515Z" },
] ]
[[package]]
name = "schemdraw"
version = "0.22"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6a/0e/3d2a9c2541ced42877e8b6049fb17c093329ccb81344529dc12a33d4c118/schemdraw-0.22.tar.gz", hash = "sha256:59d1fcfe817f93f8bfc37a3f4645186dc03316bbcfd9ac68804fd6cb3aa69f51", size = 10881350, upload-time = "2025-11-30T23:45:03.279Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/ab/b281071bc11555670a82c538e4be35e7f5ad5e4cb4de7ac356a05a4b2c6a/schemdraw-0.22-py3-none-any.whl", hash = "sha256:fb294fe086b89a7dc9bedce43dc39014a9b9e369864eab438f22009c9f0e1175", size = 148359, upload-time = "2025-11-30T23:42:04.794Z" },
]
[[package]] [[package]]
name = "spicebook" name = "spicebook"
version = "2026.2.13" version = "2026.2.13"
@ -454,6 +463,7 @@ dependencies = [
{ name = "fastapi" }, { name = "fastapi" },
{ name = "numpy" }, { name = "numpy" },
{ name = "pydantic" }, { name = "pydantic" },
{ name = "schemdraw" },
{ name = "uvicorn", extra = ["standard"] }, { name = "uvicorn", extra = ["standard"] },
{ name = "websockets" }, { name = "websockets" },
] ]
@ -475,6 +485,7 @@ requires-dist = [
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" },
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23" },
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.8" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.8" },
{ name = "schemdraw", specifier = ">=0.19" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.30.0" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.30.0" },
{ name = "websockets", specifier = ">=12.0" }, { name = "websockets", specifier = ">=12.0" },
] ]

View File

@ -5,6 +5,7 @@ import { useNotebookStore } from '../../../lib/notebook-store';
import { CellToolbar } from '../toolbar/CellToolbar'; import { CellToolbar } from '../toolbar/CellToolbar';
import { SpiceEditor } from '../editor/SpiceEditor'; import { SpiceEditor } from '../editor/SpiceEditor';
import { WaveformViewer } from '../output/WaveformViewer'; import { WaveformViewer } from '../output/WaveformViewer';
import { SchematicViewer } from '../output/SchematicViewer';
import { SimulationLog } from '../output/SimulationLog'; import { SimulationLog } from '../output/SimulationLog';
import { cn } from '../../../lib/cn'; import { cn } from '../../../lib/cn';
@ -23,6 +24,7 @@ export function SpiceCell({ cell, isFirst, isLast }: SpiceCellProps) {
deleteCell, deleteCell,
moveCell, moveCell,
runCell, runCell,
generateSchematic,
} = useNotebookStore(); } = useNotebookStore();
const isActive = activeCell === cell.id; const isActive = activeCell === cell.id;
@ -39,9 +41,15 @@ export function SpiceCell({ cell, isFirst, isLast }: SpiceCellProps) {
runCell(cell.id); runCell(cell.id);
}, [cell.id, runCell]); }, [cell.id, runCell]);
// Extract output data const handleGenerateSchematic = useCallback(() => {
const lastOutput = cell.outputs.length > 0 ? cell.outputs[cell.outputs.length - 1] : null; generateSchematic(cell.id);
const outputData = lastOutput?.data as { }, [cell.id, generateSchematic]);
// Extract simulation output
const simOutput = cell.outputs.find(
(o) => o.output_type === 'simulation_result' || o.output_type === 'error',
);
const outputData = simOutput?.data as {
success?: boolean; success?: boolean;
waveform?: WaveformData | null; waveform?: WaveformData | null;
log?: string; log?: string;
@ -49,6 +57,10 @@ export function SpiceCell({ cell, isFirst, isLast }: SpiceCellProps) {
elapsed_seconds?: number; elapsed_seconds?: number;
} | null; } | null;
// Extract schematic output
const schematicOutput = cell.outputs.find((o) => o.output_type === 'schematic');
const schematicSvg = schematicOutput?.data?.svg as string | null;
return ( return (
<div <div
className={cn( className={cn(
@ -66,12 +78,20 @@ export function SpiceCell({ cell, isFirst, isLast }: SpiceCellProps) {
isFirst={isFirst} isFirst={isFirst}
isLast={isLast} isLast={isLast}
onRun={handleRun} onRun={handleRun}
onGenerateSchematic={handleGenerateSchematic}
onDelete={() => deleteCell(cell.id)} onDelete={() => deleteCell(cell.id)}
onMoveUp={() => moveCell(cell.id, 'up')} onMoveUp={() => moveCell(cell.id, 'up')}
onMoveDown={() => moveCell(cell.id, 'down')} onMoveDown={() => moveCell(cell.id, 'down')}
/> />
{/* Editor */} {/* Schematic — shown above the editor when generated */}
{schematicSvg && (
<div className="border-b border-slate-700/50">
<SchematicViewer svg={schematicSvg} />
</div>
)}
{/* SPICE Editor — always visible */}
<div className="min-h-[80px]"> <div className="min-h-[80px]">
<SpiceEditor <SpiceEditor
value={cell.source} value={cell.source}
@ -85,12 +105,12 @@ export function SpiceCell({ cell, isFirst, isLast }: SpiceCellProps) {
<div className="border-t border-slate-700/50 px-4 py-3"> <div className="border-t border-slate-700/50 px-4 py-3">
<div className="flex items-center gap-2 text-sm text-blue-400"> <div className="flex items-center gap-2 text-sm text-blue-400">
<div className="w-4 h-4 border-2 border-blue-400/30 border-t-blue-400 rounded-full animate-spin" /> <div className="w-4 h-4 border-2 border-blue-400/30 border-t-blue-400 rounded-full animate-spin" />
Running simulation... Processing...
</div> </div>
</div> </div>
)} )}
{/* Outputs */} {/* Waveform + Simulation results — shown below the editor */}
{!isRunning && outputData && ( {!isRunning && outputData && (
<div className="border-t border-slate-700/50"> <div className="border-t border-slate-700/50">
{/* Error banner */} {/* Error banner */}

View File

@ -0,0 +1,96 @@
import { useState, useRef, useCallback } from 'react';
import { ZoomIn, ZoomOut, Maximize2, Download } from 'lucide-react';
interface SchematicViewerProps {
svg: string;
}
const ZOOM_STEPS = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0];
export function SchematicViewer({ svg }: SchematicViewerProps) {
const [zoomIndex, setZoomIndex] = useState(2); // Start at 100%
const containerRef = useRef<HTMLDivElement>(null);
const zoom = ZOOM_STEPS[zoomIndex];
const zoomIn = useCallback(() => {
setZoomIndex((i) => Math.min(i + 1, ZOOM_STEPS.length - 1));
}, []);
const zoomOut = useCallback(() => {
setZoomIndex((i) => Math.max(i - 1, 0));
}, []);
const fitToWidth = useCallback(() => {
setZoomIndex(2); // Reset to 100%
}, []);
const downloadSvg = useCallback(() => {
const blob = new Blob([svg], { type: 'image/svg+xml' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'schematic.svg';
a.click();
URL.revokeObjectURL(url);
}, [svg]);
return (
<div className="relative">
{/* Toolbar */}
<div className="flex items-center gap-1 px-3 py-1.5 border-b border-slate-700/50 bg-slate-800/50">
<button
onClick={zoomOut}
disabled={zoomIndex === 0}
className="p-1 rounded text-slate-400 hover:text-slate-200 hover:bg-slate-700/50 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
title="Zoom out"
>
<ZoomOut className="w-3.5 h-3.5" />
</button>
<span className="text-xs text-slate-500 tabular-nums w-10 text-center">
{Math.round(zoom * 100)}%
</span>
<button
onClick={zoomIn}
disabled={zoomIndex === ZOOM_STEPS.length - 1}
className="p-1 rounded text-slate-400 hover:text-slate-200 hover:bg-slate-700/50 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
title="Zoom in"
>
<ZoomIn className="w-3.5 h-3.5" />
</button>
<button
onClick={fitToWidth}
className="p-1 rounded text-slate-400 hover:text-slate-200 hover:bg-slate-700/50 transition-colors"
title="Reset zoom"
>
<Maximize2 className="w-3.5 h-3.5" />
</button>
<div className="flex-1" />
<button
onClick={downloadSvg}
className="flex items-center gap-1 px-2 py-1 rounded text-xs text-slate-400 hover:text-slate-200 hover:bg-slate-700/50 transition-colors"
title="Download SVG"
>
<Download className="w-3.5 h-3.5" />
<span>SVG</span>
</button>
</div>
{/* SVG Canvas */}
<div
ref={containerRef}
className="overflow-auto bg-white rounded-b-lg"
style={{ maxHeight: '500px' }}
>
<div
className="p-4"
style={{
transform: `scale(${zoom})`,
transformOrigin: 'top left',
}}
dangerouslySetInnerHTML={{ __html: svg }}
/>
</div>
</div>
);
}

View File

@ -8,6 +8,7 @@ import {
Zap, Zap,
Code, Code,
Image, Image,
CircuitBoard,
} from 'lucide-react'; } from 'lucide-react';
import { Button } from '../../ui/Button'; import { Button } from '../../ui/Button';
import { Badge } from '../../ui/Badge'; import { Badge } from '../../ui/Badge';
@ -20,6 +21,7 @@ interface CellToolbarProps {
isFirst: boolean; isFirst: boolean;
isLast: boolean; isLast: boolean;
onRun?: () => void; onRun?: () => void;
onGenerateSchematic?: () => void;
onDelete: () => void; onDelete: () => void;
onMoveUp: () => void; onMoveUp: () => void;
onMoveDown: () => void; onMoveDown: () => void;
@ -58,6 +60,7 @@ export function CellToolbar({
isFirst, isFirst,
isLast, isLast,
onRun, onRun,
onGenerateSchematic,
onDelete, onDelete,
onMoveUp, onMoveUp,
onMoveDown, onMoveDown,
@ -80,6 +83,19 @@ export function CellToolbar({
{/* Cell ID */} {/* Cell ID */}
<span className="text-[10px] text-slate-600 font-mono mr-2">{cellId}</span> <span className="text-[10px] text-slate-600 font-mono mr-2">{cellId}</span>
{/* Generate schematic (SPICE cells only) */}
{onGenerateSchematic && (
<Button
variant="ghost"
size="icon"
onClick={onGenerateSchematic}
disabled={isRunning}
title="Generate schematic from netlist"
>
<CircuitBoard className="w-3.5 h-3.5 text-cyan-400" />
</Button>
)}
{/* Run button (SPICE cells only) */} {/* Run button (SPICE cells only) */}
{onRun && ( {onRun && (
<Button <Button

View File

@ -170,3 +170,23 @@ export async function runCell(
}, },
); );
} }
// ── Schematics ────────────────────────────────────────────────
export interface SchematicResponse {
svg: string | null;
success: boolean;
error?: string;
}
export async function generateSchematic(
notebookId: string,
cellId: string,
): Promise<SchematicResponse> {
return request<SchematicResponse>(
`/api/notebooks/${encodeURIComponent(notebookId)}/cells/${encodeURIComponent(cellId)}/schematic`,
{
method: 'POST',
},
);
}

View File

@ -49,6 +49,7 @@ interface NotebookStore {
setActiveCell: (cellId: string | null) => void; setActiveCell: (cellId: string | null) => void;
runCell: (cellId: string) => Promise<void>; runCell: (cellId: string) => Promise<void>;
runAllCells: () => Promise<void>; runAllCells: () => Promise<void>;
generateSchematic: (cellId: string) => Promise<void>;
setCellOutput: (cellId: string, output: CellOutput) => void; setCellOutput: (cellId: string, output: CellOutput) => void;
updateTitle: (title: string) => void; updateTitle: (title: string) => void;
updateEngine: (engine: string) => void; updateEngine: (engine: string) => void;
@ -222,9 +223,12 @@ export const useNotebookStore = create<NotebookStore>((set, get) => ({
set({ set({
notebook: { notebook: {
...updatedNotebook, ...updatedNotebook,
cells: updatedNotebook.cells.map((c) => cells: updatedNotebook.cells.map((c) => {
c.id === cellId ? { ...c, outputs: [output] } : c, if (c.id !== cellId) return c;
), // Preserve schematic outputs across simulation re-runs
const preserved = c.outputs.filter((o) => o.output_type === 'schematic');
return { ...c, outputs: [output, ...preserved] };
}),
}, },
}); });
} }
@ -245,9 +249,11 @@ export const useNotebookStore = create<NotebookStore>((set, get) => ({
set({ set({
notebook: { notebook: {
...updatedNotebook, ...updatedNotebook,
cells: updatedNotebook.cells.map((c) => cells: updatedNotebook.cells.map((c) => {
c.id === cellId ? { ...c, outputs: [output] } : c, if (c.id !== cellId) return c;
), const preserved = c.outputs.filter((o) => o.output_type === 'schematic');
return { ...c, outputs: [output, ...preserved] };
}),
}, },
}); });
} }
@ -258,6 +264,57 @@ export const useNotebookStore = create<NotebookStore>((set, get) => ({
} }
}, },
generateSchematic: async (cellId: string) => {
const { notebook, notebookId, runningCells } = get();
if (!notebook || !notebookId) return;
const cell = notebook.cells.find((c) => c.id === cellId);
if (!cell || cell.type !== 'spice') return;
const newRunning = new Set(runningCells);
newRunning.add(cellId);
set({ runningCells: newRunning });
try {
const result = await api.generateSchematic(notebookId, cellId);
if (result.success && result.svg) {
const output: CellOutput = {
output_type: 'schematic',
data: { svg: result.svg, success: true },
timestamp: new Date().toISOString(),
};
const updatedNotebook = get().notebook;
if (updatedNotebook) {
set({
notebook: {
...updatedNotebook,
cells: updatedNotebook.cells.map((c) => {
if (c.id !== cellId) return c;
// Replace existing schematic, keep everything else
const others = c.outputs.filter((o) => o.output_type !== 'schematic');
return { ...c, outputs: [...others, output] };
}),
},
});
}
} else {
set({
error: result.error || 'Schematic generation failed',
});
}
} catch (err) {
set({
error: err instanceof Error ? err.message : 'Schematic generation failed',
});
} finally {
const currentRunning = new Set(get().runningCells);
currentRunning.delete(cellId);
set({ runningCells: currentRunning });
}
},
runAllCells: async () => { runAllCells: async () => {
const { notebook } = get(); const { notebook } = get();
if (!notebook) return; if (!notebook) return;