diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 72cbc6f..4ab3c39 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -11,6 +11,7 @@ dependencies = [ "pydantic>=2.0", "numpy>=1.24.0", "websockets>=12.0", + "schemdraw>=0.19", ] [project.optional-dependencies] diff --git a/backend/src/spicebook/engine/schematic.py b/backend/src/spicebook/engine/schematic.py new file mode 100644 index 0000000..429da16 --- /dev/null +++ b/backend/src/spicebook/engine/schematic.py @@ -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) diff --git a/backend/src/spicebook/main.py b/backend/src/spicebook/main.py index 9bad435..5dd41e3 100644 --- a/backend/src/spicebook/main.py +++ b/backend/src/spicebook/main.py @@ -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) diff --git a/backend/src/spicebook/routers/schematics.py b/backend/src/spicebook/routers/schematics.py new file mode 100644 index 0000000..527756f --- /dev/null +++ b/backend/src/spicebook/routers/schematics.py @@ -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) diff --git a/backend/src/spicebook/routers/simulation.py b/backend/src/spicebook/routers/simulation.py index 61d60d9..8833522 100644 --- a/backend/src/spicebook/routers/simulation.py +++ b/backend/src/spicebook/routers/simulation.py @@ -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) diff --git a/backend/uv.lock b/backend/uv.lock index fe84667..0634707 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -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" }, ] diff --git a/frontend/src/components/notebook/cells/SpiceCell.tsx b/frontend/src/components/notebook/cells/SpiceCell.tsx index 2efb528..21d7d5f 100644 --- a/frontend/src/components/notebook/cells/SpiceCell.tsx +++ b/frontend/src/components/notebook/cells/SpiceCell.tsx @@ -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 (
deleteCell(cell.id)} onMoveUp={() => moveCell(cell.id, 'up')} onMoveDown={() => moveCell(cell.id, 'down')} /> - {/* Editor */} + {/* Schematic — shown above the editor when generated */} + {schematicSvg && ( +
+ +
+ )} + + {/* SPICE Editor — always visible */}
- Running simulation... + Processing...
)} - {/* Outputs */} + {/* Waveform + Simulation results — shown below the editor */} {!isRunning && outputData && (
{/* Error banner */} diff --git a/frontend/src/components/notebook/output/SchematicViewer.tsx b/frontend/src/components/notebook/output/SchematicViewer.tsx new file mode 100644 index 0000000..01621a8 --- /dev/null +++ b/frontend/src/components/notebook/output/SchematicViewer.tsx @@ -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(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 ( +
+ {/* Toolbar */} +
+ + + {Math.round(zoom * 100)}% + + + +
+ +
+ + {/* SVG Canvas */} +
+
+
+
+ ); +} diff --git a/frontend/src/components/notebook/toolbar/CellToolbar.tsx b/frontend/src/components/notebook/toolbar/CellToolbar.tsx index aefed4f..40d147b 100644 --- a/frontend/src/components/notebook/toolbar/CellToolbar.tsx +++ b/frontend/src/components/notebook/toolbar/CellToolbar.tsx @@ -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 */} {cellId} + {/* Generate schematic (SPICE cells only) */} + {onGenerateSchematic && ( + + )} + {/* Run button (SPICE cells only) */} {onRun && (