Color-code resistor zigzag symbols with standard 4-band colors

Map physical resistor color bands onto the IEEE zigzag schematic symbol.
Each sub-segment is colored to match its band (digit, multiplier, tolerance),
with wire-colored entry/exit and a gap before tolerance mimicking real spacing.
Parseable values 0.01–1GΩ get color; parametric/out-of-range fall back to mono.
This commit is contained in:
Ryan Malloy 2026-02-20 17:36:50 -07:00
parent 67bb47c0cd
commit 7e10b87a13
4 changed files with 183 additions and 1 deletions

View File

@ -38,6 +38,7 @@ SpiceBook lets you write a document that says: "here's why this filter works, he
- Click-drag zoom, scroll to pan - Click-drag zoom, scroll to pan
- **ngspice backend** -- subprocess execution with binary `.raw` file parsing - **ngspice backend** -- subprocess execution with binary `.raw` file parsing
- **Notebook operations** -- create, open, edit, save, delete, reorder cells, run individually or all at once - **Notebook operations** -- create, open, edit, save, delete, reorder cells, run individually or all at once
- **Color-coded resistor schematics** -- zigzag symbols colored with standard 4-band resistor color codes. A 10kΩ resistor renders Brown-Black-Orange-Gold directly on the schematic symbol, bridging the gap between physical components and abstract schematics. Falls back to standard monochrome for parametric or out-of-range values.
- **Keyboard shortcuts** -- `Ctrl+S` save, `Shift+Enter` run cell - **Keyboard shortcuts** -- `Ctrl+S` save, `Shift+Enter` run cell
- **Clean URLs** -- `/notebook/rc-lowpass-filter` via Astro dynamic routes - **Clean URLs** -- `/notebook/rc-lowpass-filter` via Astro dynamic routes
- **15 example notebooks** included: - **15 example notebooks** included:
@ -139,7 +140,9 @@ spicebook/
│ │ ├── config.py # Settings from environment │ │ ├── config.py # Settings from environment
│ │ ├── engine/ # SPICE engine abstraction │ │ ├── engine/ # SPICE engine abstraction
│ │ │ ├── base.py # Abstract base class │ │ │ ├── base.py # Abstract base class
│ │ │ └── ngspice.py # ngspice subprocess + .raw parser │ │ │ ├── ngspice.py # ngspice subprocess + .raw parser
│ │ │ ├── schematic.py # Netlist → SVG schematic (SchemDraw)
│ │ │ └── resistor_colors.py # Color-coded zigzag resistors
│ │ ├── models/ # Pydantic schemas │ │ ├── models/ # Pydantic schemas
│ │ │ ├── notebook.py # Notebook, Cell, CellOutput │ │ │ ├── notebook.py # Notebook, Cell, CellOutput
│ │ │ └── simulation.py │ │ │ └── simulation.py

View File

@ -0,0 +1,163 @@
"""Resistor color code bands mapped onto zigzag schematic symbols.
Maps standard 4-band resistor color codes directly onto the IEEE zigzag
(zigzag) symbol segments. Each zigzag sub-segment is colored to match
its corresponding band, teaching the color code while reading schematics.
Band-to-segment mapping (4-band):
Seg 0 (entry half-rise): wire color (default)
Seg 1 (peak-to-valley): 1st significant digit
Seg 2 (valley-to-peak): 2nd significant digit
Seg 3 (peak-to-valley): multiplier
Seg 4 (valley-to-peak): gap wire color (mimics physical spacing)
Seg 5 (peak-to-valley): tolerance
Seg 6 (exit half-rise): wire color (default)
"""
import re
from schemdraw.elements.twoterm import Element2Term
from schemdraw.segments import Segment
# ── SPICE Value Parser ──────────────────────────────────────
_SPICE_SUFFIXES = {
"T": 1e12,
"G": 1e9,
"MEG": 1e6,
"K": 1e3,
"M": 1e6, # For resistors, bare M = Mega (milliohm resistors are vanishingly rare)
"U": 1e-6,
"N": 1e-9,
"P": 1e-12,
"F": 1e-15,
}
_SUFFIX_RE = re.compile(
r"^([0-9]*\.?[0-9]+)\s*(MEG|[TGKMUNPF])?$",
re.IGNORECASE,
)
def parse_spice_value(value_str: str) -> float | None:
"""Convert a SPICE value string to numeric ohms.
Handles: "10k", "4.7MEG", "100", "2.2K", "0.1", "10k ic=0"
Returns None for parametric expressions like "{R_val}".
"""
if not value_str:
return None
token = value_str.strip().split()[0]
if token.startswith("{"):
return None
# Try bare numeric first
try:
return float(token)
except ValueError:
pass
m = _SUFFIX_RE.match(token)
if m:
number = float(m.group(1))
suffix = (m.group(2) or "").upper()
multiplier = _SPICE_SUFFIXES.get(suffix, 1.0)
return number * multiplier
return None
# ── Hex Colors Tuned for Mims Background (#f8faf6) ─────────
BAND_HEX: dict[str, str] = {
"black": "#1a1a1a",
"brown": "#8B4513",
"red": "#CC0000",
"orange": "#E67300",
"yellow": "#B8960B", # Dark goldenrod — visible on off-white
"green": "#006400",
"blue": "#0000CC",
"violet": "#7B00B3",
"gray": "#666666",
"white": "#D0D0D0", # Light gray — visible on off-white
"gold": "#B8860B",
"silver": "#808080",
"pink": "#FF69B4",
}
# ── Zigzag Geometry (matches SchemDraw 0.22 ResistorIEEE) ───
_RW = 1 / 6 # reswidth
_RH = 0.25 # resheight
_ZIGZAG_POINTS = [
(0, 0),
(0.5 * _RW, _RH),
(1.5 * _RW, -_RH),
(2.5 * _RW, _RH),
(3.5 * _RW, -_RH),
(4.5 * _RW, _RH),
(5.5 * _RW, -_RH),
(6 * _RW, 0),
]
# ── Custom Element ──────────────────────────────────────────
class ColorCodedResistor(Element2Term):
"""IEEE zigzag resistor with segments colored as standard color code bands.
Uses an overlay strategy: segments[0] is the full 8-point zigzag path
(needed by Element2Term._place() for lead extension), then individual
2-point colored segments are appended at a higher zorder so they render
on top, hiding the base path under the band colors.
"""
def __init__(self, value_ohms: float, tolerance_pct: float = 5, **kwargs):
super().__init__(**kwargs)
# segments[0]: full zigzag — required for Element2Term lead geometry
self.segments.append(Segment(_ZIGZAG_POINTS))
# Get 4-band color names from SchemDraw's existing logic
from schemdraw.pictorial.pictorial import resistor_colors
band_names = resistor_colors(value_ohms, tolerance_pct)
# Map band colors to the 7 zigzag sub-segments
hex_colors = _map_4band(band_names)
# Add colored overlay segments at higher zorder
for i, hex_color in enumerate(hex_colors):
if hex_color is not None:
self.segments.append(
Segment(
[_ZIGZAG_POINTS[i], _ZIGZAG_POINTS[i + 1]],
color=hex_color,
zorder=3,
)
)
def _map_4band(
band_names: tuple[str, str, str, str | None],
) -> list[str | None]:
"""Map 4-band color names to 7 zigzag sub-segment slots.
Returns a list of 7 hex color strings (or None for wire-colored segments).
The gap at seg 4 mimics the physical spacing before the tolerance band.
"""
c1, c2, c3, ct = band_names
return [
None, # seg 0: entry half-rise (wire)
BAND_HEX.get(c1), # seg 1: 1st significant digit
BAND_HEX.get(c2), # seg 2: 2nd significant digit
BAND_HEX.get(c3), # seg 3: multiplier
None, # seg 4: gap (wire — mimics physical spacing)
BAND_HEX.get(ct) if ct else None, # seg 5: tolerance
None, # seg 6: exit half-rise (wire)
]

View File

@ -579,6 +579,11 @@ def _get_element(comp: SpiceComponent, models: dict[str, str]):
prefix = comp.prefix prefix = comp.prefix
if prefix == "R": if prefix == "R":
from spicebook.engine.resistor_colors import ColorCodedResistor, parse_spice_value
ohms = parse_spice_value(comp.value)
if ohms is not None and 0.01 <= ohms <= 1e9:
return ColorCodedResistor(ohms)
return elm.Resistor() return elm.Resistor()
elif prefix == "C": elif prefix == "C":
return elm.Capacitor() return elm.Capacitor()

View File

@ -246,6 +246,17 @@ No request body. Cell must be type `spice`.
} }
``` ```
**Color-coded resistors**: Resistors with parseable values between 0.01 and 1e9 ohms render as IEEE zigzag symbols with segments colored according to the standard 4-band resistor color code. A 10k resistor shows Brown-Black-Orange-Gold bands directly on the zigzag. The entry/exit half-segments and the gap before the tolerance band use the default wire color, mimicking the physical spacing that indicates reading direction. Parametric values (e.g. `{R_val}`) and out-of-range resistances fall back to a standard monochrome zigzag.
Band-to-segment mapping (7 zigzag sub-segments):
- Seg 0 (entry): wire color
- Seg 1: 1st significant digit
- Seg 2: 2nd significant digit
- Seg 3: multiplier
- Seg 4: gap (wire color, mimics physical spacing)
- Seg 5: tolerance
- Seg 6 (exit): wire color
--- ---
### Waveforms ### Waveforms