""" 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 __