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
This commit is contained in:
Ryan Malloy 2026-02-23 19:17:46 -07:00
parent c0639c775c
commit b8b83fd282

View File

@ -71,6 +71,17 @@ class PlacedComponent:
GROUND_NAMES = {"0", "gnd"} GROUND_NAMES = {"0", "gnd"}
SUPPLY_NAMES = {"vcc", "vdd", "vee", "vss"} 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: def _is_ground(node: str) -> bool:
return node.lower() in GROUND_NAMES return node.lower() in GROUND_NAMES
@ -524,8 +535,15 @@ def _path_style(
supply_term: str, supply_term: str,
input_term: str, input_term: str,
) -> 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 path.end_type == "supply":
if term_name == input_term:
return "input_up"
return "up" if not is_inverted else "down" return "up" if not is_inverted else "down"
if term_name == input_term and has_signal and len(path.components) > 1: if term_name == input_term and has_signal and len(path.components) > 1:
return "input" return "input"
@ -638,7 +656,7 @@ def _render_loop(parsed: ParsedNetlist, loop: list[SpiceComponent]) -> str:
d.add( d.add(
_get_element(source, parsed.models) _get_element(source, parsed.models)
.up() .up()
.label(_component_label(source), loc="left", ofst=0.15) .label(_component_label(source), loc="left", ofst=_LABEL_OFST)
) )
d.add(elm.Ground()) d.add(elm.Ground())
return d.get_imagedata("svg").decode() return d.get_imagedata("svg").decode()
@ -647,7 +665,7 @@ def _render_loop(parsed: ParsedNetlist, loop: list[SpiceComponent]) -> str:
src = d.add( src = d.add(
_get_element(source, parsed.models) _get_element(source, parsed.models)
.up() .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 # 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( d.add(
_get_element(comp, parsed.models) _get_element(comp, parsed.models)
.right() .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 # 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) _get_element(last_comp, parsed.models)
.down() .down()
.toy(src.start) .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 # Return wire along the bottom back to source start
@ -679,7 +697,7 @@ def _render_loop(parsed: ParsedNetlist, loop: list[SpiceComponent]) -> str:
d.add( d.add(
elm.Dot(open=True) elm.Dot(open=True)
.at(src.end) .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 # 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]] node_ys = [tiers.get(n, _GRID_MID_Y) for n in comp.nodes[:3]]
center_y = (max(node_ys) + min(node_ys)) / 2 center_y = (max(node_ys) + min(node_ys)) / 2
placed_elem = d.add( 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: elif len(comp.nodes) >= 2:
# 2-terminal: orient vertically between the two node tiers # 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] # node[0] higher → go down so start=node[0], end=node[1]
placed_elem = d.add( placed_elem = d.add(
elem.at((x, y0)).down().length(length) 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: else:
# node[0] lower → go up so start=node[0], end=node[1] # node[0] lower → go up so start=node[0], end=node[1]
placed_elem = d.add( placed_elem = d.add(
elem.at((x, y0)).up().length(length) 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: else:
# Fallback: single-node or unusual component # Fallback: single-node or unusual component
placed_elem = d.add( placed_elem = d.add(
elem.at((x, _GRID_MID_Y)).down().length(_GRID_MIN_COMP_LEN) 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) positions = _get_terminal_positions(placed_elem, comp)
@ -1059,7 +1077,7 @@ def _render_grid(parsed: ParsedNetlist) -> str:
px, py = positions[0] px, py = positions[0]
d.add( d.add(
elm.Dot(open=True).at((px, py)) elm.Dot(open=True).at((px, py))
.label(node, loc="right", fontsize=9) .label(node, loc="right", fontsize=_NET_LABEL_FONTSIZE)
) )
continue continue
@ -1073,7 +1091,7 @@ def _render_grid(parsed: ParsedNetlist) -> str:
# Label at the midpoint of the wire # Label at the midpoint of the wire
mx = (positions[0][0] + positions[1][0]) / 2 mx = (positions[0][0] + positions[1][0]) / 2
my = (positions[0][1] + positions[1][1]) / 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: else:
# 3+ connections: star wiring with junction dot # 3+ connections: star wiring with junction dot
_draw_star_wires(d, positions) _draw_star_wires(d, positions)
@ -1081,7 +1099,7 @@ def _render_grid(parsed: ParsedNetlist) -> str:
ys = sorted(p[1] for p in positions) ys = sorted(p[1] for p in positions)
d.add( d.add(
elm.Label().at((xs[len(xs) // 2], ys[len(ys) // 2])) 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() return d.get_imagedata("svg").decode()
@ -1096,14 +1114,14 @@ def _label_two_terminal(d, placed, comp: SpiceComponent) -> None:
d.add( d.add(
elm.Label() elm.Label()
.at(placed.start) .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: if len(comp.nodes) >= 2:
d.add(elm.Dot(radius=0.08).at(placed.end)) d.add(elm.Dot(radius=0.08).at(placed.end))
d.add( d.add(
elm.Label() elm.Label()
.at(placed.end) .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( d.add(
elm.Label() elm.Label()
.at(pos) .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 ──────────────────────────────── # ── Connected Layout Renderer ────────────────────────────────
@ -1147,20 +1190,31 @@ def _draw_vert_chain(
import schemdraw.elements as elm import schemdraw.elements as elm
direction = "up" if going_up else "down" 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): for i, comp in enumerate(components):
elem = _get_element(comp, parsed.models) elem = _get_element(comp, parsed.models)
if i == 0 and start is not None: if i == 0 and start is not None:
elem = elem.at(start) elem = elem.at(start)
elem = getattr(elem, direction)() elem = getattr(elem, direction)().length(_VERT_CHAIN_MIN_LEN)
elem = elem.label(_component_label(comp), loc=label_loc, ofst=0.15) 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) 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": if end_type == "ground":
d.add(elm.Ground()) d.add(elm.Ground())
elif end_type == "supply": elif end_type == "supply":
d.add(elm.Vdd().label(end_node.upper())) d.add(elm.Vdd().label(end_node.upper()))
else: 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): 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 # Turn downward at the bend — label faces outward
d.push() d.push()
down_label = "right" if going_right else "left" 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": if path.end_type == "ground":
d.add(elm.Ground()) d.add(elm.Ground())
elif path.end_type == "supply": elif path.end_type == "supply":
d.add(elm.Vdd().label(path.end_node.upper())) d.add(elm.Vdd().label(path.end_node.upper()))
else: 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() d.pop()
# Label the junction node at the bend # Label the junction node at the bend
prev = comps[-2] 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): if not _is_ground(node) and not _is_supply(node):
loc = "right" if going_right else "left" loc = "right" if going_right else "left"
d.add( 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 break
else: 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 # Single-component horizontal path ending at ground/supply/open
if len(comps) == 1: if len(comps) == 1:
@ -1208,7 +1264,7 @@ def _draw_horiz_then_down(d, parsed, start, path, going_right):
elif path.end_type == "supply": elif path.end_type == "supply":
d.add(elm.Vdd().label(path.end_node.upper())) d.add(elm.Vdd().label(path.end_node.upper()))
else: 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: 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) # (Mims convention: transistor type label beside the symbol)
if layout.device_type.startswith("bjt"): if layout.device_type.startswith("bjt"):
dev_elem = elm.BjtPnp() if is_inverted else elm.BjtNpn() 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 = { anchors = {
"collector": q.collector, "collector": q.collector,
"base": q.base, "base": q.base,
@ -1238,7 +1297,10 @@ def _render_connected(parsed: ParsedNetlist, layout: ActiveLayout) -> str:
input_term = "base" input_term = "base"
else: else:
dev_elem = elm.PFet() if is_inverted else elm.NFet() 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 = { anchors = {
"drain": q.drain, "drain": q.drain,
"gate": q.gate, "gate": q.gate,
@ -1257,6 +1319,7 @@ def _render_connected(parsed: ParsedNetlist, layout: ActiveLayout) -> str:
down_paths: list[TerminalPath] = [] down_paths: list[TerminalPath] = []
input_paths: list[TerminalPath] = [] input_paths: list[TerminalPath] = []
output_paths: list[TerminalPath] = [] output_paths: list[TerminalPath] = []
input_up_paths: list[TerminalPath] = []
for p in term_paths: for p in term_paths:
has_sig = any(c.name in signal_names for c in p.components) 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) down_paths.append(p)
elif style == "input": elif style == "input":
input_paths.append(p) input_paths.append(p)
elif style == "input_up":
input_up_paths.append(p)
else: else:
output_paths.append(p) 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, # Mims-style label placement: vertical chain labels default to LEFT
# keeping clear of the VCC/Ground terminators above/below. # because the transistor body label already sits on the RIGHT.
# The transistor label sits on the RIGHT, so left is open. # Parallel offset paths (i > 0) alternate sides to avoid overlap.
# Offset parallel paths (i > 0) flip to RIGHT to face outward. is_left = True
vert_label = "left"
# Lead stub wires from collector/emitter create clearance from Q # Lead stub wires from collector/emitter create clearance from Q
# (Mims always drew short leads from transistor pins) # (Mims always drew short leads from transistor pins)
if term_name == input_term and total > 1: if term_name == input_term and total > 1:
# Base/gate: junction wire left for bias branching # 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 draw_from = junc.end
elif term_name == supply_term and (up_paths or output_paths): elif term_name == supply_term and (up_paths or down_paths or output_paths):
# Collector/drain: short stub up for clearance # Supply terminal stub: direction matches polarity.
lead = d.add(elm.Line().at(anchor).up().length(0.75)) # 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 draw_from = lead.end
elif term_name not in (supply_term, input_term) and (down_paths): elif term_name not in (supply_term, input_term) and (up_paths or down_paths):
# Emitter/source: short stub down for clearance # Ground terminal stub: opposite direction from supply.
lead = d.add(elm.Line().at(anchor).down().length(0.75)) # 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 draw_from = lead.end
else: else:
draw_from = anchor 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) # Vertical-up paths (toward supply rail)
for i, p in enumerate(up_paths): for i, p in enumerate(up_paths):
d.push() d.push()
# Offset parallel paths flip label side to avoid overlap loc = _choose_label_side(i, is_left)
loc = "right" if i > 0 else vert_label
if i > 0: 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( _draw_vert_chain(
d, parsed, None, p.components, True, p.end_type, p.end_node, d, parsed, None, p.components, True, p.end_type, p.end_node,
label_loc=loc, label_loc=loc,
@ -1318,9 +1400,9 @@ def _render_connected(parsed: ParsedNetlist, layout: ActiveLayout) -> str:
# Vertical-down paths (toward ground) # Vertical-down paths (toward ground)
for i, p in enumerate(down_paths): for i, p in enumerate(down_paths):
d.push() d.push()
loc = "right" if i > 0 else vert_label loc = _choose_label_side(i, is_left)
if i > 0: 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( _draw_vert_chain(
d, parsed, None, p.components, False, p.end_type, p.end_node, d, parsed, None, p.components, False, p.end_type, p.end_node,
label_loc=loc, label_loc=loc,
@ -1332,6 +1414,29 @@ def _render_connected(parsed: ParsedNetlist, layout: ActiveLayout) -> str:
) )
d.pop() 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) # Output paths (right then down)
for p in output_paths: for p in output_paths:
d.push() d.push()