"""
Wire Manager for KiCad Schematics
Handles wire creation using S-expression manipulation, similar to dynamic symbol loading.
kicad-skip's wire API doesn't support creating wires with standard parameters, so we
manipulate the .kicad_sch file directly.
"""
import logging
import math
import re
import tempfile
import uuid
from pathlib import Path
from typing import Any, List, Optional, Tuple
import sexpdata
from sexpdata import Symbol
logger = logging.getLogger("kicad_interface")
# Module-level Symbol constants — avoids repeated allocation on every call
_SYM_WIRE = Symbol("wire")
_SYM_PTS = Symbol("pts")
_SYM_XY = Symbol("xy")
_SYM_AT = Symbol("at")
_SYM_LABEL = Symbol("label")
_SYM_GLOBAL_LABEL = Symbol("global_label")
_SYM_HIERARCHICAL_LABEL = Symbol("hierarchical_label")
_SYM_STROKE = Symbol("stroke")
_SYM_WIDTH = Symbol("width")
_SYM_TYPE = Symbol("type")
_SYM_UUID = Symbol("uuid")
_SYM_SHEET_INSTANCES = Symbol("sheet_instances")
_SYM_JUNCTION = Symbol("junction")
_SYM_LIB_SYMBOLS = Symbol("lib_symbols")
_SYM_LIB_ID = Symbol("lib_id")
_SYM_MIRROR = Symbol("mirror")
_SYM_PIN = Symbol("pin")
_IU_PER_MM = 10000
def _find_insertion_point(content: str) -> int:
"""Find the right place to insert new elements in a .kicad_sch file.
Looks for (sheet_instances (KiCad 8) first, falls back to inserting
before the final closing paren (KiCad 9+).
"""
marker = "(sheet_instances"
pos = content.rfind(marker)
if pos != -1:
return pos
pos = content.rfind(")")
if pos == -1:
raise ValueError("Could not find insertion point in schematic")
return pos
def _text_insert(file_path: Path, sexp_text: str) -> bool:
"""Insert S-expression text into a .kicad_sch file preserving formatting."""
with open(file_path, "r", encoding="utf-8") as f:
content = f.read()
insert_at = _find_insertion_point(content)
content = content[:insert_at] + sexp_text + content[insert_at:]
with open(file_path, "w", encoding="utf-8") as f:
f.write(content)
return True
def _make_hierarchical_label_text(
text: str,
position: List[float],
shape: str = "bidirectional",
orientation: int = 0,
) -> str:
"""Generate a hierarchical_label S-expression as formatted text.
orientation: 0=right (label points right, justify left),
180=left (label points left, justify right),
90/270=vertical.
"""
uid = str(uuid.uuid4())
justify = "right" if orientation == 180 else "left"
return (
f'\t(hierarchical_label "{text}"\n'
f"\t\t(shape {shape})\n"
f"\t\t(at {position[0]} {position[1]} {orientation})\n"
f"\t\t(effects\n"
f"\t\t\t(font\n"
f"\t\t\t\t(size 1.27 1.27)\n"
f"\t\t\t)\n"
f"\t\t\t(justify {justify})\n"
f"\t\t)\n"
f'\t\t(uuid "{uid}")\n'
f"\t)\n"
)
def _make_sheet_pin_text(
pin_name: str,
pin_type: str,
position: List[float],
orientation: int = 0,
) -> str:
"""Generate a sheet pin S-expression as formatted text (indented for inside sheet block).
orientation: 0=right side of sheet box, 180=left side.
"""
uid = str(uuid.uuid4())
justify = "left" if orientation == 0 else "right"
return (
f'\t\t(pin "{pin_name}" {pin_type}\n'
f"\t\t\t(at {position[0]} {position[1]} {orientation})\n"
f'\t\t\t(uuid "{uid}")\n'
f"\t\t\t(effects\n"
f"\t\t\t\t(font\n"
f"\t\t\t\t\t(size 1.27 1.27)\n"
f"\t\t\t\t)\n"
f"\t\t\t\t(justify {justify})\n"
f"\t\t\t)\n"
f"\t\t)\n"
)
class WireManager:
"""Manage wires in KiCad schematics using S-expression manipulation"""
@staticmethod
def add_wire(
schematic_path: Path,
start_point: List[float],
end_point: List[float],
stroke_width: float = 0,
stroke_type: str = "default",
) -> bool:
"""
Add a wire to the schematic using S-expression manipulation
Args:
schematic_path: Path to .kicad_sch file
start_point: [x, y] coordinates for wire start
end_point: [x, y] coordinates for wire end
stroke_width: Wire width (default 0 for standard)
stroke_type: Stroke type (default, solid, dashed, etc.)
Returns:
True if successful, False otherwise
"""
try:
# Read schematic
with open(schematic_path, "r", encoding="utf-8") as f:
sch_content = f.read()
sch_data = sexpdata.loads(sch_content)
# Break any existing wire that passes through a new endpoint (T-junction support)
for pt in (start_point, end_point):
splits = WireManager._break_wires_at_point(sch_data, pt)
if splits:
logger.info(f"Broke {splits} wire(s) at new wire endpoint {pt}")
# Create wire S-expression
# Format: (wire (pts (xy x1 y1) (xy x2 y2)) (stroke (width N) (type default)) (uuid ...))
wire_sexp = WireManager._make_wire_sexp(
start_point, end_point, stroke_width, stroke_type
)
# Find insertion point (before sheet_instances)
sheet_instances_index = None
for i, item in enumerate(sch_data):
if isinstance(item, list) and len(item) > 0 and item[0] == _SYM_SHEET_INSTANCES:
sheet_instances_index = i
break
if sheet_instances_index is None:
logger.error("No sheet_instances section found in schematic")
return False
# Insert wire before sheet_instances
sch_data.insert(sheet_instances_index, wire_sexp)
logger.info(f"Injected wire from {start_point} to {end_point}")
WireManager.sync_junctions(sch_data)
# Write back
with open(schematic_path, "w", encoding="utf-8") as f:
output = sexpdata.dumps(sch_data)
f.write(output)
logger.info(f"Successfully added wire to {schematic_path.name}")
return True
except Exception as e:
logger.error(f"Error adding wire: {e}")
import traceback
logger.error(traceback.format_exc())
return False
@staticmethod
def add_polyline_wire(
schematic_path: Path,
points: List[List[float]],
stroke_width: float = 0,
stroke_type: str = "default",
) -> bool:
"""
Add a multi-segment wire (polyline) to the schematic
Args:
schematic_path: Path to .kicad_sch file
points: List of [x, y] coordinates for each point in the path
stroke_width: Wire width
stroke_type: Stroke type
Returns:
True if successful, False otherwise
"""
try:
if len(points) < 2:
logger.error("Polyline requires at least 2 points")
return False
# Read schematic
with open(schematic_path, "r", encoding="utf-8") as f:
sch_content = f.read()
sch_data = sexpdata.loads(sch_content)
# Break any existing wire at the outer endpoints of the new path
for pt in (points[0], points[-1]):
splits = WireManager._break_wires_at_point(sch_data, pt)
if splits:
logger.info(f"Broke {splits} wire(s) at new polyline endpoint {pt}")
# KiCAD wire elements only support exactly 2 pts each.
# Split N waypoints into N-1 individual wire segments.
wire_sexps = [
WireManager._make_wire_sexp(points[i], points[i + 1], stroke_width, stroke_type)
for i in range(len(points) - 1)
]
# Find insertion point
sheet_instances_index = None
for i, item in enumerate(sch_data):
if isinstance(item, list) and len(item) > 0 and item[0] == _SYM_SHEET_INSTANCES:
sheet_instances_index = i
break
if sheet_instances_index is None:
logger.error("No sheet_instances section found in schematic")
return False
# Insert all segments (in reverse so order is preserved after inserts)
for wire_sexp in reversed(wire_sexps):
sch_data.insert(sheet_instances_index, wire_sexp)
logger.info(
f"Injected {len(wire_sexps)} wire segments for {len(points)}-point polyline"
)
WireManager.sync_junctions(sch_data)
# Write back
with open(schematic_path, "w", encoding="utf-8") as f:
output = sexpdata.dumps(sch_data)
f.write(output)
logger.info(f"Successfully added polyline wire to {schematic_path.name}")
return True
except Exception as e:
logger.error(f"Error adding polyline wire: {e}")
import traceback
logger.error(traceback.format_exc())
return False
@staticmethod
def add_label(
schematic_path: Path,
text: str,
position: List[float],
label_type: str = "label",
orientation: int = 0,
) -> bool:
"""
Add a net label to the schematic
Args:
schematic_path: Path to .kicad_sch file
text: Label text (net name)
position: [x, y] coordinates for label
label_type: Type of label ('label', 'global_label', 'hierarchical_label')
orientation: Rotation angle (0, 90, 180, 270)
Returns:
True if successful, False otherwise
"""
try:
# Read schematic
with open(schematic_path, "r", encoding="utf-8") as f:
sch_content = f.read()
sch_data = sexpdata.loads(sch_content)
# Create label S-expression
# Format: (label "TEXT" (at x y angle) (effects (font (size 1.27 1.27))))
label_sexp = [
Symbol(label_type),
text,
[Symbol("at"), position[0], position[1], orientation],
[Symbol("fields_autoplaced"), Symbol("yes")],
[
Symbol("effects"),
[Symbol("font"), [Symbol("size"), 1.27, 1.27]],
[Symbol("justify"), Symbol("left"), Symbol("bottom")],
],
[Symbol("uuid"), str(uuid.uuid4())],
]
# Find insertion point
sheet_instances_index = None
for i, item in enumerate(sch_data):
if isinstance(item, list) and len(item) > 0 and item[0] == _SYM_SHEET_INSTANCES:
sheet_instances_index = i
break
if sheet_instances_index is None:
# Sub-sheets in hierarchical designs don't have (sheet_instances).
# Fall back to appending before the final closing paren of (kicad_sch ...).
sheet_instances_index = len(sch_data)
# Insert label
sch_data.insert(sheet_instances_index, label_sexp)
logger.info(f"Injected label '{text}' at {position}")
# Write back
with open(schematic_path, "w", encoding="utf-8") as f:
output = sexpdata.dumps(sch_data)
f.write(output)
logger.info(f"Successfully added label to {schematic_path.name}")
return True
except Exception as e:
logger.error(f"Error adding label: {e}")
import traceback
logger.error(traceback.format_exc())
return False
@staticmethod
def _parse_wire(
wire_item: Any,
) -> Optional[Tuple[Tuple[float, float], Tuple[float, float], float, str]]:
"""
Parse a wire S-expression item in a single pass.
Returns ((x1,y1), (x2,y2), stroke_width, stroke_type), or None if not a valid wire.
"""
if not (isinstance(wire_item, list) and len(wire_item) >= 2 and wire_item[0] == _SYM_WIRE):
return None
start = end = None
stroke_width: float = 0
stroke_type: str = "default"
for part in wire_item[1:]:
if not isinstance(part, list) or not part:
continue
tag = part[0]
if tag == _SYM_PTS:
found: List[Tuple[float, float]] = []
for p in part[1:]:
if isinstance(p, list) and len(p) >= 3 and p[0] == _SYM_XY:
found.append((float(p[1]), float(p[2])))
if len(found) == 2:
break
if len(found) == 2:
start, end = found[0], found[1]
elif tag == _SYM_STROKE:
for sp in part[1:]:
if isinstance(sp, list) and len(sp) >= 2:
if sp[0] == _SYM_WIDTH:
stroke_width = sp[1]
elif sp[0] == _SYM_TYPE:
stroke_type = str(sp[1])
if start is not None and end is not None:
return start, end, stroke_width, stroke_type
return None
@staticmethod
def _point_strictly_on_wire(
px: float,
py: float,
x1: float,
y1: float,
x2: float,
y2: float,
eps: float = 1e-6,
) -> bool:
"""
Return True if (px, py) lies strictly between (x1,y1) and (x2,y2)
on a horizontal or vertical wire segment (not at either endpoint).
"""
if abs(y1 - y2) < eps: # horizontal wire
if abs(py - y1) > eps:
return False
lo, hi = min(x1, x2), max(x1, x2)
return lo + eps < px < hi - eps
if abs(x1 - x2) < eps: # vertical wire
if abs(px - x1) > eps:
return False
lo, hi = min(y1, y2), max(y1, y2)
return lo + eps < py < hi - eps
return False
@staticmethod
def _make_wire_sexp(
start: List[float],
end: List[float],
stroke_width: float = 0,
stroke_type: str = "default",
) -> list:
return [
_SYM_WIRE,
[_SYM_PTS, [_SYM_XY, start[0], start[1]], [_SYM_XY, end[0], end[1]]],
[_SYM_STROKE, [_SYM_WIDTH, stroke_width], [_SYM_TYPE, Symbol(stroke_type)]],
[_SYM_UUID, str(uuid.uuid4())],
]
@staticmethod
def _break_wires_at_point(sch_data: list, position: List[float]) -> int:
"""
Split any wire segment that passes through *position* as a strict
midpoint (i.e. position is not an existing endpoint). Mirrors
KiCAD's SCH_LINE_WIRE_BUS_TOOL::BreakSegments behaviour.
Returns the number of wires split.
"""
px, py = float(position[0]), float(position[1])
splits = 0
i = 0
while i < len(sch_data):
parsed = WireManager._parse_wire(sch_data[i])
if parsed is not None:
(x1, y1), (x2, y2), stroke_width, stroke_type = parsed
if WireManager._point_strictly_on_wire(px, py, x1, y1, x2, y2):
seg_a = WireManager._make_wire_sexp(
[x1, y1], [px, py], stroke_width, stroke_type
)
seg_b = WireManager._make_wire_sexp(
[px, py], [x2, y2], stroke_width, stroke_type
)
sch_data[i : i + 1] = [seg_a, seg_b]
logger.info(f"Split wire ({x1},{y1})->({x2},{y2}) at ({px},{py})")
splits += 1
i += 2 # skip the two new segments
continue
i += 1
return splits
@staticmethod
def _collect_wire_endpoints(sch_data: list) -> List[Tuple[float, float]]:
"""Return all (x, y) endpoints for every wire in sch_data."""
endpoints: List[Tuple[float, float]] = []
for item in sch_data:
parsed = WireManager._parse_wire(item)
if parsed is not None:
(x1, y1), (x2, y2), _, _ = parsed
endpoints.append((x1, y1))
endpoints.append((x2, y2))
return endpoints
@staticmethod
def _get_existing_junctions(sch_data: list) -> dict:
"""Return {(iu_x, iu_y): index_in_sch_data} for every junction element."""
result: dict = {}
for i, item in enumerate(sch_data):
if not (isinstance(item, list) and len(item) > 0 and item[0] == _SYM_JUNCTION):
continue
at_entry = next(
(p for p in item[1:] if isinstance(p, list) and len(p) >= 3 and p[0] == _SYM_AT),
None,
)
if at_entry is None:
continue
x, y = float(at_entry[1]), float(at_entry[2])
result[(round(x * _IU_PER_MM), round(y * _IU_PER_MM))] = i
return result
@staticmethod
def _make_junction_sexp(x: float, y: float, diameter: float = 0) -> list:
return [
_SYM_JUNCTION,
[_SYM_AT, x, y],
[Symbol("diameter"), diameter],
[Symbol("color"), 0, 0, 0, 0],
[_SYM_UUID, str(uuid.uuid4())],
]
# Regex to parse sub-unit names like "LM324_2_1" → (base="LM324", unit=2, style=1)
# The sub-unit suffix is __