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:
parent
42f4428295
commit
de7a29c69e
@ -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]
|
||||||
|
|||||||
636
backend/src/spicebook/engine/schematic.py
Normal file
636
backend/src/spicebook/engine/schematic.py
Normal 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)
|
||||||
@ -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)
|
||||||
|
|
||||||
|
|||||||
66
backend/src/spicebook/routers/schematics.py
Normal file
66
backend/src/spicebook/routers/schematics.py
Normal 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)
|
||||||
@ -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
11
backend/uv.lock
generated
@ -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" },
|
||||||
]
|
]
|
||||||
|
|||||||
@ -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 */}
|
||||||
|
|||||||
96
frontend/src/components/notebook/output/SchematicViewer.tsx
Normal file
96
frontend/src/components/notebook/output/SchematicViewer.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
|||||||
@ -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',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user