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",
"numpy>=1.24.0",
"websockets>=12.0",
"schemdraw>=0.19",
]
[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 spicebook.config import settings
from spicebook.routers import notebooks, simulation, waveforms
from spicebook.routers import notebooks, schematics, simulation, waveforms
logger = logging.getLogger("spicebook")
@ -27,9 +27,11 @@ def create_app() -> FastAPI:
allow_origins=[
"http://localhost:4321",
"http://localhost:4322",
"http://localhost:4326",
"http://localhost:3000",
"http://127.0.0.1:4321",
"http://127.0.0.1:4322",
"http://127.0.0.1:4326",
"http://127.0.0.1:3000",
],
allow_credentials=True,
@ -38,6 +40,7 @@ def create_app() -> FastAPI:
)
application.include_router(notebooks.router)
application.include_router(schematics.router)
application.include_router(simulation.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:
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()
cell.outputs = [CellOutput(
sim_output = CellOutput(
output_type="simulation_result" if result.success else "error",
data={
"success": result.success,
@ -79,7 +79,9 @@ async def run_cell(notebook_id: str, cell_id: str):
"elapsed_seconds": result.elapsed_seconds,
},
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)

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" },
]
[[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]]
name = "spicebook"
version = "2026.2.13"
@ -454,6 +463,7 @@ dependencies = [
{ name = "fastapi" },
{ name = "numpy" },
{ name = "pydantic" },
{ name = "schemdraw" },
{ name = "uvicorn", extra = ["standard"] },
{ name = "websockets" },
]
@ -475,6 +485,7 @@ requires-dist = [
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" },
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23" },
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.8" },
{ name = "schemdraw", specifier = ">=0.19" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.30.0" },
{ name = "websockets", specifier = ">=12.0" },
]

View File

@ -5,6 +5,7 @@ import { useNotebookStore } from '../../../lib/notebook-store';
import { CellToolbar } from '../toolbar/CellToolbar';
import { SpiceEditor } from '../editor/SpiceEditor';
import { WaveformViewer } from '../output/WaveformViewer';
import { SchematicViewer } from '../output/SchematicViewer';
import { SimulationLog } from '../output/SimulationLog';
import { cn } from '../../../lib/cn';
@ -23,6 +24,7 @@ export function SpiceCell({ cell, isFirst, isLast }: SpiceCellProps) {
deleteCell,
moveCell,
runCell,
generateSchematic,
} = useNotebookStore();
const isActive = activeCell === cell.id;
@ -39,9 +41,15 @@ export function SpiceCell({ cell, isFirst, isLast }: SpiceCellProps) {
runCell(cell.id);
}, [cell.id, runCell]);
// Extract output data
const lastOutput = cell.outputs.length > 0 ? cell.outputs[cell.outputs.length - 1] : null;
const outputData = lastOutput?.data as {
const handleGenerateSchematic = useCallback(() => {
generateSchematic(cell.id);
}, [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;
waveform?: WaveformData | null;
log?: string;
@ -49,6 +57,10 @@ export function SpiceCell({ cell, isFirst, isLast }: SpiceCellProps) {
elapsed_seconds?: number;
} | null;
// Extract schematic output
const schematicOutput = cell.outputs.find((o) => o.output_type === 'schematic');
const schematicSvg = schematicOutput?.data?.svg as string | null;
return (
<div
className={cn(
@ -66,12 +78,20 @@ export function SpiceCell({ cell, isFirst, isLast }: SpiceCellProps) {
isFirst={isFirst}
isLast={isLast}
onRun={handleRun}
onGenerateSchematic={handleGenerateSchematic}
onDelete={() => deleteCell(cell.id)}
onMoveUp={() => moveCell(cell.id, 'up')}
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]">
<SpiceEditor
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="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" />
Running simulation...
Processing...
</div>
</div>
)}
{/* Outputs */}
{/* Waveform + Simulation results — shown below the editor */}
{!isRunning && outputData && (
<div className="border-t border-slate-700/50">
{/* 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,
Code,
Image,
CircuitBoard,
} from 'lucide-react';
import { Button } from '../../ui/Button';
import { Badge } from '../../ui/Badge';
@ -20,6 +21,7 @@ interface CellToolbarProps {
isFirst: boolean;
isLast: boolean;
onRun?: () => void;
onGenerateSchematic?: () => void;
onDelete: () => void;
onMoveUp: () => void;
onMoveDown: () => void;
@ -58,6 +60,7 @@ export function CellToolbar({
isFirst,
isLast,
onRun,
onGenerateSchematic,
onDelete,
onMoveUp,
onMoveDown,
@ -80,6 +83,19 @@ export function CellToolbar({
{/* Cell ID */}
<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) */}
{onRun && (
<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;
runCell: (cellId: string) => Promise<void>;
runAllCells: () => Promise<void>;
generateSchematic: (cellId: string) => Promise<void>;
setCellOutput: (cellId: string, output: CellOutput) => void;
updateTitle: (title: string) => void;
updateEngine: (engine: string) => void;
@ -222,9 +223,12 @@ export const useNotebookStore = create<NotebookStore>((set, get) => ({
set({
notebook: {
...updatedNotebook,
cells: updatedNotebook.cells.map((c) =>
c.id === cellId ? { ...c, outputs: [output] } : c,
),
cells: updatedNotebook.cells.map((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({
notebook: {
...updatedNotebook,
cells: updatedNotebook.cells.map((c) =>
c.id === cellId ? { ...c, outputs: [output] } : c,
),
cells: updatedNotebook.cells.map((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 () => {
const { notebook } = get();
if (!notebook) return;