Auto-sync junctions on wire/symbol mutations
Replaces the manual add_schematic_junction tool with automatic junction management. WireManager.sync_junctions inserts/removes junction dots based on wire endpoints plus component pin positions and is invoked after add_wire, add_polyline_wire, delete_wire, move, and rotate. - Pin-aware: parses lib_symbols and applies KiCad's mirror/rotate/ translate transform to compute world pin coordinates - Multi-unit safe: filters lib_symbols sub-units by the placed symbol's (unit N) field plus the unit-0 common body - Removes the now-unused WireManager.add_junction static method - Updates CHANGELOG [Unreleased] with the tool removal notice - Adds .mcp.json to .gitignore (machine-local paths) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -89,6 +89,7 @@ Desktop.ini
|
||||
|
||||
# Generated local config files (contain machine-specific paths)
|
||||
windows-mcp-config.json
|
||||
.mcp.json
|
||||
|
||||
# Personal notes / local contributions (not for upstream)
|
||||
myContribution/
|
||||
|
||||
13
CHANGELOG.md
13
CHANGELOG.md
@@ -1,5 +1,10 @@
|
||||
# Changelog
|
||||
|
||||
> **⚠️ Research Project — No Stability Guarantees**
|
||||
> This is an unstable, experimental research project. APIs, tool names, parameters,
|
||||
> and file formats may change at any time without notice. Back-compatibility is
|
||||
> explicitly **not** preserved between versions.
|
||||
|
||||
All notable changes to the KiCAD MCP Server project are documented here.
|
||||
|
||||
## [Unreleased]
|
||||
@@ -71,6 +76,14 @@ All notable changes to the KiCAD MCP Server project are documented here.
|
||||
no-op removal, special-character escaping, UUID preservation, and the two
|
||||
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
|
||||
|
||||
@@ -32,6 +32,12 @@ _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:
|
||||
@@ -176,6 +182,8 @@ class WireManager:
|
||||
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)
|
||||
@@ -252,6 +260,8 @@ class WireManager:
|
||||
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)
|
||||
@@ -450,74 +460,266 @@ class WireManager:
|
||||
return splits
|
||||
|
||||
@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."""
|
||||
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 <base>_<unit>_<style> where unit and style are integers.
|
||||
_SUB_UNIT_RE = re.compile(r"^(.+)_(\d+)_(\d+)$")
|
||||
|
||||
@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.
|
||||
"""
|
||||
Add a junction (connection dot) to the schematic.
|
||||
_SYM_SYMBOL = Symbol("symbol")
|
||||
pins: List[Tuple[float, float]] = []
|
||||
|
||||
Mirrors KiCAD's AddJunction behaviour: any wire whose interior passes
|
||||
through *position* is split into two segments at that point so that
|
||||
the BFS-based get_wire_connections tool can traverse the T/X branch.
|
||||
# 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)
|
||||
|
||||
Args:
|
||||
schematic_path: Path to .kicad_sch file
|
||||
position: [x, y] coordinates for junction
|
||||
diameter: Junction diameter (0 for default)
|
||||
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:])
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
# 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.
|
||||
"""
|
||||
try:
|
||||
# Read schematic
|
||||
with open(schematic_path, "r", encoding="utf-8") as f:
|
||||
sch_content = f.read()
|
||||
_SYM_UNIT = Symbol("unit")
|
||||
|
||||
sch_data = sexpdata.loads(sch_content)
|
||||
# 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] == Symbol("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
|
||||
|
||||
# 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}")
|
||||
# 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] == Symbol("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])
|
||||
|
||||
# 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("color"), 0, 0, 0, 0],
|
||||
[Symbol("uuid"), str(uuid.uuid4())],
|
||||
]
|
||||
# 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
|
||||
|
||||
# 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
|
||||
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
|
||||
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
|
||||
|
||||
to_add = needed_iu - existing_iu
|
||||
added = 0
|
||||
if to_add:
|
||||
if sheet_instances_index is None:
|
||||
logger.error("No sheet_instances section found in schematic")
|
||||
return False
|
||||
logger.warning("sync_junctions: no sheet_instances found, skipping junction insert")
|
||||
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
|
||||
sch_data.insert(sheet_instances_index, junction_sexp)
|
||||
logger.info(f"Injected junction 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 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
|
||||
if added or removed:
|
||||
logger.info(f"sync_junctions: added {added}, removed {removed}")
|
||||
return added, removed
|
||||
|
||||
@staticmethod
|
||||
def add_no_connect(schematic_path: Path, position: List[float]) -> bool:
|
||||
@@ -644,6 +846,7 @@ class WireManager:
|
||||
|
||||
if match_fwd or match_rev:
|
||||
del sch_data[i]
|
||||
WireManager.sync_junctions(sch_data)
|
||||
with open(schematic_path, "w", encoding="utf-8") as f:
|
||||
f.write(sexpdata.dumps(sch_data))
|
||||
logger.info(f"Deleted wire from {start_point} to {end_point}")
|
||||
@@ -973,29 +1176,24 @@ if __name__ == "__main__":
|
||||
print(f"\n✓ Created test schematic: {test_path}")
|
||||
|
||||
# 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])
|
||||
print(f" {'✓' if success else '✗'} Simple wire: {success}")
|
||||
|
||||
# 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])
|
||||
print(f" Orthogonal path: {path}")
|
||||
success = WireManager.add_polyline_wire(test_path, path)
|
||||
print(f" {'✓' if success else '✗'} Polyline wire: {success}")
|
||||
|
||||
# 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])
|
||||
print(f" {'✓' if success else '✗'} Label: {success}")
|
||||
|
||||
# Test 4: Add junction
|
||||
print("\n[4/5] Testing junction 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...")
|
||||
# Test 4: Add no-connect
|
||||
print("\n[4/4] Testing no-connect creation...")
|
||||
success = WireManager.add_no_connect(test_path, [127, 50.8])
|
||||
print(f" {'✓' if success else '✗'} No-connect: {success}")
|
||||
|
||||
|
||||
@@ -389,7 +389,6 @@ class KiCADInterface:
|
||||
"get_schematic_component": self._handle_get_schematic_component,
|
||||
"add_schematic_wire": self._handle_add_schematic_wire,
|
||||
"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_passthrough": self._handle_connect_passthrough,
|
||||
"get_schematic_pin_locations": self._handle_get_schematic_pin_locations,
|
||||
@@ -1628,39 +1627,6 @@ class KiCADInterface:
|
||||
"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]:
|
||||
"""List available symbol libraries"""
|
||||
logger.info("Listing schematic libraries")
|
||||
@@ -2626,6 +2592,10 @@ class KiCADInterface:
|
||||
# Update symbol position
|
||||
WireDragger.update_symbol_position(sch_data, reference, float(new_x), float(new_y))
|
||||
|
||||
from commands.wire_manager import WireManager as _WireManager
|
||||
|
||||
_WireManager.sync_junctions(sch_data)
|
||||
|
||||
with open(schematic_path, "w", encoding="utf-8") as f:
|
||||
f.write(_sexpdata.dumps(sch_data))
|
||||
|
||||
@@ -2684,6 +2654,19 @@ class KiCADInterface:
|
||||
)
|
||||
|
||||
SchematicManager.save_schematic(schematic, schematic_path)
|
||||
|
||||
# Re-open with raw sexpdata so sync_junctions can operate on
|
||||
# the mutated file (mirrors the pattern used in move handler).
|
||||
import sexpdata as _sexpdata
|
||||
|
||||
with open(schematic_path, "r", encoding="utf-8") as _f:
|
||||
sch_data = _sexpdata.load(_f)
|
||||
from commands.wire_manager import WireManager as _WireManager
|
||||
|
||||
_WireManager.sync_junctions(sch_data)
|
||||
with open(schematic_path, "w", encoding="utf-8") as _f:
|
||||
_f.write(_sexpdata.dumps(sch_data))
|
||||
|
||||
return {"success": True, "reference": reference, "angle": angle}
|
||||
|
||||
return {"success": False, "message": f"Component {reference} not found"}
|
||||
|
||||
@@ -1704,28 +1704,6 @@ SCHEMATIC_TOOLS = [
|
||||
"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) ---
|
||||
{
|
||||
"name": "get_schematic_view_region",
|
||||
|
||||
@@ -94,7 +94,6 @@ export const toolCategories: ToolCategory[] = [
|
||||
"annotate_schematic",
|
||||
"add_schematic_wire",
|
||||
"delete_schematic_wire",
|
||||
"add_schematic_junction",
|
||||
"add_schematic_net_label",
|
||||
"delete_schematic_net_label",
|
||||
"connect_to_net",
|
||||
|
||||
@@ -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
|
||||
server.tool(
|
||||
"add_schematic_net_label",
|
||||
|
||||
@@ -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_junction: new tool replacing add_schematic_connection
|
||||
- Automatic junction sync (add_schematic_junction tool removed)
|
||||
- Schema updates in tool_schemas.py
|
||||
- ConnectionManager orphaned method removal
|
||||
"""
|
||||
@@ -91,22 +91,10 @@ class TestSchemas:
|
||||
"add_schematic_connection" not in self.tools
|
||||
), "add_schematic_connection must not appear in SCHEMATIC_TOOLS"
|
||||
|
||||
def test_add_schematic_junction_present(self) -> None:
|
||||
assert "add_schematic_junction" in self.tools
|
||||
|
||||
def test_add_schematic_junction_schema(self) -> None:
|
||||
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
|
||||
def test_add_schematic_junction_removed(self) -> None:
|
||||
assert (
|
||||
"add_schematic_junction" not in self.tools
|
||||
), "add_schematic_junction was removed; junctions are now managed automatically"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -137,7 +125,6 @@ class TestHandlerDispatch:
|
||||
# Build the handler map the same way the real __init__ does
|
||||
obj._tool_handlers = {
|
||||
"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,
|
||||
}
|
||||
self.handlers = obj._tool_handlers
|
||||
@@ -148,10 +135,10 @@ class TestHandlerDispatch:
|
||||
# Just verify the class has the handler method
|
||||
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
|
||||
|
||||
assert hasattr(KiCADInterface, "_handle_add_schematic_junction")
|
||||
assert not hasattr(KiCADInterface, "_handle_add_schematic_junction")
|
||||
|
||||
def test_add_schematic_connection_not_present(self) -> None:
|
||||
from kicad_interface import KiCADInterface
|
||||
@@ -418,67 +405,7 @@ class TestPinSnapping:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6. _handle_add_schematic_junction — unit tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
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
|
||||
# 6. ConnectionManager — orphaned methods removed
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -541,19 +468,6 @@ class TestIntegrationWireManager:
|
||||
wires = _find_elements(data, "wire")
|
||||
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:
|
||||
from commands.wire_manager import WireManager
|
||||
|
||||
@@ -590,18 +504,6 @@ class TestIntegrationHandlerEndToEnd:
|
||||
yield
|
||||
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:
|
||||
result = self.iface._handle_add_schematic_wire(
|
||||
{
|
||||
@@ -934,7 +836,7 @@ class TestBreakWiresAtPoint:
|
||||
|
||||
@pytest.mark.integration
|
||||
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)
|
||||
def sch(self) -> Any:
|
||||
@@ -966,28 +868,6 @@ class TestIntegrationTJunction:
|
||||
wires = _find_elements(data, "wire")
|
||||
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:
|
||||
"""Polyline whose start/end hits mid-wire should break it."""
|
||||
from commands.wire_manager import WireManager
|
||||
@@ -1008,3 +888,589 @@ class TestIntegrationTJunction:
|
||||
data = _parse_sch(sch)
|
||||
wires = _find_elements(data, "wire")
|
||||
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."
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user