Program Design
This section describes the internal structure of the SLiCAP Schematic Capture program — which module is responsible for what, how the layers relate to each other, and what data each layer owns. It is written for contributors and for users who want to understand the reasoning behind the architecture.
Overview
The program is built in Python on top of PySide6 (Qt for Python). It follows a classic model–view split:
A data model (plain Python dataclasses, serialised as JSON) is the sole definition of a schematic on disk.
A scene / canvas (a
QGraphicsScenepopulated withQGraphicsItemsubclasses) is the live, interactive view of that model.Export pipelines read the scene directly and produce netlist files, SVG images, and PDF/print output without going through an intermediate format.
The following diagram shows the main modules and their relationships:
┌─────────────────────────────────────────────────────────────────────┐
│ symbol_library.py ─── Symbols.svg + user lib/*.svg │
│ (SymbolLibrary / Symbol) reads: SVG <g id>; writes: component_item│
│ module dicts + SVG bytes for rendering │
└──────────────────────────────┬──────────────────────────────────────┘
│ inject_into_component_item()
┌──────────────────────────────▼──────────────────────────────────────┐
│ component_item.py ─── module-level metadata dicts │
│ (ComponentItem + helpers) SYMBOL_PREFIX, PIN_POSITIONS, … │
└──────────────────────────────┬──────────────────────────────────────┘
│ used by
┌──────────────────────────────▼──────────────────────────────────────┐
│ canvas.py ─── SchematicScene (QGraphicsScene) │
│ + *_item.py all scene items live here │
│ interaction modes, undo/redo │
└──────────┬────────────────────────────────────────────────┬─────────┘
│ to_data() / from_data() │ items
┌──────────▼──────────────────────────────┐ ┌────────────▼─────────┐
│ schematic_data.py │ │ connectivity.py │
│ SchematicData (pure-Python dataclasses│ │ net resolution, │
│ + JSON serialisation) │ │ union-find, junction│
│ → .slicap_sch (JSON on disk) │ │ detection │
└──────────┬──────────────────────────────┘ └──────────────────────┘
│ save / load
┌──────────▼──────────────────────────────┐
│ project.py ─── project root, sidecars │
│ (sch/, cir/, lib/, img/, .cache, .ini) │
└─────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│ export.py / netlist.py ─── read scene items, write files │
│ SVG / PDF / Print / SLiCAP .cir netlist │
└─────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│ window.py ─── MainWindow (QMainWindow) + SchematicView │
│ menus, file dialogs, zoom, grid rendering │
└─────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│ config.py / style.ini / <name>.ini ─── visual constants │
│ grid size, colours, fonts, snap() │
└─────────────────────────────────────────────────────────────────────┘
Layer 1 — Symbol Definitions (symbol_library.py)
What it contains:
All schematic symbol definitions live in one SVG bundle file,
app/symbols/Symbols.svg. Each symbol is a single <g id="name">
element inside the SVG <defs> block. User-defined symbols are individual
*.svg files placed in the project’s lib/ directory; their <g>
elements are parsed by the same loader and added to (or override) the bundle.
A symbol group carries everything the editor needs as SVG attributes:
<g id="R"
data-prefix="R"
data-nodes="p n"
data-model="R"
data-params="value"
data-description="Resistor"
data-info="https://…">
…artwork…
<circle cx="0" cy="-20" r="0.5" class="node" data-node="p"/>
<circle cx="0" cy="20" r="0.5" class="node" data-node="n"/>
</g>
Pin positions are read from class="node" circles whose data-node
attribute matches a name in data-nodes. The editor never infers pin
positions from artwork geometry; if a name in data-nodes has no matching
marker the file is rejected with a SymbolError.
What it does NOT contain: Model equations, SPICE parameters, simulation instructions. The symbol layer is purely visual + structural metadata.
Key classes / functions:
SymbolParsed representation of one
<g>definition. Holdsname,prefix,nodes,pins(pin coordinates indata-nodesorder),model,params,refs,description,info,select_box(computed bounding box with padding),svg(standalone SVG bytes for rendering), andg_xml(raw XML for frozen-bundle export).SymbolLibraryLoads the bundle, then scans individual SVG files. Provides
svg_bytes(name)(bytes for canvas rendering) andinject_into_component_item()(publishes metadata to the scene layer).
Frozen symbol bundle (``<name>.symbols``):
When a schematic is saved, write_bundle() copies the raw <g> XML of
every symbol the schematic uses into <name>.symbols. On the next open,
add_bundle() re-loads these frozen definitions and they override the
system bundle. This means a saved schematic always renders with the symbols
it was originally drawn with, even after the system library changes.
Layer 2 — Component Metadata Registry (component_item.py)
What it contains: Module-level Python dictionaries that map symbol name → metadata value:
SYMBOL_PREFIX— refdes letter (e.g."R","M")PIN_POSITIONS— list of(x, y)tuples indata-nodesorderSYMBOL_TIGHT_RECT—(x, y, w, h)select boxSYMBOL_NODES— ordered node name listSYMBOL_MODEL— SLiCAP model identifierSYMBOL_PARAMS— overridable parameter namesSYMBOL_REFS— number ofdata-refsentriesSYMBOL_DESCRIPTION— human-readable descriptionSYMBOL_INFO— help/datasheet URL
These dicts are populated by SymbolLibrary.inject_into_component_item()
and cleared first, so they always mirror exactly the current library.
They are the only channel through which the symbol layer talks to the scene
layer; the scene layer never imports symbol_library directly.
Why module-level dicts (not instance attributes):
ComponentItem and SchematicScene need to look up pin positions during
mouse events, connectivity checks, and undo/redo restoration. Placing the
data in module-level dicts avoids threading library references through every
call site and keeps the scene items self-contained.
Layer 3 — Data Model (schematic_data.py)
What it contains:
SchematicData is a plain-Python dataclass tree that represents the complete,
serialisable state of a schematic. It contains no Qt objects and no display
logic. Its fields map one-to-one onto the item types in the scene layer:
DocumentPropertiesTitle, author, creation/modification dates, page size, subcircuit flag, subcircuit port order and parameter defaults.
ComponentDataSymbol name, instance ID (refdes), position
(x, y), rotation, flip flags, parameter values (as strings), model override,refs(referenced elements), label display/offset settings.WireDataOrdered list of
(x, y)waypoints defining the polyline, net name, whether the name label is shown, label offset, lock flag (set by port symbols).JunctionDataPosition of a junction dot.
FreeTextData,CommandData,LibraryData,HyperlinkDataPosition and text/URL content.
ImageDataFile path and display size.
LatexFragmentData,ParameterDataLaTeX source, preamble path, base64-encoded SVG render, display size.
AnalysisDataSLiCAP
source,detector, andlgreflists.ShapeDataKind (line/rect/circle), anchor position, relative waypoints, stroke/fill colour, line style, arrow-end markers, line width.
BorderDataPosition, size, and whether the border is included in exports.
Serialisation:
SchematicData.to_json() / from_json() round-trip to compact JSON.
The file is normalised before writing: normalize_origin() shifts all
coordinates so the bounding-box centre lands on the origin (snapped to grid),
keeping the file content stable regardless of where the user positioned the
schematic on the canvas.
The on-disk format is *.slicap_sch (a JSON text file). It is the only
persistent representation of a schematic; there is no separate binary format.
Layer 4 — Canvas Scene (canvas.py + *_item.py)
What it contains:
The live, interactive representation of the schematic. SchematicScene
subclasses QGraphicsScene and owns a flat list of QGraphicsItem
subclasses. Qt’s z-order (insertion order / setZValue) determines which
items appear on top; there are no named “layers” in the Qt sense.
The item types divide into two groups:
Persistent items (serialised into SchematicData on save):
ComponentItemRenders the symbol SVG via
QSvgRendererinside aQGraphicsSvgItem. Hosts childQGraphicsSimpleTextItemlabels for refdes, parameter values, and (for subcircuit blocks) pin names. Carries its ownparamsdict,model, andrefs; these are the live values that get written intoComponentData.params/model/refswhento_data()is called.WireItemA
QPainterPathrepresenting a single straight axis-aligned segment between two grid-snapped endpoints. Carries the net name, display flag, label offset, and lock state.Every committed wire in the scene is exactly two points (no elbows). An L-shaped route drawn by the user is split by
_split_wire_elbows()into two separateWireItems meeting at the corner. This invariant ensures that a single mouse-click selects exactly one straight segment, which can then be moved independently.JunctionItemA filled circle drawn at T-intersections. Created and removed automatically by
_sync_junctions(); the user can also place one manually.FreeTextItem,CommandItemEditable
QGraphicsTextItemsubclasses.CommandItemrenders in a distinct colour/font to distinguish SLiCAP commands (.param,.lib, etc.) from free annotation text.BorderItemA rectangle marking the schematic page boundary. Only one border is allowed per schematic; placing a second one removes the first.
LibraryItemDisplays a
.libfilename annotation and records the library path for netlist export.ImageItemRenders an embedded raster or SVG image. Stores the file path and display size; the image is re-loaded from the path on each open.
LatexFragmentItem,ParameterItemDisplay a LaTeX-rendered SVG pixmap. Store the LaTeX source and the rendered SVG bytes (base64 in the save file) so the schematic opens correctly without re-running
pdflatex.AnalysisItemDisplays the SLiCAP
source/detector/lgrefspecification as a text annotation.HyperlinkItemA styled text item that opens a URL in the browser on double-click.
ShapeItemA drawing-primitive item (line, rectangle, circle) with configurable stroke, fill, line style, and end markers.
Transient items (not saved; discarded when placement ends):
Ghost items — semi-transparent previews of the item being placed, tracking the cursor. Created in each
start_*_placement()call and removed when the item is committed or placement is cancelled.Wire preview — a dashed
QGraphicsPathItemshowing the in-progress wire routing, updated on everymouseMoveEventwhile in WIRING mode.
The scene as the single source of truth for the view:
The canvas does not keep a separate “logical” model alongside the items. The
dataclass model is generated on demand by to_data(), which walks
scene.items() and serialises each item. Conversely, from_data() calls
reset() (clears the scene) and then adds fresh items from the dataclass
tree. Undo/redo is implemented as a stack of SchematicData snapshots:
_push_undo() calls to_data() before a change; undo() calls
_restore() which calls from_data() with the saved snapshot.
Layer 5 — Interaction and Editing Modes (canvas.py)
SchematicScene uses an explicit finite-state machine to route mouse and
keyboard events:
NORMAL ← default; selection, drag, double-click to edit
PLACING ← placing a symbol (ghost follows cursor)
WIRING ← drawing wires (click adds waypoints)
PLACING_JUNCTION ← placing a junction dot
PLACING_TEXT ← placing a free-text item
PLACING_COMMAND ← placing a command text item
PLACING_BORDER ← placing the page border
PLACING_LIBRARY ← placing a .lib annotation
PLACING_IMAGE ← placing an embedded image
PLACING_LATEX ← placing a LaTeX fragment
PLACING_PARAMETER ← placing a parameter table
PLACING_ANALYSIS ← placing a source/detector/lgref block
PLACING_HYPERLINK ← placing a hyperlink
PASTING ← moving the clipboard paste ghost to the drop point
DRAWING_LINE ← free-draw polyline
DRAWING_RECT ← free-draw rectangle
DRAWING_CIRCLE ← free-draw circle
Every mousePressEvent, mouseMoveEvent, and mouseReleaseEvent
dispatches on self._mode first, so each mode has a clean, isolated code
path. The Escape key always returns to NORMAL by calling
_cancel_placement() or _end_wire(commit=False).
Wire selection: Because every committed wire is a single straight 2-point segment, a click selects exactly one segment. Rubber-band selection (left-to-right or right-to-left) selects multiple segments just like components.
Wire drag sub-modes (within NORMAL):
When the user presses on a selected wire, the scene checks whether the cursor
is within _HIT_TOL of a vertex (endpoint).
Vertex drag —
_vdrag_wire/_vdrag_idxtrack the wire and which endpoint is moving. The opposite endpoint stays fixed; one rubber-band wire tracks each adjacent connection.Body drag — the whole segment (and any rubber-band wires tracking its endpoints) moves rigidly. A body drag is only triggered when the cursor is closer to the wire interior than to either vertex.
On mouse release both paths call _sync_junctions() (which can split, merge,
or remove the wire), then _reselect_on_footprint() to restore the Qt
selection so the user can immediately move the wire again.
Undo / redo:
_push_undo() snapshots the current state with to_data() and pushes it
onto _undo_stack (capped at 50 entries). undo() / redo() call
_restore(data) which re-populates the scene from the snapshot. The undo
stack is separate from the data model: each entry is a complete, independent
SchematicData object.
Layer 6 — Connectivity (connectivity.py + canvas.py)
What it contains: Net resolution and topological maintenance. It has no persistent state: all functions operate on the current contents of the scene and are re-run whenever the topology changes.
connectivity.py—resolve_nets()Builds a
{grid_point: net_name}mapping using a union-find structure (_UF) over wire points and component pins. Net names are assigned by priority: ground symbol →"0", port symbol name, explicit usernet_nameon a wire, auto-generated sequential integer.canvas.py—_sync_junctions()Called after every topological edit (place, delete, move, wire commit). Runs the following wire-normalisation pipeline in order, then updates junctions and markers:
Remove zero-length wires (endpoints coincide).
_split_through_wires()— break any wire whose interior is crossed by another wire endpoint or a component pin (T-tap rule)._split_wire_elbows()— decompose every multi-segment (elbow) wire into individual two-point straight segments. After this pass everyWireItemin the scene has exactly two grid-snapped endpoints._merge_collinear_wires()— fuse pairs of collinear adjacent segments that share a junction-free endpoint into a single longer segment. Also removes exact duplicate segments. Fusion is blocked at endpoints that coincide with a component pin, so component connections are never silently absorbed. Net attributes (name, lock, label) are inherited from the segment that carried a locked or named net.Compute the required junction set with
_find_junction_points().Add missing
JunctionItems and remove superfluous ones._sync_port_net_names()— propagate port names to all wires on the same net._refresh_pin_markers()— mark unconnected pins.
canvas.py—_remove_short_circuit_wires()Called when a component or wire is moved. Removes any single wire segment whose both endpoints land exactly on pins of the same component (e.g. a wire accidentally connecting drain to source of a transistor).
The check is strictly per-segment: only a direct pin-to-pin connection expressed as one straight line segment is removed. Intentional multi-hop connections such as a bulk–source tie routed with an elbow survive because the elbow is already decomposed into two separate segments by
_split_wire_elbows(), and each individual segment touches at most one pin of the same component.canvas.py—_reselect_on_footprint(moved_segs)Helper called by both the body-move and vertex-drag release handlers after
_sync_junctions()has run. Re-applies the Qt selection state to anyWireItemthat overlaps the footprint of the just-moved wire(s).Two cases are handled:
Split — the moved segment was broken into shorter pieces; each piece has all its points inside the original footprint so it is re-selected.
Merge — the moved segment was fused with a rubber-band partner into a longer wire; the moved segment is a strict sub-range of the new wire, so the new (longer) wire is re-selected.
Without this helper,
_sync_junctions()would destroy the Qt selection and the user would be unable to immediately move the wire again.canvas.py—_sync_port_net_names()Runs the same union-find as
resolve_nets()but operates on the live scene items rather than the data model. Wires whose net contains a port symbol have theirnet_namelocked to the port name and their original user label saved in_user_net_name; removing the port restores the original name.- Junction detection rule (
_find_junction_points): A junction is required when the total number of connections (wire endpoints + component pins) at a grid point is ≥ 3, or when a wire endpoint lands on the interior of another wire (T-tap safety rule).
Layer 7 — Project and File Layout (project.py)
What it contains:
Resolution of the project directory structure and per-schematic sidecar file
paths. It is a module with a single piece of mutable state: _base, the
path of the currently open .slicap_sch file (None when unsaved).
A SLiCAP project directory has a fixed layout:
<project>/
sch/ <name>.slicap_sch ← schematic source (JSON)
<name>.cache/ ← rendered LaTeX SVGs (one per fragment)
<name>.ini ← per-schematic style overrides
<name>.symbols ← frozen symbol bundle
cir/ <name>.cir ← exported SLiCAP / NGspice netlist
lib/ <name>.lib ← exported subcircuit library
*.svg ← user symbol definitions
img/ <name>.svg ← exported schematic image
<name>.pdf
project_root() derives the root from the schematic path (if the
.slicap_sch file is in sch/, root is its parent) or falls back to the
application root for unsaved schematics.
On the first save of a previously-unsaved schematic, the session-temporary
LaTeX cache directory is migrated to <name>.cache/ so the saved file is
immediately self-contained and portable.
Layer 8 — Export (export.py, netlist.py)
SVG export (``export.py``):
Iterates scene.items() directly. For each ComponentItem it inlines the
symbol SVG content as a transformed <g> element; labels, wires, junctions,
text items, and shapes are each rendered to SVG geometry. LaTeX fragments and
images are embedded as <image> elements with data: URIs. The output
is a self-contained SVG file with no external dependencies.
PDF / Print export (``export.py``):
The SVG generated above is fed to QSvgRenderer and rendered onto a
QPainter backed by a QPrinter (PDF mode or system printer). This
produces vector PDF output with the same fidelity as the SVG.
Netlist export (``netlist.py``):
Walks the scene to collect ComponentItems, WireItems, LibraryItems,
and CommandItems. Calls connectivity.resolve_nets() to assign net
names, then formats each component as a SLiCAP/SPICE element line. The
resulting .cir file is written to cir/<name>.cir. If the schematic is
marked as a subcircuit (DocumentProperties.is_subcircuit), the netlist is
wrapped in a .subckt / .ends block and written to lib/<name>.lib
instead.
Layer 9 — Application Window (window.py, canvas.py)
MainWindow(QMainWindow)Top-level window. Builds the menu bar (File, Edit, View, Draw, Place, Tools, Help) and connects menu actions to scene methods and file-dialog handlers. Manages
_dirtystate (set byscene.data_changed). Owns theSymbolLibraryinstance and rebuilds it on every New / Open.SchematicView(QGraphicsView)The viewport. Renders the grid (minor grey lines every
GRID_SIZEunits, major lines everyGRID_MAJORmultiples) indrawBackground(). Handles wheel zoom and drag-scroll. Switches betweenRubberBandDragandNoDragscroll modes depending on the scene’s active mode (placement and wiring useNoDragso clicks are not intercepted by drag-selection).
Layer 10 — Configuration (config.py, style.ini)
config.pyModule-level constants for visual appearance:
GRID_SIZE,GRID_MAJOR,GRID_MINOR_COLOR,GRID_MAJOR_COLOR,JUNCTION_COLOR,JUNCTION_RADIUS,COMMAND_COLOR,COMMAND_FONT,COMP_LABEL_FONT,COMP_LABEL_COLOR, etc. Also defines thesnap()function that rounds a sceneQPointFto the nearestGRID_SIZEinteger.style.iniINI file at the project root with user-adjustable overrides for the same constants (loaded by
config.pyat startup).<name>.iniPer-schematic style overrides (loaded alongside
style.iniwhen a schematic is opened, so different schematics can have different visual styles).
Data-Flow Summary
The following table shows which layer owns which data and in which direction information flows:
Layer |
Owns / is responsible for |
Does NOT contain |
|---|---|---|
Symbol definitions (SVG) |
Artwork, pin positions, metadata attributes |
Instance data, net names, parameter values |
Metadata registry (dicts) |
Per-symbol lookup tables for the scene layer |
Qt objects, schematic state |
Data model (dataclasses) |
Serialisable schematic state (positions, params, net hints) |
Qt objects, rendering, interaction logic |
Canvas scene (items) |
Live Qt items, rendering, undo snapshots |
On-disk format, connectivity logic |
Interaction / modes |
Mouse-event dispatch, placement ghosts, undo stack |
Persistent state (ghosts are discarded after each action) |
Connectivity |
Net names, junction placement, wire splitting |
Visual appearance, serialisation |
Project layout |
Path resolution, sidecar locations, cache migration |
Scene state, symbol data |
Export |
SVG/PDF/netlist file generation |
Scene mutation, UI state |
Window / view |
Menus, zoom, grid rendering, file dialogs |
Schematic data, symbol metadata |
Configuration |
Visual constants, snap function |
Circuit data of any kind |