Merge pull request #136 from mixelpixx/fix/auto-junction-sync-followups

Auto-sync schematic junctions on wire and symbol mutations
This commit is contained in:
Eugene Mikhantyev
2026-04-26 15:43:20 +01:00
committed by GitHub
8 changed files with 939 additions and 310 deletions

1
.gitignore vendored
View File

@@ -89,6 +89,7 @@ Desktop.ini
# Generated local config files (contain machine-specific paths) # Generated local config files (contain machine-specific paths)
windows-mcp-config.json windows-mcp-config.json
.mcp.json
# Personal notes / local contributions (not for upstream) # Personal notes / local contributions (not for upstream)
myContribution/ myContribution/

View File

@@ -71,6 +71,14 @@ All notable changes to the KiCAD MCP Server project are documented here.
no-op removal, special-character escaping, UUID preservation, and the two no-op removal, special-character escaping, UUID preservation, and the two
new convenience tools. new convenience tools.
### Removed
- `add_schematic_junction` MCP tool has been removed. Junctions are now
inserted and removed automatically via `WireManager.sync_junctions` whenever
wires are added, deleted, or moved.
- Junction placement is pin-aware: `sync_junctions` consults component pin
positions so that T-junctions at component pins are correctly recognised.
--- ---
## [2.2.3] - 2026-03-11 ## [2.2.3] - 2026-03-11

View File

@@ -32,6 +32,14 @@ _SYM_WIDTH = Symbol("width")
_SYM_TYPE = Symbol("type") _SYM_TYPE = Symbol("type")
_SYM_UUID = Symbol("uuid") _SYM_UUID = Symbol("uuid")
_SYM_SHEET_INSTANCES = Symbol("sheet_instances") _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")
_SYM_SYMBOL = Symbol("symbol")
_SYM_UNIT = Symbol("unit")
_IU_PER_MM = 10000
def _find_insertion_point(content: str) -> int: def _find_insertion_point(content: str) -> int:
@@ -121,6 +129,11 @@ def _make_sheet_pin_text(
class WireManager: class WireManager:
"""Manage wires in KiCad schematics using S-expression manipulation""" """Manage wires in KiCad schematics using S-expression manipulation"""
# Regex to parse sub-unit names like "LM324_2_1" → (base="LM324", unit=2, style=1)
# The sub-unit suffix is <base>_<unit>_<style> where unit and style are integers.
# Assumes KiCad's <base>_<unit>_<style> convention (rightmost two underscore-separated numeric groups are unit/style); unparseable names fall back to including all pins via the else branch in _parse_lib_pins.
_SUB_UNIT_RE = re.compile(r"^(.+)_(\d+)_(\d+)$")
@staticmethod @staticmethod
def add_wire( def add_wire(
schematic_path: Path, schematic_path: Path,
@@ -176,6 +189,8 @@ class WireManager:
sch_data.insert(sheet_instances_index, wire_sexp) sch_data.insert(sheet_instances_index, wire_sexp)
logger.info(f"Injected wire from {start_point} to {end_point}") logger.info(f"Injected wire from {start_point} to {end_point}")
WireManager.sync_junctions(sch_data)
# Write back # Write back
with open(schematic_path, "w", encoding="utf-8") as f: with open(schematic_path, "w", encoding="utf-8") as f:
output = sexpdata.dumps(sch_data) output = sexpdata.dumps(sch_data)
@@ -252,6 +267,8 @@ class WireManager:
f"Injected {len(wire_sexps)} wire segments for {len(points)}-point polyline" f"Injected {len(wire_sexps)} wire segments for {len(points)}-point polyline"
) )
WireManager.sync_junctions(sch_data)
# Write back # Write back
with open(schematic_path, "w", encoding="utf-8") as f: with open(schematic_path, "w", encoding="utf-8") as f:
output = sexpdata.dumps(sch_data) output = sexpdata.dumps(sch_data)
@@ -450,74 +467,257 @@ class WireManager:
return splits return splits
@staticmethod @staticmethod
def add_junction(schematic_path: Path, position: List[float], diameter: float = 0) -> bool: def _collect_wire_endpoints(sch_data: list) -> List[Tuple[float, float]]:
""" """Return all (x, y) endpoints for every wire in sch_data."""
Add a junction (connection dot) to the schematic. 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
Mirrors KiCAD's AddJunction behaviour: any wire whose interior passes @staticmethod
through *position* is split into two segments at that point so that def _get_existing_junctions(sch_data: list) -> dict:
the BFS-based get_wire_connections tool can traverse the T/X branch. """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
Args: @staticmethod
schematic_path: Path to .kicad_sch file def _make_junction_sexp(x: float, y: float, diameter: float = 0) -> list:
position: [x, y] coordinates for junction return [
diameter: Junction diameter (0 for default) _SYM_JUNCTION,
[_SYM_AT, x, y],
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)
# Split any wire that passes through the junction as a midpoint
# (mirrors KiCAD's AddJunction / BreakSegments behaviour)
splits = WireManager._break_wires_at_point(sch_data, position)
if splits:
logger.info(f"Broke {splits} wire(s) at junction position {position}")
# Create junction S-expression
# Format: (junction (at x y) (diameter 0) (color 0 0 0 0) (uuid ...))
junction_sexp = [
Symbol("junction"),
[Symbol("at"), position[0], position[1]],
[Symbol("diameter"), diameter], [Symbol("diameter"), diameter],
[Symbol("color"), 0, 0, 0, 0], [Symbol("color"), 0, 0, 0, 0],
[Symbol("uuid"), str(uuid.uuid4())], [_SYM_UUID, str(uuid.uuid4())],
] ]
# Find insertion point @staticmethod
def _parse_lib_pins(sym_def: list, unit: int = 1) -> List[Tuple[float, float]]:
"""Extract pin local (x, y) positions for *unit* from a lib_symbols symbol definition.
Only collects pins from sub-unit symbols whose parsed unit number matches *unit*
OR is 0 (the "common" body drawn on every unit, e.g. power pins on an op-amp).
Sub-units whose unit index is neither *unit* nor 0 are skipped entirely.
If the lib_symbols entry has no nested (symbol ...) children at all (rare, simple
defs), falls back to collecting every (pin ...) directly from the top-level entry.
Uses a stack instead of recursion to handle nested sub-unit symbols.
"""
pins: List[Tuple[float, float]] = []
# Separate top-level direct children into sub-unit symbols vs other nodes.
sub_units: list = []
direct_pins: list = []
for child in sym_def[1:]:
if not isinstance(child, list) or not child:
continue
if child[0] == _SYM_SYMBOL:
sub_units.append(child)
elif child[0] == _SYM_PIN:
direct_pins.append(child)
if not sub_units:
# Fallback: simple definition with no nested sub-unit symbols — collect all pins.
nodes_to_search = direct_pins
else:
# Filter sub-units by parsed unit number.
nodes_to_search = []
for sub in sub_units:
sub_name = (
sub[1]
if len(sub) > 1 and isinstance(sub[1], str)
else str(sub[1]) if len(sub) > 1 else ""
)
m = WireManager._SUB_UNIT_RE.match(sub_name)
if m:
sub_unit_num = int(m.group(2))
if sub_unit_num == unit or sub_unit_num == 0:
nodes_to_search.extend(sub[1:])
else:
# Name doesn't match the expected pattern — include it (fail-safe).
logger.debug(
"lib_symbols sub-unit name %r did not match <base>_<unit>_<style>; "
"including all its pins as fallback",
sub_name,
)
nodes_to_search.extend(sub[1:])
# Walk the selected nodes to collect (pin ...) entries via stack.
stack: list = list(nodes_to_search)
while stack:
node = stack.pop()
if not isinstance(node, list) or not node:
continue
if node[0] == _SYM_PIN:
at = next(
(
p
for p in node[1:]
if isinstance(p, list) and len(p) >= 3 and p[0] == _SYM_AT
),
None,
)
if at:
pins.append((float(at[1]), float(at[2])))
continue # don't recurse into pin sub-expressions
stack.extend(node[1:])
return pins
@staticmethod
def _collect_pin_positions(sch_data: list) -> List[Tuple[float, float]]:
"""Return world (x, y) positions for every placed component pin in sch_data.
Parses lib_symbols for pin local coordinates (unit-aware), then applies the KiCad
transform chain (y-negate → mirror → rotate → translate) to each pin.
"""
# Build {lib_id: sym_def} from the embedded lib_symbols section.
# We defer pin extraction until we know which unit each placed instance uses.
lib_sym_defs: dict = {}
for item in sch_data:
if not (isinstance(item, list) and len(item) > 0 and item[0] == _SYM_LIB_SYMBOLS):
continue
for sym_def in item[1:]:
if not (
isinstance(sym_def, list) and len(sym_def) > 1 and sym_def[0] == _SYM_SYMBOL
):
continue
lib_id = sym_def[1] if isinstance(sym_def[1], str) else str(sym_def[1])
lib_sym_defs[lib_id] = sym_def
break
# Transform each placed symbol's pins to world coordinates
world_positions: List[Tuple[float, float]] = []
for item in sch_data:
if not (isinstance(item, list) and len(item) > 0 and item[0] == _SYM_SYMBOL):
continue
lib_id_part = next(
(
p
for p in item[1:]
if isinstance(p, list) and len(p) >= 2 and p[0] == _SYM_LIB_ID
),
None,
)
if lib_id_part is None:
continue # not a placed instance (e.g. sub-unit inside lib_symbols)
lib_id = lib_id_part[1] if isinstance(lib_id_part[1], str) else str(lib_id_part[1])
# Read the placed unit number (default 1 for single-unit parts).
unit_part = next(
(p for p in item[1:] if isinstance(p, list) and len(p) >= 2 and p[0] == _SYM_UNIT),
None,
)
unit_num = int(unit_part[1]) if unit_part is not None else 1
at_part = next(
(p for p in item[1:] if isinstance(p, list) and len(p) >= 3 and p[0] == _SYM_AT),
None,
)
if at_part is None:
continue
sym_x, sym_y = float(at_part[1]), float(at_part[2])
rotation = float(at_part[3]) if len(at_part) > 3 else 0.0
mirror_x = mirror_y = False
for part in item[1:]:
if isinstance(part, list) and len(part) >= 2 and part[0] == _SYM_MIRROR:
if part[1] == Symbol("x"):
mirror_x = True
elif part[1] == Symbol("y"):
mirror_y = True
sym_def = lib_sym_defs.get(lib_id)
if sym_def is None:
continue
local_pins = WireManager._parse_lib_pins(sym_def, unit=unit_num)
for lx, ly in local_pins:
# KiCad lib uses y-up; schematic uses y-down — negate before transform
ly = -ly
if mirror_x:
ly = -ly
if mirror_y:
lx = -lx
if rotation != 0.0:
rad = math.radians(rotation)
c, s = math.cos(rad), math.sin(rad)
lx, ly = lx * c - ly * s, lx * s + ly * c
world_positions.append((sym_x + lx, sym_y + ly))
return world_positions
@staticmethod
def sync_junctions(sch_data: list) -> Tuple[int, int]:
"""Add missing junctions and remove stale ones in sch_data in-place.
A junction is needed at any point where the total of wire endpoints plus
component pin positions is ≥ 3 and at least one wire endpoint is present.
This covers wire-only T/X junctions and wire-meets-pin-with-another-wire cases.
Returns (added_count, removed_count).
"""
from collections import Counter
wire_endpoints = WireManager._collect_wire_endpoints(sch_data)
wire_iu: Counter = Counter(
(round(x * _IU_PER_MM), round(y * _IU_PER_MM)) for x, y in wire_endpoints
)
pin_positions = WireManager._collect_pin_positions(sch_data)
pin_iu: Counter = Counter(
(round(x * _IU_PER_MM), round(y * _IU_PER_MM)) for x, y in pin_positions
)
# wire_iu.items() guarantees wire_cnt >= 1, so no extra guard needed
needed_iu = {iu for iu, wire_cnt in wire_iu.items() if wire_cnt + pin_iu.get(iu, 0) >= 3}
existing = WireManager._get_existing_junctions(sch_data)
existing_iu = set(existing.keys())
# Remove stale junctions; work in reverse index order to avoid shifting
stale_indices = sorted([existing[iu] for iu in existing_iu - needed_iu], reverse=True)
for idx in stale_indices:
del sch_data[idx]
removed = len(stale_indices)
# Locate insertion point for new junctions
sheet_instances_index = None sheet_instances_index = None
for i, item in enumerate(sch_data): for i, item in enumerate(sch_data):
if isinstance(item, list) and len(item) > 0 and item[0] == _SYM_SHEET_INSTANCES: if isinstance(item, list) and len(item) > 0 and item[0] == _SYM_SHEET_INSTANCES:
sheet_instances_index = i sheet_instances_index = i
break break
to_add = needed_iu - existing_iu
added = 0
if to_add:
if sheet_instances_index is None: if sheet_instances_index is None:
logger.error("No sheet_instances section found in schematic") logger.warning("sync_junctions: no sheet_instances found, skipping junction insert")
return False else:
for iu_x, iu_y in to_add:
x = iu_x / _IU_PER_MM
y = iu_y / _IU_PER_MM
sch_data.insert(sheet_instances_index, WireManager._make_junction_sexp(x, y))
sheet_instances_index += 1
added += 1
# Insert junction if added or removed:
sch_data.insert(sheet_instances_index, junction_sexp) logger.info(f"sync_junctions: added {added}, removed {removed}")
logger.info(f"Injected junction at {position}") return added, removed
# 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 junction to {schematic_path.name}")
return True
except Exception as e:
logger.error(f"Error adding junction: {e}")
import traceback
logger.error(traceback.format_exc())
return False
@staticmethod @staticmethod
def add_no_connect(schematic_path: Path, position: List[float]) -> bool: def add_no_connect(schematic_path: Path, position: List[float]) -> bool:
@@ -644,6 +844,7 @@ class WireManager:
if match_fwd or match_rev: if match_fwd or match_rev:
del sch_data[i] del sch_data[i]
WireManager.sync_junctions(sch_data)
with open(schematic_path, "w", encoding="utf-8") as f: with open(schematic_path, "w", encoding="utf-8") as f:
f.write(sexpdata.dumps(sch_data)) f.write(sexpdata.dumps(sch_data))
logger.info(f"Deleted wire from {start_point} to {end_point}") logger.info(f"Deleted wire from {start_point} to {end_point}")
@@ -973,29 +1174,24 @@ if __name__ == "__main__":
print(f"\n✓ Created test schematic: {test_path}") print(f"\n✓ Created test schematic: {test_path}")
# Test 1: Add simple wire # Test 1: Add simple wire
print("\n[1/5] Testing simple wire creation...") print("\n[1/4] Testing simple wire creation...")
success = WireManager.add_wire(test_path, [50.8, 50.8], [101.6, 50.8]) success = WireManager.add_wire(test_path, [50.8, 50.8], [101.6, 50.8])
print(f" {'' if success else ''} Simple wire: {success}") print(f" {'' if success else ''} Simple wire: {success}")
# Test 2: Add orthogonal wire # Test 2: Add orthogonal wire
print("\n[2/5] Testing orthogonal wire...") print("\n[2/4] Testing orthogonal wire...")
path = WireManager.create_orthogonal_path([50.8, 60.96], [101.6, 88.9]) path = WireManager.create_orthogonal_path([50.8, 60.96], [101.6, 88.9])
print(f" Orthogonal path: {path}") print(f" Orthogonal path: {path}")
success = WireManager.add_polyline_wire(test_path, path) success = WireManager.add_polyline_wire(test_path, path)
print(f" {'' if success else ''} Polyline wire: {success}") print(f" {'' if success else ''} Polyline wire: {success}")
# Test 3: Add label # Test 3: Add label
print("\n[3/5] Testing label creation...") print("\n[3/4] Testing label creation...")
success = WireManager.add_label(test_path, "VCC", [76.2, 50.8]) success = WireManager.add_label(test_path, "VCC", [76.2, 50.8])
print(f" {'' if success else ''} Label: {success}") print(f" {'' if success else ''} Label: {success}")
# Test 4: Add junction # Test 4: Add no-connect
print("\n[4/5] Testing junction creation...") print("\n[4/4] Testing no-connect creation...")
success = WireManager.add_junction(test_path, [76.2, 50.8])
print(f" {'' if success else ''} Junction: {success}")
# Test 5: Add no-connect
print("\n[5/5] Testing no-connect creation...")
success = WireManager.add_no_connect(test_path, [127, 50.8]) success = WireManager.add_no_connect(test_path, [127, 50.8])
print(f" {'' if success else ''} No-connect: {success}") print(f" {'' if success else ''} No-connect: {success}")

View File

@@ -15,7 +15,9 @@ import traceback
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple from typing import Any, Dict, List, Optional, Tuple
import sexpdata
from annotations import AnnotationLoader from annotations import AnnotationLoader
from commands.wire_manager import WireManager
from resources.resource_definitions import RESOURCE_DEFINITIONS, handle_resource_read from resources.resource_definitions import RESOURCE_DEFINITIONS, handle_resource_read
# Import tool schemas, resource definitions, and IPC API annotations # Import tool schemas, resource definitions, and IPC API annotations
@@ -389,7 +391,6 @@ class KiCADInterface:
"get_schematic_component": self._handle_get_schematic_component, "get_schematic_component": self._handle_get_schematic_component,
"add_schematic_wire": self._handle_add_schematic_wire, "add_schematic_wire": self._handle_add_schematic_wire,
"add_schematic_net_label": self._handle_add_schematic_net_label, "add_schematic_net_label": self._handle_add_schematic_net_label,
"add_schematic_junction": self._handle_add_schematic_junction,
"connect_to_net": self._handle_connect_to_net, "connect_to_net": self._handle_connect_to_net,
"connect_passthrough": self._handle_connect_passthrough, "connect_passthrough": self._handle_connect_passthrough,
"get_schematic_pin_locations": self._handle_get_schematic_pin_locations, "get_schematic_pin_locations": self._handle_get_schematic_pin_locations,
@@ -1629,39 +1630,6 @@ class KiCADInterface:
"errorDetails": traceback.format_exc(), "errorDetails": traceback.format_exc(),
} }
def _handle_add_schematic_junction(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Add a junction (connection dot) to a schematic using WireManager"""
logger.info("Adding junction to schematic")
try:
from pathlib import Path
from commands.wire_manager import WireManager
schematic_path = params.get("schematicPath")
position = params.get("position")
if not schematic_path:
return {"success": False, "message": "Schematic path is required"}
if not position:
return {"success": False, "message": "Position is required"}
success = WireManager.add_junction(Path(schematic_path), position)
if success:
return {"success": True, "message": "Junction added successfully"}
else:
return {"success": False, "message": "Failed to add junction"}
except Exception as e:
logger.error(f"Error adding junction to schematic: {str(e)}")
import traceback
logger.error(traceback.format_exc())
return {
"success": False,
"message": str(e),
"errorDetails": traceback.format_exc(),
}
def _handle_list_schematic_libraries(self, params: Dict[str, Any]) -> Dict[str, Any]: def _handle_list_schematic_libraries(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""List available symbol libraries""" """List available symbol libraries"""
logger.info("Listing schematic libraries") logger.info("Listing schematic libraries")
@@ -2660,7 +2628,6 @@ class KiCADInterface:
"""Move a schematic component to a new position, dragging connected wires.""" """Move a schematic component to a new position, dragging connected wires."""
logger.info("Moving schematic component") logger.info("Moving schematic component")
try: try:
import sexpdata as _sexpdata
from commands.wire_dragger import WireDragger from commands.wire_dragger import WireDragger
schematic_path = params.get("schematicPath") schematic_path = params.get("schematicPath")
@@ -2682,7 +2649,7 @@ class KiCADInterface:
} }
with open(schematic_path, "r", encoding="utf-8") as f: with open(schematic_path, "r", encoding="utf-8") as f:
sch_data = _sexpdata.loads(f.read()) sch_data = sexpdata.loads(f.read())
# Find symbol and record old position # Find symbol and record old position
found = WireDragger.find_symbol(sch_data, reference) found = WireDragger.find_symbol(sch_data, reference)
@@ -2721,8 +2688,10 @@ class KiCADInterface:
# Update symbol position # Update symbol position
WireDragger.update_symbol_position(sch_data, reference, float(new_x), float(new_y)) WireDragger.update_symbol_position(sch_data, reference, float(new_x), float(new_y))
WireManager.sync_junctions(sch_data)
with open(schematic_path, "w", encoding="utf-8") as f: with open(schematic_path, "w", encoding="utf-8") as f:
f.write(_sexpdata.dumps(sch_data)) f.write(sexpdata.dumps(sch_data))
return { return {
"success": True, "success": True,
@@ -2755,34 +2724,78 @@ class KiCADInterface:
"message": "schematicPath and reference are required", "message": "schematicPath and reference are required",
} }
schematic = SchematicManager.load_schematic(schematic_path) sym_k = sexpdata.Symbol("symbol")
if not schematic: prop_k = sexpdata.Symbol("property")
return {"success": False, "message": "Failed to load schematic"} at_k = sexpdata.Symbol("at")
mirror_k = sexpdata.Symbol("mirror")
for symbol in schematic.symbol: with open(schematic_path, "r", encoding="utf-8") as _f:
if not hasattr(symbol.property, "Reference"): sch_data = sexpdata.load(_f)
target = None
for item in sch_data:
if not (isinstance(item, list) and item and item[0] == sym_k):
continue continue
if symbol.property.Reference.value == reference: ref_val = None
pos = list(symbol.at.value) for sub in item[1:]:
while len(pos) < 3: if (
pos.append(0) isinstance(sub, list)
pos[2] = angle and len(sub) >= 3
symbol.at.value = pos and sub[0] == prop_k
and str(sub[1]).strip('"') == "Reference"
):
ref_val = str(sub[2]).strip('"')
break
if ref_val == reference:
target = item
break
if target is None:
return {"success": False, "message": f"Component {reference} not found"}
# Update (at x y rot)
at_node = None
mirror_idx = None
for idx, sub in enumerate(target[1:], start=1):
if not isinstance(sub, list) or not sub:
continue
if sub[0] == at_k:
at_node = sub
elif sub[0] == mirror_k:
mirror_idx = idx
if at_node is None:
return {
"success": False,
"message": f"Component {reference} has no (at ...) node",
}
while len(at_node) < 3:
at_node.append(0)
if len(at_node) < 4:
at_node.append(angle)
else:
at_node[3] = angle
if mirror: if mirror:
if hasattr(symbol, "mirror"): mirror_node = [mirror_k, sexpdata.Symbol(str(mirror))]
symbol.mirror.value = mirror if mirror_idx is not None:
target[mirror_idx] = mirror_node
else: else:
logger.warning( # Insert after (at ...) for stability
f"Mirror '{mirror}' requested for {reference}, " insert_at = len(target)
f"but symbol has no mirror attribute; skipped" for idx, sub in enumerate(target[1:], start=1):
) if isinstance(sub, list) and sub and sub[0] == at_k:
insert_at = idx + 1
break
target.insert(insert_at, mirror_node)
WireManager.sync_junctions(sch_data)
with open(schematic_path, "w", encoding="utf-8") as _f:
_f.write(sexpdata.dumps(sch_data))
SchematicManager.save_schematic(schematic, schematic_path)
return {"success": True, "reference": reference, "angle": angle} return {"success": True, "reference": reference, "angle": angle}
return {"success": False, "message": f"Component {reference} not found"}
except Exception as e: except Exception as e:
logger.error(f"Error rotating schematic component: {e}") logger.error(f"Error rotating schematic component: {e}")
import traceback import traceback

View File

@@ -1704,28 +1704,6 @@ SCHEMATIC_TOOLS = [
"required": ["schematicPath", "outputPath"], "required": ["schematicPath", "outputPath"],
}, },
}, },
{
"name": "add_schematic_junction",
"title": "Add Junction to Schematic",
"description": "Adds a junction (connection dot) at the specified coordinates on the schematic. Junctions are required in KiCAD to mark intentional connections where wires cross or where a wire branches off another wire. Without a junction, crossing wires are not electrically connected.",
"inputSchema": {
"type": "object",
"properties": {
"schematicPath": {
"type": "string",
"description": "Path to schematic file",
},
"position": {
"type": "array",
"description": "The [x, y] coordinates where the junction should be placed. Must be on an existing wire intersection or branch point.",
"items": {"type": "number"},
"minItems": 2,
"maxItems": 2,
},
},
"required": ["schematicPath", "position"],
},
},
# --- Schematic Analysis Tools (read-only) --- # --- Schematic Analysis Tools (read-only) ---
{ {
"name": "get_schematic_view_region", "name": "get_schematic_view_region",

View File

@@ -94,7 +94,6 @@ export const toolCategories: ToolCategory[] = [
"annotate_schematic", "annotate_schematic",
"add_schematic_wire", "add_schematic_wire",
"delete_schematic_wire", "delete_schematic_wire",
"add_schematic_junction",
"add_schematic_net_label", "add_schematic_net_label",
"delete_schematic_net_label", "delete_schematic_net_label",
"connect_to_net", "connect_to_net",

View File

@@ -510,38 +510,6 @@ edit_schematic_component and set its value to an empty string.`,
}, },
); );
// Add junction dot at a T/X intersection
server.tool(
"add_schematic_junction",
"Place a junction dot at a wire intersection in the schematic. Required at T-branch and X-cross points so KiCAD recognises the electrical connection.",
{
schematicPath: z.string().describe("Path to the .kicad_sch file"),
position: z.array(z.number()).length(2).describe("Junction position [x, y] in mm"),
},
async (args: { schematicPath: string; position: number[] }) => {
const result = await callKicadScript("add_schematic_junction", args);
if (result.success) {
return {
content: [
{
type: "text" as const,
text: result.message || "Junction added successfully",
},
],
};
} else {
return {
content: [
{
type: "text" as const,
text: `Failed to add junction: ${result.message || "Unknown error"}`,
},
],
};
}
},
);
// Add net label // Add net label
server.tool( server.tool(
"add_schematic_net_label", "add_schematic_net_label",

View File

@@ -1,7 +1,7 @@
""" """
Tests for fix/tool-schema-descriptions branch changes: Tests for schematic wire and junction handling:
- add_schematic_wire: waypoints param, pin snapping, polyline routing - add_schematic_wire: waypoints param, pin snapping, polyline routing
- add_schematic_junction: new tool replacing add_schematic_connection - Automatic junction sync (add_schematic_junction tool removed)
- Schema updates in tool_schemas.py - Schema updates in tool_schemas.py
- ConnectionManager orphaned method removal - ConnectionManager orphaned method removal
""" """
@@ -91,22 +91,10 @@ class TestSchemas:
"add_schematic_connection" not in self.tools "add_schematic_connection" not in self.tools
), "add_schematic_connection must not appear in SCHEMATIC_TOOLS" ), "add_schematic_connection must not appear in SCHEMATIC_TOOLS"
def test_add_schematic_junction_present(self) -> None: def test_add_schematic_junction_removed(self) -> None:
assert "add_schematic_junction" in self.tools assert (
"add_schematic_junction" not in self.tools
def test_add_schematic_junction_schema(self) -> None: ), "add_schematic_junction was removed; junctions are now managed automatically"
schema = self.tools["add_schematic_junction"]["inputSchema"]
props = schema["properties"]
assert "schematicPath" in props
assert "position" in props
assert set(schema["required"]) >= {"schematicPath", "position"}
def test_add_schematic_junction_position_is_array(self) -> None:
schema = self.tools["add_schematic_junction"]["inputSchema"]
pos = schema["properties"]["position"]
assert pos["type"] == "array"
assert pos.get("minItems") == 2
assert pos.get("maxItems") == 2
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -137,7 +125,6 @@ class TestHandlerDispatch:
# Build the handler map the same way the real __init__ does # Build the handler map the same way the real __init__ does
obj._tool_handlers = { obj._tool_handlers = {
"add_schematic_wire": obj._handle_add_schematic_wire, "add_schematic_wire": obj._handle_add_schematic_wire,
"add_schematic_junction": obj._handle_add_schematic_junction,
"add_schematic_net_label": obj._handle_add_schematic_net_label, "add_schematic_net_label": obj._handle_add_schematic_net_label,
} }
self.handlers = obj._tool_handlers self.handlers = obj._tool_handlers
@@ -148,10 +135,10 @@ class TestHandlerDispatch:
# Just verify the class has the handler method # Just verify the class has the handler method
assert hasattr(KiCADInterface, "_handle_add_schematic_wire") assert hasattr(KiCADInterface, "_handle_add_schematic_wire")
def test_add_schematic_junction_registered(self) -> None: def test_add_schematic_junction_handler_removed(self) -> None:
from kicad_interface import KiCADInterface from kicad_interface import KiCADInterface
assert hasattr(KiCADInterface, "_handle_add_schematic_junction") assert not hasattr(KiCADInterface, "_handle_add_schematic_junction")
def test_add_schematic_connection_not_present(self) -> None: def test_add_schematic_connection_not_present(self) -> None:
from kicad_interface import KiCADInterface from kicad_interface import KiCADInterface
@@ -418,67 +405,7 @@ class TestPinSnapping:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 6. _handle_add_schematic_junction — unit tests # 6. ConnectionManager — orphaned methods removed
# ---------------------------------------------------------------------------
class TestHandleAddSchematicJunction:
@pytest.fixture(autouse=True)
def setup(self) -> Any:
import types
for mod in ["pcbnew", "skip"]:
sys.modules.setdefault(mod, types.ModuleType(mod))
from kicad_interface import KiCADInterface
with patch.object(KiCADInterface, "__init__", lambda self, *a, **kw: None):
self.iface = KiCADInterface.__new__(KiCADInterface)
def test_missing_schematic_path(self) -> None:
result = self.iface._handle_add_schematic_junction({"position": [10.0, 20.0]})
assert result["success"] is False
assert "Schematic path" in result["message"]
def test_missing_position(self) -> None:
result = self.iface._handle_add_schematic_junction({"schematicPath": "/tmp/x.kicad_sch"})
assert result["success"] is False
assert "Position" in result["message"]
@patch("commands.wire_manager.WireManager.add_junction", return_value=True)
def test_success(self, mock_jct: Any) -> None:
sch = _make_temp_sch()
try:
result = self.iface._handle_add_schematic_junction(
{
"schematicPath": str(sch),
"position": [25.4, 25.4],
}
)
assert result["success"] is True
assert "Junction added" in result["message"]
mock_jct.assert_called_once_with(sch, [25.4, 25.4])
finally:
shutil.rmtree(sch.parent, ignore_errors=True)
@patch("commands.wire_manager.WireManager.add_junction", return_value=False)
def test_failure(self, _: Any) -> None:
sch = _make_temp_sch()
try:
result = self.iface._handle_add_schematic_junction(
{
"schematicPath": str(sch),
"position": [25.4, 25.4],
}
)
assert result["success"] is False
assert "Failed" in result["message"]
finally:
shutil.rmtree(sch.parent, ignore_errors=True)
# ---------------------------------------------------------------------------
# 7. ConnectionManager — orphaned methods removed
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -541,19 +468,6 @@ class TestIntegrationWireManager:
wires = _find_elements(data, "wire") wires = _find_elements(data, "wire")
assert len(wires) == 3, f"4 waypoints should produce 3 wire segments, got {len(wires)}" assert len(wires) == 3, f"4 waypoints should produce 3 wire segments, got {len(wires)}"
def test_add_junction_writes_junction_element(self, sch: Any) -> None:
from commands.wire_manager import WireManager
ok = WireManager.add_junction(sch, [25.4, 25.4])
assert ok is True
data = _parse_sch(sch)
junctions = _find_elements(data, "junction")
assert len(junctions) == 1
# Verify position
at = junctions[0][1] # (at x y)
assert at[1] == 25.4
assert at[2] == 25.4
def test_wire_endpoint_coordinates_match(self, sch: Any) -> None: def test_wire_endpoint_coordinates_match(self, sch: Any) -> None:
from commands.wire_manager import WireManager from commands.wire_manager import WireManager
@@ -590,18 +504,6 @@ class TestIntegrationHandlerEndToEnd:
yield yield
shutil.rmtree(self.sch.parent, ignore_errors=True) shutil.rmtree(self.sch.parent, ignore_errors=True)
def test_junction_handler_writes_junction(self) -> None:
result = self.iface._handle_add_schematic_junction(
{
"schematicPath": str(self.sch),
"position": [50.8, 50.8],
}
)
assert result["success"] is True
data = _parse_sch(self.sch)
junctions = _find_elements(data, "junction")
assert len(junctions) == 1
def test_wire_handler_two_points_writes_wire(self) -> None: def test_wire_handler_two_points_writes_wire(self) -> None:
result = self.iface._handle_add_schematic_wire( result = self.iface._handle_add_schematic_wire(
{ {
@@ -934,7 +836,7 @@ class TestBreakWiresAtPoint:
@pytest.mark.integration @pytest.mark.integration
class TestIntegrationTJunction: class TestIntegrationTJunction:
"""Integration tests for T-junction wire breaking during add_wire/add_junction.""" """Integration tests for T-junction wire breaking during add_wire."""
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def sch(self) -> Any: def sch(self) -> Any:
@@ -966,28 +868,6 @@ class TestIntegrationTJunction:
wires = _find_elements(data, "wire") wires = _find_elements(data, "wire")
assert len(wires) == 2, f"Expected 2 wires (no split), got {len(wires)}" assert len(wires) == 2, f"Expected 2 wires (no split), got {len(wires)}"
def test_add_junction_breaks_wire(self, sch: Any) -> None:
"""Adding a junction mid-wire should split that wire."""
from commands.wire_manager import WireManager
WireManager.add_wire(sch, [0, 0], [30, 0])
WireManager.add_junction(sch, [15, 0])
data = _parse_sch(sch)
wires = _find_elements(data, "wire")
assert len(wires) == 2, f"Expected 2 wires after junction split, got {len(wires)}"
junctions = _find_elements(data, "junction")
assert len(junctions) == 1
def test_add_junction_at_wire_endpoint_no_split(self, sch: Any) -> None:
"""Junction at wire endpoint should not split it."""
from commands.wire_manager import WireManager
WireManager.add_wire(sch, [0, 0], [20, 0])
WireManager.add_junction(sch, [20, 0])
data = _parse_sch(sch)
wires = _find_elements(data, "wire")
assert len(wires) == 1, f"Expected 1 wire (no split at endpoint), got {len(wires)}"
def test_polyline_breaks_existing_wire(self, sch: Any) -> None: def test_polyline_breaks_existing_wire(self, sch: Any) -> None:
"""Polyline whose start/end hits mid-wire should break it.""" """Polyline whose start/end hits mid-wire should break it."""
from commands.wire_manager import WireManager from commands.wire_manager import WireManager
@@ -1008,3 +888,589 @@ class TestIntegrationTJunction:
data = _parse_sch(sch) data = _parse_sch(sch)
wires = _find_elements(data, "wire") wires = _find_elements(data, "wire")
assert len(wires) == 1 assert len(wires) == 1
# ---------------------------------------------------------------------------
# 14. Auto junction sync
# ---------------------------------------------------------------------------
class TestSyncJunctionsUnit:
"""Unit tests for WireManager.sync_junctions operating on raw sch_data."""
def _base_data(self) -> Any:
"""Minimal sch_data with a sheet_instances section."""
return [Symbol("kicad_sch"), [Symbol("sheet_instances")]]
def _wire(self, x1: float, y1: float, x2: float, y2: float) -> list:
return [
Symbol("wire"),
[Symbol("pts"), [Symbol("xy"), x1, y1], [Symbol("xy"), x2, y2]],
[Symbol("stroke"), [Symbol("width"), 0], [Symbol("type"), Symbol("default")]],
[Symbol("uuid"), "00000000-0000-0000-0000-000000000000"],
]
def _junction(self, x: float, y: float) -> list:
return [
Symbol("junction"),
[Symbol("at"), x, y],
[Symbol("diameter"), 0],
[Symbol("color"), 0, 0, 0, 0],
[Symbol("uuid"), "00000000-0000-0000-0000-000000000001"],
]
def test_t_junction_adds_junction(self) -> None:
"""Three wire endpoints meeting at one point → one junction added."""
from commands.wire_manager import WireManager
data = self._base_data()
# Horizontal wire split at (10,0): (0,0)→(10,0) and (10,0)→(20,0)
data.insert(1, self._wire(0, 0, 10, 0))
data.insert(1, self._wire(10, 0, 20, 0))
# Vertical wire whose endpoint is (10,0): 3 endpoints at (10,0)
data.insert(1, self._wire(10, -10, 10, 0))
added, removed = WireManager.sync_junctions(data)
assert added == 1
assert removed == 0
junctions = _find_elements(data, "junction")
assert len(junctions) == 1
at = next(p for p in junctions[0][1:] if isinstance(p, list) and p[0] == Symbol("at"))
assert abs(at[1] - 10) < 1e-6
assert abs(at[2] - 0) < 1e-6
def test_two_wire_join_no_junction(self) -> None:
"""Two wires meeting end-to-end (2 endpoints) → no junction."""
from commands.wire_manager import WireManager
data = self._base_data()
data.insert(1, self._wire(0, 0, 10, 0))
data.insert(1, self._wire(10, 0, 20, 0))
added, removed = WireManager.sync_junctions(data)
assert added == 0
assert removed == 0
assert _find_elements(data, "junction") == []
def test_stale_junction_removed(self) -> None:
"""A junction with fewer than 3 wire endpoints at its position is removed."""
from commands.wire_manager import WireManager
data = self._base_data()
# Only two wires meeting at (10,0) — junction is stale
data.insert(1, self._wire(0, 0, 10, 0))
data.insert(1, self._wire(10, 0, 20, 0))
data.insert(1, self._junction(10, 0))
added, removed = WireManager.sync_junctions(data)
assert added == 0
assert removed == 1
assert _find_elements(data, "junction") == []
def test_x_junction_gets_one_junction(self) -> None:
"""Four wire endpoints meeting (X-junction) → exactly one junction."""
from commands.wire_manager import WireManager
data = self._base_data()
# Simulate already-split X: four segments meeting at (10,10)
data.insert(1, self._wire(0, 10, 10, 10))
data.insert(1, self._wire(10, 10, 20, 10))
data.insert(1, self._wire(10, 0, 10, 10))
data.insert(1, self._wire(10, 10, 10, 20))
added, removed = WireManager.sync_junctions(data)
assert added == 1
junctions = _find_elements(data, "junction")
assert len(junctions) == 1
def test_idempotent_on_correct_junction(self) -> None:
"""Running sync_junctions when junction is already correct is a no-op."""
from commands.wire_manager import WireManager
data = self._base_data()
data.insert(1, self._wire(0, 0, 10, 0))
data.insert(1, self._wire(10, 0, 20, 0))
data.insert(1, self._wire(10, -10, 10, 0))
data.insert(1, self._junction(10, 0))
added, removed = WireManager.sync_junctions(data)
assert added == 0
assert removed == 0
assert len(_find_elements(data, "junction")) == 1
@pytest.mark.integration
class TestSyncJunctionsIntegration:
"""Integration tests for automatic junction sync through add_wire / delete_wire."""
@pytest.fixture(autouse=True)
def sch(self) -> Any:
path = _make_temp_sch()
yield path
shutil.rmtree(path.parent, ignore_errors=True)
def test_t_junction_auto_inserted_on_add_wire(self, sch: Any) -> None:
"""Adding a wire that creates a T-junction should auto-insert a junction dot."""
from commands.wire_manager import WireManager
# Horizontal wire, then split it manually, then add connecting vertical
WireManager.add_wire(sch, [0, 10], [10, 10])
WireManager.add_wire(sch, [10, 10], [20, 10])
WireManager.add_wire(sch, [10, 0], [10, 10]) # creates T at (10,10)
data = _parse_sch(sch)
junctions = _find_elements(data, "junction")
assert len(junctions) == 1, f"Expected 1 junction, got {len(junctions)}"
def test_straight_join_no_junction(self, sch: Any) -> None:
"""Two wires joining end-to-end should not produce a junction."""
from commands.wire_manager import WireManager
WireManager.add_wire(sch, [0, 0], [10, 0])
WireManager.add_wire(sch, [10, 0], [20, 0])
data = _parse_sch(sch)
junctions = _find_elements(data, "junction")
assert junctions == [], f"Expected no junctions, got {len(junctions)}"
def test_delete_wire_removes_stale_junction(self, sch: Any) -> None:
"""Removing a wire that was part of a T-junction should remove the junction."""
from commands.wire_manager import WireManager
WireManager.add_wire(sch, [0, 10], [10, 10])
WireManager.add_wire(sch, [10, 10], [20, 10])
WireManager.add_wire(sch, [10, 0], [10, 10]) # creates T, junction auto-inserted
data = _parse_sch(sch)
assert len(_find_elements(data, "junction")) == 1, "Pre-condition: junction present"
WireManager.delete_wire(sch, [10, 0], [10, 10]) # remove the branch
data = _parse_sch(sch)
junctions = _find_elements(data, "junction")
assert junctions == [], f"Expected junction removed, got {len(junctions)}"
def test_polyline_t_junction_auto_inserted(self, sch: Any) -> None:
"""Polyline whose endpoint hits a wire midpoint auto-inserts a junction."""
from commands.wire_manager import WireManager
WireManager.add_wire(sch, [0, 10], [20, 10]) # horizontal
# Polyline from above ending at (10,10) — mid-wire, wire breaks → T at (10,10)
WireManager.add_polyline_wire(sch, [[10, 0], [10, 10]])
data = _parse_sch(sch)
junctions = _find_elements(data, "junction")
assert len(junctions) == 1, f"Expected 1 junction after polyline T, got {len(junctions)}"
# ---------------------------------------------------------------------------
# 15. Pin-aware junction sync
# ---------------------------------------------------------------------------
class TestSyncJunctionsPinAware:
"""Unit tests for junction sync when component pins are present."""
def _base_data_with_resistor(self, sym_x: float, sym_y: float, rotation: float = 0) -> Any:
"""Build minimal sch_data with a Device:R-like symbol.
Pin 1 is at (0, 3.81) in lib coords → world (sym_x, sym_y - 3.81) at rot=0.
Pin 2 is at (0, -3.81) in lib coords → world (sym_x, sym_y + 3.81) at rot=0.
"""
pin1_sexp = [
Symbol("pin"),
Symbol("passive"),
Symbol("line"),
[Symbol("at"), 0, 3.81, 270],
[Symbol("length"), 1.27],
[Symbol("name"), "~"],
[Symbol("number"), "1"],
]
pin2_sexp = [
Symbol("pin"),
Symbol("passive"),
Symbol("line"),
[Symbol("at"), 0, -3.81, 90],
[Symbol("length"), 1.27],
[Symbol("name"), "~"],
[Symbol("number"), "2"],
]
lib_symbol = [
Symbol("symbol"),
"Device:R",
[Symbol("pin_numbers"), Symbol("hide")],
[Symbol("symbol"), "R_1_1", pin1_sexp, pin2_sexp],
]
lib_symbols_section = [Symbol("lib_symbols"), lib_symbol]
placed_symbol = [
Symbol("symbol"),
[Symbol("lib_id"), "Device:R"],
[Symbol("at"), sym_x, sym_y, rotation],
[Symbol("unit"), 1],
[Symbol("property"), "Reference", "R1", [Symbol("at"), sym_x, sym_y, 0]],
[Symbol("property"), "Value", "10k", [Symbol("at"), sym_x, sym_y, 0]],
[Symbol("pin"), "1", [Symbol("uuid"), "pin1-uuid"]],
[Symbol("pin"), "2", [Symbol("uuid"), "pin2-uuid"]],
]
return [
Symbol("kicad_sch"),
lib_symbols_section,
placed_symbol,
[Symbol("sheet_instances")],
]
def _wire(self, x1: float, y1: float, x2: float, y2: float) -> list:
return [
Symbol("wire"),
[Symbol("pts"), [Symbol("xy"), x1, y1], [Symbol("xy"), x2, y2]],
[Symbol("stroke"), [Symbol("width"), 0], [Symbol("type"), Symbol("default")]],
[Symbol("uuid"), "wire-uuid"],
]
def test_collect_pin_positions_no_rotation(self) -> None:
"""_collect_pin_positions returns correct world coords for R at (100,100) rot=0."""
from commands.wire_manager import WireManager
data = self._base_data_with_resistor(100, 100, rotation=0)
pins = WireManager._collect_pin_positions(data)
# Pin 1: lib (0, 3.81) → y-negate → (0, -3.81) → world (100, 96.19)
# Pin 2: lib (0, -3.81) → y-negate → (0, 3.81) → world (100, 103.81)
assert len(pins) == 2
pin_set = {(round(x, 2), round(y, 2)) for x, y in pins}
assert (100.0, 96.19) in pin_set
assert (100.0, 103.81) in pin_set
def test_collect_pin_positions_rotation_90(self) -> None:
"""_collect_pin_positions handles 90° rotation correctly."""
from commands.wire_manager import WireManager
data = self._base_data_with_resistor(100, 100, rotation=90)
pins = WireManager._collect_pin_positions(data)
# Pin 1: lib (0, 3.81) → y-neg → (0, -3.81) → rot90 → (3.81, 0) → world (103.81, 100)
# Pin 2: lib (0, -3.81) → y-neg → (0, 3.81) → rot90 → (-3.81, 0) → world (96.19, 100)
assert len(pins) == 2
pin_set = {(round(x, 2), round(y, 2)) for x, y in pins}
assert (103.81, 100.0) in pin_set
assert (96.19, 100.0) in pin_set
def test_two_wires_plus_pin_gets_junction(self) -> None:
"""Two wires ending at a pin position → junction needed (total 3)."""
from commands.wire_manager import WireManager
# R at (100, 100): pin 1 world = (100, 96.19)
data = self._base_data_with_resistor(100, 100, rotation=0)
# Two wires both ending at pin 1
data.insert(2, self._wire(80, 96.19, 100, 96.19))
data.insert(2, self._wire(100, 96.19, 120, 96.19))
added, removed = WireManager.sync_junctions(data)
assert added == 1, f"Expected 1 junction added, got {added}"
assert removed == 0
def test_one_wire_plus_pin_no_junction(self) -> None:
"""Single wire connecting to a pin (total 2) → no junction."""
from commands.wire_manager import WireManager
data = self._base_data_with_resistor(100, 100, rotation=0)
# Single wire ending at pin 1
data.insert(2, self._wire(80, 96.19, 100, 96.19))
added, removed = WireManager.sync_junctions(data)
assert added == 0, f"Expected no junction, got {added}"
assert removed == 0
def test_wire_midpoint_pin_gets_junction(self) -> None:
"""Wire passing through (broken at) a pin position + another wire → junction."""
from commands.wire_manager import WireManager
# R at (100, 100): pin 1 world = (100, 96.19)
# Simulate a horizontal wire already split at pin position
data = self._base_data_with_resistor(100, 100, rotation=0)
data.insert(2, self._wire(80, 96.19, 100, 96.19)) # left segment
data.insert(2, self._wire(100, 96.19, 120, 96.19)) # right segment
# wire_cnt=2, pin_cnt=1 → total=3 → junction
added, removed = WireManager.sync_junctions(data)
assert added == 1, f"Expected junction at wire-split + pin, got {added}"
# ------------------------------------------------------------------
# Mirror / mirror+rotation tests
# Transform order (per _collect_pin_positions docstring):
# 1. y-negate (lib y-up → schematic y-down)
# 2. mirror_x → ly = -ly
# mirror_y → lx = -lx
# 3. rotate by `rotation` degrees
# 4. translate by (sym_x, sym_y)
# TODO: verify against KiCad's actual transform order — see schematic_view.py
# ------------------------------------------------------------------
def _base_data_with_resistor_mirrored(
self,
sym_x: float,
sym_y: float,
rotation: float = 0,
mirror: str = "x",
) -> Any:
"""Like _base_data_with_resistor but injects a (mirror x) or (mirror y) clause
into the placed-symbol form."""
data = self._base_data_with_resistor(sym_x, sym_y, rotation)
# data[2] is the placed_symbol list
placed = data[2]
placed.append([Symbol("mirror"), Symbol(mirror)])
return data
def test_collect_pin_positions_mirror_x_rotation_0(self) -> None:
"""mirror_x at rotation=0: pins flip across the local x-axis (y-coords invert).
Pin 1: lib(0, 3.81) → y-neg → (0, -3.81) → mirror_x → (0, +3.81) → world (100, 103.81)
Pin 2: lib(0,-3.81) → y-neg → (0, +3.81) → mirror_x → (0, -3.81) → world (100, 96.19)
"""
from commands.wire_manager import WireManager
data = self._base_data_with_resistor_mirrored(100, 100, rotation=0, mirror="x")
pins = WireManager._collect_pin_positions(data)
assert len(pins) == 2
pin_set = {(round(x, 2), round(y, 2)) for x, y in pins}
assert (100.0, 103.81) in pin_set
assert (100.0, 96.19) in pin_set
def test_collect_pin_positions_mirror_y_rotation_0(self) -> None:
"""mirror_y at rotation=0: flips lx (which is 0 for both pins) → same world coords.
Pin 1: lib(0, 3.81) → y-neg → (0, -3.81) → mirror_y: lx=-0=0 → world (100, 96.19)
Pin 2: lib(0,-3.81) → y-neg → (0, +3.81) → mirror_y: lx=-0=0 → world (100, 103.81)
"""
from commands.wire_manager import WireManager
data = self._base_data_with_resistor_mirrored(100, 100, rotation=0, mirror="y")
pins = WireManager._collect_pin_positions(data)
assert len(pins) == 2
pin_set = {(round(x, 2), round(y, 2)) for x, y in pins}
assert (100.0, 96.19) in pin_set
assert (100.0, 103.81) in pin_set
def test_collect_pin_positions_mirror_x_rotation_90(self) -> None:
"""mirror_x combined with rotation=90.
Pin 1: lib(0, 3.81) → y-neg → (0,-3.81) → mirror_x → (0,+3.81)
→ rot90 (c=0,s=1): lx'=0*0-3.81*1=-3.81, ly'=0*1+3.81*0=0
→ world (96.19, 100.0)
Pin 2: lib(0,-3.81) → y-neg → (0,+3.81) → mirror_x → (0,-3.81)
→ rot90: lx'=0*0-(-3.81)*1=3.81, ly'=0*1+(-3.81)*0=0
→ world (103.81, 100.0)
"""
from commands.wire_manager import WireManager
data = self._base_data_with_resistor_mirrored(100, 100, rotation=90, mirror="x")
pins = WireManager._collect_pin_positions(data)
assert len(pins) == 2
pin_set = {(round(x, 2), round(y, 2)) for x, y in pins}
assert (96.19, 100.0) in pin_set
assert (103.81, 100.0) in pin_set
def test_collect_pin_positions_mirror_y_rotation_180(self) -> None:
"""mirror_y combined with rotation=180.
Pin 1: lib(0, 3.81) → y-neg → (0,-3.81) → mirror_y: lx=-0=0, ly=-3.81
→ rot180 (c=-1,s=0): lx'=0*(-1)-(-3.81)*0=0, ly'=0*0+(-3.81)*(-1)=3.81
→ world (100.0, 103.81)
Pin 2: lib(0,-3.81) → y-neg → (0,+3.81) → mirror_y: lx=0, ly=3.81
→ rot180: lx'=0*(-1)-3.81*0=0, ly'=0*0+3.81*(-1)=-3.81
→ world (100.0, 96.19)
"""
from commands.wire_manager import WireManager
data = self._base_data_with_resistor_mirrored(100, 100, rotation=180, mirror="y")
pins = WireManager._collect_pin_positions(data)
assert len(pins) == 2
pin_set = {(round(x, 2), round(y, 2)) for x, y in pins}
assert (100.0, 103.81) in pin_set
assert (100.0, 96.19) in pin_set
# ---------------------------------------------------------------------------
# 16. Multi-unit IC pin-awareness (LM324-like quad op-amp)
# ---------------------------------------------------------------------------
class TestMultiUnitLibPins:
"""Unit tests for _parse_lib_pins unit-aware filtering (multi-unit ICs)."""
@staticmethod
def _make_lm324_lib_symbol() -> list:
"""Return a minimal lib_symbols entry modelled on the LM324 quad op-amp.
Structure:
(symbol "Amplifier_Operational:LM324"
(symbol "LM324_1_1" ;; unit 1 op-amp body A
(pin input line (at -5 2.54 0) ...) ; IN+
(pin input line (at -5 -2.54 0) ...) ; IN-
(pin output line (at 5 0 180) ...) ; OUT
)
(symbol "LM324_2_1" ;; unit 2 op-amp body B
(pin input line (at -5 2.54 0) ...)
(pin input line (at -5 -2.54 0) ...)
(pin output line (at 5 0 180) ...)
)
(symbol "LM324_3_1" ;; unit 3
...
)
(symbol "LM324_4_1" ;; unit 4
...
)
(symbol "LM324_5_1" ;; unit 5 = power (common)
(pin power_in line (at 0 5 270) ...) ; VCC
(pin power_in line (at 0 -5 90) ...) ; GND
)
)
"""
def _pin(px: float, py: float) -> list:
return [
Symbol("pin"),
Symbol("input"),
Symbol("line"),
[Symbol("at"), px, py, 0],
[Symbol("length"), 1.27],
[Symbol("name"), "~"],
[Symbol("number"), "1"],
]
def _sub(name: str, pins: list) -> list:
return [Symbol("symbol"), name] + pins
unit1 = _sub("LM324_1_1", [_pin(-5, 2.54), _pin(-5, -2.54), _pin(5, 0)])
unit2 = _sub("LM324_2_1", [_pin(-5, 12.54), _pin(-5, -12.54), _pin(5, 10)])
unit3 = _sub("LM324_3_1", [_pin(-5, 22.54), _pin(-5, -22.54), _pin(5, 20)])
unit4 = _sub("LM324_4_1", [_pin(-5, 32.54), _pin(-5, -32.54), _pin(5, 30)])
power = _sub("LM324_5_1", [_pin(0, 5), _pin(0, -5)]) # unit 5 = common/power
return [
Symbol("symbol"),
"Amplifier_Operational:LM324",
unit1,
unit2,
unit3,
unit4,
power,
]
def test_unit2_returns_only_unit2_and_common_pins(self) -> None:
"""Placing unit 2 must return 3 op-amp pins + 2 power (common) pins = 5 total."""
from commands.wire_manager import WireManager
sym_def = self._make_lm324_lib_symbol()
pins = WireManager._parse_lib_pins(sym_def, unit=2)
# Unit 2 contributes 3 pins; LM324_5_1 (unit 5 != 2 != 0) should be excluded.
# Only unit==2 OR unit==0 qualifies. LM324_5_1 has unit=5, so it is skipped.
assert (
len(pins) == 3
), f"Expected 3 pins (unit 2 only, power unit 5 excluded), got {len(pins)}: {pins}"
# The three unit-2 pins have distinct y-coords in our fixture
pin_coords = {(round(x, 2), round(y, 2)) for x, y in pins}
assert (-5.0, 12.54) in pin_coords
assert (-5.0, -12.54) in pin_coords
assert (5.0, 10.0) in pin_coords
def test_unit1_does_not_include_unit2_pins(self) -> None:
"""Placing unit 1 must not return pins from units 2-5."""
from commands.wire_manager import WireManager
sym_def = self._make_lm324_lib_symbol()
pins = WireManager._parse_lib_pins(sym_def, unit=1)
assert len(pins) == 3, f"Expected 3 pins (unit 1 only), got {len(pins)}: {pins}"
pin_coords = {(round(x, 2), round(y, 2)) for x, y in pins}
assert (-5.0, 2.54) in pin_coords
assert (-5.0, -2.54) in pin_coords
assert (5.0, 0.0) in pin_coords
def test_common_unit_zero_always_included(self) -> None:
"""A sub-unit named <base>_0_1 (unit=0) must appear for every placed unit."""
from commands.wire_manager import WireManager
# Build a symbol that has a _0_1 common sub-unit
def _pin(px: float, py: float) -> list:
return [
Symbol("pin"),
Symbol("power_in"),
Symbol("line"),
[Symbol("at"), px, py, 0],
[Symbol("length"), 1.27],
[Symbol("name"), "VCC"],
[Symbol("number"), "1"],
]
sym_def = [
Symbol("symbol"),
"Device:Foo",
[Symbol("symbol"), "Foo_0_1", _pin(0, 5)], # common (unit 0)
[Symbol("symbol"), "Foo_1_1", _pin(1, 1)], # unit 1
[Symbol("symbol"), "Foo_2_1", _pin(2, 2)], # unit 2
]
pins_u1 = WireManager._parse_lib_pins(sym_def, unit=1)
pins_u2 = WireManager._parse_lib_pins(sym_def, unit=2)
# Both units should include the unit-0 common pin
assert len(pins_u1) == 2, f"Unit 1: expected 2 pins (1 common + 1 own), got {len(pins_u1)}"
assert len(pins_u2) == 2, f"Unit 2: expected 2 pins (1 common + 1 own), got {len(pins_u2)}"
def test_no_spurious_junctions_for_multi_unit_ic(self) -> None:
"""Placing unit 2 of an LM324-like part must not create phantom junctions
from the pins of units 1, 3, 4, or 5."""
from commands.wire_manager import WireManager
sym_def = self._make_lm324_lib_symbol()
lib_symbols_section = [Symbol("lib_symbols"), sym_def]
# Place unit 2 at origin (0, 0), no rotation
placed = [
Symbol("symbol"),
[Symbol("lib_id"), "Amplifier_Operational:LM324"],
[Symbol("at"), 0, 0, 0],
[Symbol("unit"), 2],
]
# A single wire from (-30, 12.54) to (-5, 12.54) connects to unit-2 IN+ pin.
# world pin coords: lib (5, 12.54) → y-negate → (5, 12.54) → + origin = (5, 12.54)
# (The y-negation is part of KiCad's lib-to-schematic transform.)
wire = [
Symbol("wire"),
[Symbol("pts"), [Symbol("xy"), -30, -12.54], [Symbol("xy"), -5, -12.54]],
[Symbol("stroke"), [Symbol("width"), 0], [Symbol("type"), Symbol("default")]],
[Symbol("uuid"), "test-wire"],
]
data = [
Symbol("kicad_sch"),
lib_symbols_section,
placed,
wire,
[Symbol("sheet_instances")],
]
added, removed = WireManager.sync_junctions(data)
# Wire endpoint at (5, 12.54) plus 1 pin = 2 total → no junction needed.
assert added == 0, (
f"Expected 0 junctions (wire endpoint + 1 pin = 2), got {added} added. "
"Phantom pins from other units may be causing false positives."
)