From b8b83fd282298d4c8ed987f556b2530a18317384 Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Mon, 23 Feb 2026 19:17:46 -0700 Subject: [PATCH] Improve schematic label readability and eliminate wire crossings Replace scattered magic numbers with named layout constants (_LABEL_OFST, _NET_LABEL_FONTSIZE, _PARALLEL_PATH_SPACING, etc.) for consistent spacing. Add "input_up" path classification that routes base/gate bias paths to VCC left-then-up with local Vdd symbols, preventing wire crossings with collector/drain vertical paths. Add _choose_label_side() for smart alternating label placement and _sort_parallel_paths() to draw longest chains closest to the device body. Fix PNP/PFET polarity handling: supply terminal stub direction now matches device polarity instead of always drawing upward. Vertical chain label improvements: - valign='bottom' for down-going chains pushes labels above midpoint - Gap wire (0.5 units) before ground/Vdd terminators prevents overlap - Minimum component length (_VERT_CHAIN_MIN_LEN) for readable labels - valign='center' on device body and horizontal-turn labels --- backend/src/spicebook/engine/schematic.py | 193 +++++++++++++++++----- 1 file changed, 149 insertions(+), 44 deletions(-) diff --git a/backend/src/spicebook/engine/schematic.py b/backend/src/spicebook/engine/schematic.py index 8d35032..8879c1a 100644 --- a/backend/src/spicebook/engine/schematic.py +++ b/backend/src/spicebook/engine/schematic.py @@ -71,6 +71,17 @@ class PlacedComponent: GROUND_NAMES = {"0", "gnd"} SUPPLY_NAMES = {"vcc", "vdd", "vee", "vss"} +# ── Connected Layout Constants ──────────────────────────────── +# Replaces scattered magic numbers for label offsets, font sizes, and spacing. +_LABEL_OFST = 0.5 # general label offset from component body +_LABEL_OFST_TIGHT = 0.2 # horizontal single-component paths +_NET_LABEL_FONTSIZE = 10 # net/node labels (was 8-9) +_DEVICE_LABEL_OFST = 0.4 # active device body label offset +_PARALLEL_PATH_SPACING = 4.0 # between parallel vertical paths (was 2.5) +_LEAD_STUB_LENGTH = 1.0 # clearance from transistor pin (was 0.75) +_INPUT_JUNCTION_LENGTH = 1.5 # base/gate junction wire (was 1.0) +_VERT_CHAIN_MIN_LEN = 4.0 # minimum component length in vertical chains + def _is_ground(node: str) -> bool: return node.lower() in GROUND_NAMES @@ -524,8 +535,15 @@ def _path_style( supply_term: str, input_term: str, ) -> str: - """Classify a path's drawing style: up, down, input, or output.""" + """Classify a path's drawing style: up, down, input, input_up, or output. + + ``input_up`` routes input-terminal bias paths (base/gate → VCC) left then + up with a local Vdd symbol, keeping them on the input side of the + schematic and avoiding wire crossings with collector/drain vertical paths. + """ if path.end_type == "supply": + if term_name == input_term: + return "input_up" return "up" if not is_inverted else "down" if term_name == input_term and has_signal and len(path.components) > 1: return "input" @@ -638,7 +656,7 @@ def _render_loop(parsed: ParsedNetlist, loop: list[SpiceComponent]) -> str: d.add( _get_element(source, parsed.models) .up() - .label(_component_label(source), loc="left", ofst=0.15) + .label(_component_label(source), loc="left", ofst=_LABEL_OFST) ) d.add(elm.Ground()) return d.get_imagedata("svg").decode() @@ -647,7 +665,7 @@ def _render_loop(parsed: ParsedNetlist, loop: list[SpiceComponent]) -> str: src = d.add( _get_element(source, parsed.models) .up() - .label(_component_label(source), loc="left", ofst=0.15) + .label(_component_label(source), loc="left", ofst=_LABEL_OFST) ) # All components except the last go right across the top @@ -655,7 +673,7 @@ def _render_loop(parsed: ParsedNetlist, loop: list[SpiceComponent]) -> str: d.add( _get_element(comp, parsed.models) .right() - .label(_component_label(comp), ofst=0.15) + .label(_component_label(comp), ofst=_LABEL_OFST) ) # Last component goes down, ending at the same y-level as the source start @@ -664,7 +682,7 @@ def _render_loop(parsed: ParsedNetlist, loop: list[SpiceComponent]) -> str: _get_element(last_comp, parsed.models) .down() .toy(src.start) - .label(_component_label(last_comp), loc="right", ofst=0.15) + .label(_component_label(last_comp), loc="right", ofst=_LABEL_OFST) ) # Return wire along the bottom back to source start @@ -679,7 +697,7 @@ def _render_loop(parsed: ParsedNetlist, loop: list[SpiceComponent]) -> str: d.add( elm.Dot(open=True) .at(src.end) - .label(node_label, loc="left", fontsize=9) + .label(node_label, loc="left", fontsize=_NET_LABEL_FONTSIZE) ) # Node label at junction between top components and the last (shunt) component @@ -1009,7 +1027,7 @@ def _render_grid(parsed: ParsedNetlist) -> str: node_ys = [tiers.get(n, _GRID_MID_Y) for n in comp.nodes[:3]] center_y = (max(node_ys) + min(node_ys)) / 2 placed_elem = d.add( - elem.at((x, center_y)).label(_component_label(comp), loc="right", ofst=0.15) + elem.at((x, center_y)).label(_component_label(comp), loc="right", ofst=_LABEL_OFST) ) elif len(comp.nodes) >= 2: # 2-terminal: orient vertically between the two node tiers @@ -1021,19 +1039,19 @@ def _render_grid(parsed: ParsedNetlist) -> str: # node[0] higher → go down so start=node[0], end=node[1] placed_elem = d.add( elem.at((x, y0)).down().length(length) - .label(_component_label(comp), loc="right", ofst=0.15) + .label(_component_label(comp), loc="right", ofst=_LABEL_OFST) ) else: # node[0] lower → go up so start=node[0], end=node[1] placed_elem = d.add( elem.at((x, y0)).up().length(length) - .label(_component_label(comp), loc="right", ofst=0.15) + .label(_component_label(comp), loc="right", ofst=_LABEL_OFST) ) else: # Fallback: single-node or unusual component placed_elem = d.add( elem.at((x, _GRID_MID_Y)).down().length(_GRID_MIN_COMP_LEN) - .label(_component_label(comp), loc="right", ofst=0.15) + .label(_component_label(comp), loc="right", ofst=_LABEL_OFST) ) positions = _get_terminal_positions(placed_elem, comp) @@ -1059,7 +1077,7 @@ def _render_grid(parsed: ParsedNetlist) -> str: px, py = positions[0] d.add( elm.Dot(open=True).at((px, py)) - .label(node, loc="right", fontsize=9) + .label(node, loc="right", fontsize=_NET_LABEL_FONTSIZE) ) continue @@ -1073,7 +1091,7 @@ def _render_grid(parsed: ParsedNetlist) -> str: # Label at the midpoint of the wire mx = (positions[0][0] + positions[1][0]) / 2 my = (positions[0][1] + positions[1][1]) / 2 - d.add(elm.Label().at((mx, my)).label(node, loc="right", fontsize=9)) + d.add(elm.Label().at((mx, my)).label(node, loc="right", fontsize=_NET_LABEL_FONTSIZE)) else: # 3+ connections: star wiring with junction dot _draw_star_wires(d, positions) @@ -1081,7 +1099,7 @@ def _render_grid(parsed: ParsedNetlist) -> str: ys = sorted(p[1] for p in positions) d.add( elm.Label().at((xs[len(xs) // 2], ys[len(ys) // 2])) - .label(node, loc="right", fontsize=9) + .label(node, loc="right", fontsize=_NET_LABEL_FONTSIZE) ) return d.get_imagedata("svg").decode() @@ -1096,14 +1114,14 @@ def _label_two_terminal(d, placed, comp: SpiceComponent) -> None: d.add( elm.Label() .at(placed.start) - .label(comp.nodes[0], loc="left", fontsize=8) + .label(comp.nodes[0], loc="left", fontsize=_NET_LABEL_FONTSIZE) ) 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) + .label(comp.nodes[1], loc="right", fontsize=_NET_LABEL_FONTSIZE) ) @@ -1129,10 +1147,35 @@ def _label_multiterminal(d, placed, comp: SpiceComponent) -> None: d.add( elm.Label() .at(pos) - .label(label_text, fontsize=8) + .label(label_text, fontsize=_NET_LABEL_FONTSIZE) ) +# ── Connected Layout Helpers ───────────────────────────────── + + +def _choose_label_side(path_index: int, is_left_of_device: bool) -> str: + """Pick label side so labels fan outward from the circuit center. + + Even-indexed paths (0, 2, 4, ...) get their natural side. + Odd-indexed paths (1, 3, 5, ...) get the opposite side. + """ + natural = "left" if is_left_of_device else "right" + opposite = "right" if is_left_of_device else "left" + if path_index % 2 == 0: + return natural + return opposite + + +def _sort_parallel_paths(paths: list[TerminalPath]) -> list[TerminalPath]: + """Sort parallel paths: longest chains closest to device (index 0). + + Longer chains draw adjacent to the device body so their wires don't + cross shorter-path terminators offset further out. + """ + return sorted(paths, key=lambda p: -len(p.components)) + + # ── Connected Layout Renderer ──────────────────────────────── @@ -1147,20 +1190,31 @@ def _draw_vert_chain( import schemdraw.elements as elm direction = "up" if going_up else "down" + # Labels push away from terminators: going down → labels above midpoint, + # going up → labels below midpoint (default). + label_valign = "bottom" if not going_up else None for i, comp in enumerate(components): elem = _get_element(comp, parsed.models) if i == 0 and start is not None: elem = elem.at(start) - elem = getattr(elem, direction)() - elem = elem.label(_component_label(comp), loc=label_loc, ofst=0.15) + elem = getattr(elem, direction)().length(_VERT_CHAIN_MIN_LEN) + label_kw: dict = dict(loc=label_loc, ofst=_LABEL_OFST) + if label_valign: + label_kw["valign"] = label_valign + elem = elem.label(_component_label(comp), **label_kw) d.add(elem) + # Short gap wire before terminators so ground/Vdd symbols don't + # overlap the last component's label text. + if end_type in ("ground", "supply"): + d.add(getattr(elm.Line(), direction)().length(0.5)) + if end_type == "ground": d.add(elm.Ground()) elif end_type == "supply": d.add(elm.Vdd().label(end_node.upper())) else: - d.add(elm.Dot(open=True).label(end_node, fontsize=9)) + d.add(elm.Dot(open=True).label(end_node, fontsize=_NET_LABEL_FONTSIZE)) def _draw_horiz_then_down(d, parsed, start, path, going_right): @@ -1180,13 +1234,15 @@ def _draw_horiz_then_down(d, parsed, start, path, going_right): # Turn downward at the bend — label faces outward d.push() down_label = "right" if going_right else "left" - d.add(elem.down().label(_component_label(comp), loc=down_label, ofst=0.15)) + d.add(elem.down().label( + _component_label(comp), loc=down_label, ofst=_LABEL_OFST, valign="center", + )) if path.end_type == "ground": d.add(elm.Ground()) elif path.end_type == "supply": d.add(elm.Vdd().label(path.end_node.upper())) else: - d.add(elm.Dot(open=True).label(path.end_node, fontsize=9)) + d.add(elm.Dot(open=True).label(path.end_node, fontsize=_NET_LABEL_FONTSIZE)) d.pop() # Label the junction node at the bend prev = comps[-2] @@ -1195,11 +1251,11 @@ def _draw_horiz_then_down(d, parsed, start, path, going_right): if not _is_ground(node) and not _is_supply(node): loc = "right" if going_right else "left" d.add( - elm.Dot(open=True).label(node, loc=loc, fontsize=9) + elm.Dot(open=True).label(node, loc=loc, fontsize=_NET_LABEL_FONTSIZE) ) break else: - d.add(getattr(elem, h_dir)().label(_component_label(comp), ofst=0.15)) + d.add(getattr(elem, h_dir)().label(_component_label(comp), ofst=_LABEL_OFST_TIGHT)) # Single-component horizontal path ending at ground/supply/open if len(comps) == 1: @@ -1208,7 +1264,7 @@ def _draw_horiz_then_down(d, parsed, start, path, going_right): elif path.end_type == "supply": d.add(elm.Vdd().label(path.end_node.upper())) else: - d.add(elm.Dot(open=True).label(path.end_node, fontsize=9)) + d.add(elm.Dot(open=True).label(path.end_node, fontsize=_NET_LABEL_FONTSIZE)) def _render_connected(parsed: ParsedNetlist, layout: ActiveLayout) -> str: @@ -1228,7 +1284,10 @@ def _render_connected(parsed: ParsedNetlist, layout: ActiveLayout) -> str: # (Mims convention: transistor type label beside the symbol) if layout.device_type.startswith("bjt"): dev_elem = elm.BjtPnp() if is_inverted else elm.BjtNpn() - q = d.add(dev_elem.label(_component_label(layout.device), loc="right", ofst=0.15)) + q = d.add(dev_elem.label( + _component_label(layout.device), loc="right", + ofst=_DEVICE_LABEL_OFST, valign="center", + )) anchors = { "collector": q.collector, "base": q.base, @@ -1238,7 +1297,10 @@ def _render_connected(parsed: ParsedNetlist, layout: ActiveLayout) -> str: input_term = "base" else: dev_elem = elm.PFet() if is_inverted else elm.NFet() - q = d.add(dev_elem.label(_component_label(layout.device), loc="right", ofst=0.15)) + q = d.add(dev_elem.label( + _component_label(layout.device), loc="right", + ofst=_DEVICE_LABEL_OFST, valign="center", + )) anchors = { "drain": q.drain, "gate": q.gate, @@ -1257,6 +1319,7 @@ def _render_connected(parsed: ParsedNetlist, layout: ActiveLayout) -> str: down_paths: list[TerminalPath] = [] input_paths: list[TerminalPath] = [] output_paths: list[TerminalPath] = [] + input_up_paths: list[TerminalPath] = [] for p in term_paths: has_sig = any(c.name in signal_names for c in p.components) @@ -1269,41 +1332,60 @@ def _render_connected(parsed: ParsedNetlist, layout: ActiveLayout) -> str: down_paths.append(p) elif style == "input": input_paths.append(p) + elif style == "input_up": + input_up_paths.append(p) else: output_paths.append(p) - total = len(up_paths) + len(down_paths) + len(input_paths) + len(output_paths) + total = ( + len(up_paths) + len(down_paths) + len(input_paths) + + len(output_paths) + len(input_up_paths) + ) - # Mims-style label placement: vertical labels go LEFT, - # keeping clear of the VCC/Ground terminators above/below. - # The transistor label sits on the RIGHT, so left is open. - # Offset parallel paths (i > 0) flip to RIGHT to face outward. - vert_label = "left" + # Mims-style label placement: vertical chain labels default to LEFT + # because the transistor body label already sits on the RIGHT. + # Parallel offset paths (i > 0) alternate sides to avoid overlap. + is_left = True # Lead stub wires from collector/emitter create clearance from Q # (Mims always drew short leads from transistor pins) if term_name == input_term and total > 1: # Base/gate: junction wire left for bias branching - junc = d.add(elm.Line().at(anchor).left(1)) + junc = d.add(elm.Line().at(anchor).left(_INPUT_JUNCTION_LENGTH)) draw_from = junc.end - elif term_name == supply_term and (up_paths or output_paths): - # Collector/drain: short stub up for clearance - lead = d.add(elm.Line().at(anchor).up().length(0.75)) + elif term_name == supply_term and (up_paths or down_paths or output_paths): + # Supply terminal stub: direction matches polarity. + # NPN/NFET collector→up; PNP/PFET emitter→down. + # Longer when output paths also branch so labels clear the wire. + stub = _LEAD_STUB_LENGTH * (1.5 if output_paths else 1.0) + if is_inverted: + lead = d.add(elm.Line().at(anchor).down().length(stub)) + else: + lead = d.add(elm.Line().at(anchor).up().length(stub)) draw_from = lead.end - elif term_name not in (supply_term, input_term) and (down_paths): - # Emitter/source: short stub down for clearance - lead = d.add(elm.Line().at(anchor).down().length(0.75)) + elif term_name not in (supply_term, input_term) and (up_paths or down_paths): + # Ground terminal stub: opposite direction from supply. + # NPN/NFET emitter→down; PNP/PFET collector→up. + if is_inverted: + lead = d.add(elm.Line().at(anchor).up().length(_LEAD_STUB_LENGTH)) + else: + lead = d.add(elm.Line().at(anchor).down().length(_LEAD_STUB_LENGTH)) draw_from = lead.end else: draw_from = anchor + # Sort all path lists: longest chains closest to device + up_paths = _sort_parallel_paths(up_paths) + down_paths = _sort_parallel_paths(down_paths) + output_paths = _sort_parallel_paths(output_paths) + input_paths = _sort_parallel_paths(input_paths) + # Vertical-up paths (toward supply rail) for i, p in enumerate(up_paths): d.push() - # Offset parallel paths flip label side to avoid overlap - loc = "right" if i > 0 else vert_label + loc = _choose_label_side(i, is_left) if i > 0: - d.add(elm.Line().at(draw_from).right(2.5 * i)) + d.add(elm.Line().at(draw_from).right(_PARALLEL_PATH_SPACING * i)) _draw_vert_chain( d, parsed, None, p.components, True, p.end_type, p.end_node, label_loc=loc, @@ -1318,9 +1400,9 @@ def _render_connected(parsed: ParsedNetlist, layout: ActiveLayout) -> str: # Vertical-down paths (toward ground) for i, p in enumerate(down_paths): d.push() - loc = "right" if i > 0 else vert_label + loc = _choose_label_side(i, is_left) if i > 0: - d.add(elm.Line().at(draw_from).right(2.5 * i)) + d.add(elm.Line().at(draw_from).right(_PARALLEL_PATH_SPACING * i)) _draw_vert_chain( d, parsed, None, p.components, False, p.end_type, p.end_node, label_loc=loc, @@ -1332,6 +1414,29 @@ def _render_connected(parsed: ParsedNetlist, layout: ActiveLayout) -> str: ) d.pop() + # Input-up paths: base/gate bias paths to VCC route left then up + # with their own local Vdd symbol — avoids crossing collector paths. + # + # Geometry: draw_from is already _INPUT_JUNCTION_LENGTH left of + # the transistor anchor (junction wire drawn above). The offset + # below is measured from draw_from, so the total distance from + # the transistor is _INPUT_JUNCTION_LENGTH + input_up_base. + # When input_paths also go left, add _PARALLEL_PATH_SPACING to + # clear the horizontal signal chain (C1→V1, etc.). + input_up_base = _INPUT_JUNCTION_LENGTH + if input_paths: + input_up_base += _PARALLEL_PATH_SPACING + input_up_paths = _sort_parallel_paths(input_up_paths) + for i, p in enumerate(input_up_paths): + d.push() + offset = input_up_base + i * _PARALLEL_PATH_SPACING + d.add(elm.Line().at(draw_from).left(offset)) + _draw_vert_chain( + d, parsed, None, p.components, True, p.end_type, p.end_node, + label_loc="left", + ) + d.pop() + # Output paths (right then down) for p in output_paths: d.push()