Merge pull request #60 from Mehanik/schematic_tools
feat: add schematic inspection, editing & export tools
This commit is contained in:
@@ -10,11 +10,15 @@ logger = logging.getLogger(__name__)
|
||||
# Import dynamic symbol loader
|
||||
try:
|
||||
from commands.dynamic_symbol_loader import DynamicSymbolLoader
|
||||
|
||||
DYNAMIC_LOADING_AVAILABLE = True
|
||||
except ImportError:
|
||||
logger.warning("Dynamic symbol loader not available - falling back to template-only mode")
|
||||
logger.warning(
|
||||
"Dynamic symbol loader not available - falling back to template-only mode"
|
||||
)
|
||||
DYNAMIC_LOADING_AVAILABLE = False
|
||||
|
||||
|
||||
class ComponentManager:
|
||||
"""Manage components in a schematic"""
|
||||
|
||||
@@ -31,43 +35,44 @@ class ComponentManager:
|
||||
# Template symbol references mapping component type to template reference
|
||||
TEMPLATE_MAP = {
|
||||
# Passives
|
||||
'R': '_TEMPLATE_R',
|
||||
'C': '_TEMPLATE_C',
|
||||
'L': '_TEMPLATE_L',
|
||||
'Y': '_TEMPLATE_Y',
|
||||
'Crystal': '_TEMPLATE_Y',
|
||||
|
||||
"R": "_TEMPLATE_R",
|
||||
"C": "_TEMPLATE_C",
|
||||
"L": "_TEMPLATE_L",
|
||||
"Y": "_TEMPLATE_Y",
|
||||
"Crystal": "_TEMPLATE_Y",
|
||||
# Semiconductors
|
||||
'D': '_TEMPLATE_D',
|
||||
'LED': '_TEMPLATE_LED',
|
||||
'Q': '_TEMPLATE_Q_NPN',
|
||||
'Q_NPN': '_TEMPLATE_Q_NPN',
|
||||
'Q_NMOS': '_TEMPLATE_Q_NMOS',
|
||||
'MOSFET': '_TEMPLATE_Q_NMOS',
|
||||
|
||||
"D": "_TEMPLATE_D",
|
||||
"LED": "_TEMPLATE_LED",
|
||||
"Q": "_TEMPLATE_Q_NPN",
|
||||
"Q_NPN": "_TEMPLATE_Q_NPN",
|
||||
"Q_NMOS": "_TEMPLATE_Q_NMOS",
|
||||
"MOSFET": "_TEMPLATE_Q_NMOS",
|
||||
# ICs
|
||||
'U': '_TEMPLATE_U_OPAMP',
|
||||
'OpAmp': '_TEMPLATE_U_OPAMP',
|
||||
'IC': '_TEMPLATE_U_OPAMP',
|
||||
'U_REG': '_TEMPLATE_U_REG',
|
||||
'Regulator': '_TEMPLATE_U_REG',
|
||||
|
||||
"U": "_TEMPLATE_U_OPAMP",
|
||||
"OpAmp": "_TEMPLATE_U_OPAMP",
|
||||
"IC": "_TEMPLATE_U_OPAMP",
|
||||
"U_REG": "_TEMPLATE_U_REG",
|
||||
"Regulator": "_TEMPLATE_U_REG",
|
||||
# Connectors
|
||||
'J': '_TEMPLATE_J2',
|
||||
'J2': '_TEMPLATE_J2',
|
||||
'J4': '_TEMPLATE_J4',
|
||||
'Conn_2': '_TEMPLATE_J2',
|
||||
'Conn_4': '_TEMPLATE_J4',
|
||||
|
||||
"J": "_TEMPLATE_J2",
|
||||
"J2": "_TEMPLATE_J2",
|
||||
"J4": "_TEMPLATE_J4",
|
||||
"Conn_2": "_TEMPLATE_J2",
|
||||
"Conn_4": "_TEMPLATE_J4",
|
||||
# Misc
|
||||
'SW': '_TEMPLATE_SW',
|
||||
'Button': '_TEMPLATE_SW',
|
||||
'Switch': '_TEMPLATE_SW',
|
||||
"SW": "_TEMPLATE_SW",
|
||||
"Button": "_TEMPLATE_SW",
|
||||
"Switch": "_TEMPLATE_SW",
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def get_or_create_template(cls, schematic: Schematic, comp_type: str, library: Optional[str] = None,
|
||||
schematic_path: Optional[Path] = None) -> tuple:
|
||||
def get_or_create_template(
|
||||
cls,
|
||||
schematic: Schematic,
|
||||
comp_type: str,
|
||||
library: Optional[str] = None,
|
||||
schematic_path: Optional[Path] = None,
|
||||
) -> tuple:
|
||||
"""
|
||||
Get template reference for a component type, creating it dynamically if needed
|
||||
|
||||
@@ -80,11 +85,15 @@ class ComponentManager:
|
||||
Returns:
|
||||
Tuple of (template_ref, needs_reload) where needs_reload indicates if schematic must be reloaded
|
||||
"""
|
||||
|
||||
# Helper function to check if template exists in schematic
|
||||
def template_exists(schematic, template_ref):
|
||||
"""Check if template exists by iterating symbols (handles special characters)"""
|
||||
for symbol in schematic.symbol:
|
||||
if hasattr(symbol.property, 'Reference') and symbol.property.Reference.value == template_ref:
|
||||
if (
|
||||
hasattr(symbol.property, "Reference")
|
||||
and symbol.property.Reference.value == template_ref
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -113,46 +122,61 @@ class ComponentManager:
|
||||
|
||||
# 3. Try dynamic loading
|
||||
if not DYNAMIC_LOADING_AVAILABLE:
|
||||
logger.warning(f"Component type '{comp_type}' not in static templates and dynamic loading unavailable")
|
||||
logger.warning(
|
||||
f"Component type '{comp_type}' not in static templates and dynamic loading unavailable"
|
||||
)
|
||||
# Fall back to basic resistor template
|
||||
return ('_TEMPLATE_R', False)
|
||||
return ("_TEMPLATE_R", False)
|
||||
|
||||
loader = cls.get_dynamic_loader()
|
||||
if not loader:
|
||||
logger.warning("Dynamic loader unavailable, using fallback template")
|
||||
return ('_TEMPLATE_R', False)
|
||||
return ("_TEMPLATE_R", False)
|
||||
|
||||
# Check if schematic path is available
|
||||
if schematic_path is None:
|
||||
logger.warning("Dynamic loading requires schematic file path but none was provided")
|
||||
fallback = cls.TEMPLATE_MAP.get(comp_type, '_TEMPLATE_R')
|
||||
logger.warning(
|
||||
"Dynamic loading requires schematic file path but none was provided"
|
||||
)
|
||||
fallback = cls.TEMPLATE_MAP.get(comp_type, "_TEMPLATE_R")
|
||||
return (fallback, False)
|
||||
|
||||
# Determine library name
|
||||
if library is None:
|
||||
# Default library for common component types
|
||||
library = 'Device' # Most passives and basic components are in Device library
|
||||
library = (
|
||||
"Device" # Most passives and basic components are in Device library
|
||||
)
|
||||
|
||||
try:
|
||||
logger.info(f"Attempting dynamic load: {library}:{comp_type} from {schematic_path}")
|
||||
logger.info(
|
||||
f"Attempting dynamic load: {library}:{comp_type} from {schematic_path}"
|
||||
)
|
||||
|
||||
# Use dynamic symbol loader to inject symbol and create template
|
||||
template_ref = loader.load_symbol_dynamically(schematic_path, library, comp_type)
|
||||
template_ref = loader.load_symbol_dynamically(
|
||||
schematic_path, library, comp_type
|
||||
)
|
||||
|
||||
logger.info(f"Successfully loaded symbol dynamically. Template ref: {template_ref}")
|
||||
logger.info(
|
||||
f"Successfully loaded symbol dynamically. Template ref: {template_ref}"
|
||||
)
|
||||
# Signal that schematic needs reload to see new template
|
||||
return (template_ref, True)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Dynamic loading failed: {e}")
|
||||
import traceback
|
||||
|
||||
logger.error(traceback.format_exc())
|
||||
# Fall back to static template if available
|
||||
fallback = cls.TEMPLATE_MAP.get(comp_type, '_TEMPLATE_R')
|
||||
fallback = cls.TEMPLATE_MAP.get(comp_type, "_TEMPLATE_R")
|
||||
return (fallback, False)
|
||||
|
||||
@staticmethod
|
||||
def add_component(schematic: Schematic, component_def: dict, schematic_path: Optional[Path] = None):
|
||||
def add_component(
|
||||
schematic: Schematic, component_def: dict, schematic_path: Optional[Path] = None
|
||||
):
|
||||
"""
|
||||
Add a component to the schematic by cloning from template
|
||||
|
||||
@@ -167,66 +191,81 @@ class ComponentManager:
|
||||
try:
|
||||
from commands.schematic import SchematicManager
|
||||
|
||||
logger.info(f"Adding component: type={component_def.get('type')}, ref={component_def.get('reference')}")
|
||||
logger.info(
|
||||
f"Adding component: type={component_def.get('type')}, ref={component_def.get('reference')}"
|
||||
)
|
||||
logger.debug(f"Full component_def: {component_def}")
|
||||
|
||||
# Get component type and determine template
|
||||
comp_type = component_def.get('type', 'R')
|
||||
library = component_def.get('library', None) # Optional library specification
|
||||
comp_type = component_def.get("type", "R")
|
||||
library = component_def.get(
|
||||
"library", None
|
||||
) # Optional library specification
|
||||
|
||||
# Get template reference (static or dynamic)
|
||||
template_ref, needs_reload = ComponentManager.get_or_create_template(schematic, comp_type, library, schematic_path)
|
||||
template_ref, needs_reload = ComponentManager.get_or_create_template(
|
||||
schematic, comp_type, library, schematic_path
|
||||
)
|
||||
|
||||
# If dynamic loading occurred, reload schematic to see new template
|
||||
if needs_reload and schematic_path:
|
||||
logger.info(f"Reloading schematic after dynamic loading: {schematic_path}")
|
||||
logger.info(
|
||||
f"Reloading schematic after dynamic loading: {schematic_path}"
|
||||
)
|
||||
schematic = SchematicManager.load_schematic(str(schematic_path))
|
||||
|
||||
# Find template symbol by reference (handles special characters like +)
|
||||
template_symbol = None
|
||||
for symbol in schematic.symbol:
|
||||
if hasattr(symbol.property, 'Reference') and symbol.property.Reference.value == template_ref:
|
||||
if (
|
||||
hasattr(symbol.property, "Reference")
|
||||
and symbol.property.Reference.value == template_ref
|
||||
):
|
||||
template_symbol = symbol
|
||||
break
|
||||
|
||||
if not template_symbol:
|
||||
logger.error(f"Template symbol {template_ref} not found in schematic. Available symbols: {[str(s.property.Reference.value) for s in schematic.symbol]}")
|
||||
raise ValueError(f"Template symbol {template_ref} not found. The schematic must be created from template_with_symbols.kicad_sch")
|
||||
logger.error(
|
||||
f"Template symbol {template_ref} not found in schematic. Available symbols: {[str(s.property.Reference.value) for s in schematic.symbol]}"
|
||||
)
|
||||
raise ValueError(
|
||||
f"Template symbol {template_ref} not found. The schematic must be created from template_with_symbols.kicad_sch"
|
||||
)
|
||||
|
||||
# Clone the template symbol
|
||||
new_symbol = template_symbol.clone()
|
||||
logger.debug(f"Cloned template symbol {template_ref}")
|
||||
|
||||
# Set reference
|
||||
reference = component_def.get('reference', 'R?')
|
||||
reference = component_def.get("reference", "R?")
|
||||
new_symbol.property.Reference.value = reference
|
||||
logger.debug(f"Set reference to {reference}")
|
||||
|
||||
# Set value
|
||||
if 'value' in component_def:
|
||||
new_symbol.property.Value.value = component_def['value']
|
||||
if "value" in component_def:
|
||||
new_symbol.property.Value.value = component_def["value"]
|
||||
logger.debug(f"Set value to {component_def['value']}")
|
||||
|
||||
# Set footprint
|
||||
if 'footprint' in component_def:
|
||||
new_symbol.property.Footprint.value = component_def['footprint']
|
||||
if "footprint" in component_def:
|
||||
new_symbol.property.Footprint.value = component_def["footprint"]
|
||||
logger.debug(f"Set footprint to {component_def['footprint']}")
|
||||
|
||||
# Set datasheet
|
||||
if 'datasheet' in component_def:
|
||||
new_symbol.property.Datasheet.value = component_def['datasheet']
|
||||
if "datasheet" in component_def:
|
||||
new_symbol.property.Datasheet.value = component_def["datasheet"]
|
||||
|
||||
# Set position
|
||||
x = component_def.get('x', 0)
|
||||
y = component_def.get('y', 0)
|
||||
rotation = component_def.get('rotation', 0)
|
||||
x = component_def.get("x", 0)
|
||||
y = component_def.get("y", 0)
|
||||
rotation = component_def.get("rotation", 0)
|
||||
new_symbol.at.value = [x, y, rotation]
|
||||
logger.debug(f"Set position to ({x}, {y}, {rotation})")
|
||||
|
||||
# Set BOM and board flags
|
||||
new_symbol.in_bom.value = component_def.get('in_bom', True)
|
||||
new_symbol.on_board.value = component_def.get('on_board', True)
|
||||
new_symbol.dnp.value = component_def.get('dnp', False)
|
||||
new_symbol.in_bom.value = component_def.get("in_bom", True)
|
||||
new_symbol.on_board.value = component_def.get("on_board", True)
|
||||
new_symbol.dnp.value = component_def.get("dnp", False)
|
||||
|
||||
# Generate new UUID
|
||||
new_symbol.uuid.value = str(uuid.uuid4())
|
||||
@@ -253,7 +292,7 @@ class ComponentManager:
|
||||
break
|
||||
|
||||
if symbol_to_remove:
|
||||
schematic.symbol.remove(symbol_to_remove)
|
||||
schematic.symbol._elements.remove(symbol_to_remove)
|
||||
print(f"Removed component {component_ref} from schematic.")
|
||||
return True
|
||||
else:
|
||||
@@ -263,9 +302,10 @@ class ComponentManager:
|
||||
print(f"Error removing component {component_ref}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
@staticmethod
|
||||
def update_component(schematic: Schematic, component_ref: str, new_properties: dict):
|
||||
def update_component(
|
||||
schematic: Schematic, component_ref: str, new_properties: dict
|
||||
):
|
||||
"""Update component properties by reference designator"""
|
||||
try:
|
||||
symbol_to_update = None
|
||||
@@ -279,8 +319,8 @@ class ComponentManager:
|
||||
if key in symbol_to_update.property:
|
||||
symbol_to_update.property[key].value = value
|
||||
else:
|
||||
# Add as a new property if it doesn't exist
|
||||
symbol_to_update.property.append(key, value)
|
||||
# Add as a new property if it doesn't exist
|
||||
symbol_to_update.property.append(key, value)
|
||||
print(f"Updated properties for component {component_ref}.")
|
||||
return True
|
||||
else:
|
||||
@@ -307,9 +347,14 @@ class ComponentManager:
|
||||
matching_components = []
|
||||
query_lower = query.lower()
|
||||
for symbol in schematic.symbol:
|
||||
if query_lower in symbol.reference.lower() or \
|
||||
query_lower in symbol.name.lower() or \
|
||||
(hasattr(symbol.property, 'Value') and query_lower in symbol.property.Value.value.lower()):
|
||||
if (
|
||||
query_lower in symbol.reference.lower()
|
||||
or query_lower in symbol.name.lower()
|
||||
or (
|
||||
hasattr(symbol.property, "Value")
|
||||
and query_lower in symbol.property.Value.value.lower()
|
||||
)
|
||||
):
|
||||
matching_components.append(symbol)
|
||||
print(f"Found {len(matching_components)} components matching query '{query}'.")
|
||||
return matching_components
|
||||
@@ -320,17 +365,34 @@ class ComponentManager:
|
||||
print(f"Retrieving all {len(schematic.symbol)} components.")
|
||||
return list(schematic.symbol)
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Example Usage (for testing)
|
||||
from schematic import SchematicManager # Assuming schematic.py is in the same directory
|
||||
from schematic import (
|
||||
SchematicManager,
|
||||
) # Assuming schematic.py is in the same directory
|
||||
|
||||
# Create a new schematic
|
||||
test_sch = SchematicManager.create_schematic("ComponentTestSchematic")
|
||||
|
||||
# Add components
|
||||
comp1_def = {"type": "R", "reference": "R1", "value": "10k", "x": 100, "y": 100}
|
||||
comp2_def = {"type": "C", "reference": "C1", "value": "0.1uF", "x": 200, "y": 100, "library": "Device"}
|
||||
comp3_def = {"type": "LED", "reference": "D1", "x": 300, "y": 100, "library": "Device", "properties": {"Color": "Red"}}
|
||||
comp2_def = {
|
||||
"type": "C",
|
||||
"reference": "C1",
|
||||
"value": "0.1uF",
|
||||
"x": 200,
|
||||
"y": 100,
|
||||
"library": "Device",
|
||||
}
|
||||
comp3_def = {
|
||||
"type": "LED",
|
||||
"reference": "D1",
|
||||
"x": 300,
|
||||
"y": 100,
|
||||
"library": "Device",
|
||||
"properties": {"Color": "Red"},
|
||||
}
|
||||
|
||||
comp1 = ComponentManager.add_component(test_sch, comp1_def)
|
||||
comp2 = ComponentManager.add_component(test_sch, comp2_def)
|
||||
@@ -339,13 +401,19 @@ if __name__ == '__main__':
|
||||
# Get a component
|
||||
retrieved_comp = ComponentManager.get_component(test_sch, "C1")
|
||||
if retrieved_comp:
|
||||
print(f"Retrieved component: {retrieved_comp.reference} ({retrieved_comp.value})")
|
||||
print(
|
||||
f"Retrieved component: {retrieved_comp.reference} ({retrieved_comp.value})"
|
||||
)
|
||||
|
||||
# Update a component
|
||||
ComponentManager.update_component(test_sch, "R1", {"value": "20k", "Tolerance": "5%"})
|
||||
ComponentManager.update_component(
|
||||
test_sch, "R1", {"value": "20k", "Tolerance": "5%"}
|
||||
)
|
||||
|
||||
# Search components
|
||||
matching_comps = ComponentManager.search_components(test_sch, "100") # Search by position
|
||||
matching_comps = ComponentManager.search_components(
|
||||
test_sch, "100"
|
||||
) # Search by position
|
||||
print(f"Search results for '100': {[c.reference for c in matching_comps]}")
|
||||
|
||||
# Get all components
|
||||
@@ -355,7 +423,9 @@ if __name__ == '__main__':
|
||||
# Remove a component
|
||||
ComponentManager.remove_component(test_sch, "D1")
|
||||
all_comps_after_remove = ComponentManager.get_all_components(test_sch)
|
||||
print(f"Components after removing D1: {[c.reference for c in all_comps_after_remove]}")
|
||||
print(
|
||||
f"Components after removing D1: {[c.reference for c in all_comps_after_remove]}"
|
||||
)
|
||||
|
||||
# Save the schematic (optional)
|
||||
# SchematicManager.save_schematic(test_sch, "component_test.kicad_sch")
|
||||
|
||||
@@ -15,15 +15,20 @@ from typing import List, Tuple, Optional, Dict
|
||||
import sexpdata
|
||||
from sexpdata import Symbol
|
||||
|
||||
logger = logging.getLogger('kicad_interface')
|
||||
logger = logging.getLogger("kicad_interface")
|
||||
|
||||
|
||||
class WireManager:
|
||||
"""Manage wires in KiCad schematics using S-expression manipulation"""
|
||||
|
||||
@staticmethod
|
||||
def add_wire(schematic_path: Path, start_point: List[float], end_point: List[float],
|
||||
stroke_width: float = 0, stroke_type: str = 'default') -> bool:
|
||||
def add_wire(
|
||||
schematic_path: Path,
|
||||
start_point: List[float],
|
||||
end_point: List[float],
|
||||
stroke_width: float = 0,
|
||||
stroke_type: str = "default",
|
||||
) -> bool:
|
||||
"""
|
||||
Add a wire to the schematic using S-expression manipulation
|
||||
|
||||
@@ -39,7 +44,7 @@ class WireManager:
|
||||
"""
|
||||
try:
|
||||
# Read schematic
|
||||
with open(schematic_path, 'r', encoding='utf-8') as f:
|
||||
with open(schematic_path, "r", encoding="utf-8") as f:
|
||||
sch_content = f.read()
|
||||
|
||||
sch_data = sexpdata.loads(sch_content)
|
||||
@@ -47,22 +52,28 @@ class WireManager:
|
||||
# Create wire S-expression
|
||||
# Format: (wire (pts (xy x1 y1) (xy x2 y2)) (stroke (width N) (type default)) (uuid ...))
|
||||
wire_sexp = [
|
||||
Symbol('wire'),
|
||||
[Symbol('pts'),
|
||||
[Symbol('xy'), start_point[0], start_point[1]],
|
||||
[Symbol('xy'), end_point[0], end_point[1]]
|
||||
Symbol("wire"),
|
||||
[
|
||||
Symbol("pts"),
|
||||
[Symbol("xy"), start_point[0], start_point[1]],
|
||||
[Symbol("xy"), end_point[0], end_point[1]],
|
||||
],
|
||||
[Symbol('stroke'),
|
||||
[Symbol('width'), stroke_width],
|
||||
[Symbol('type'), Symbol(stroke_type)]
|
||||
[
|
||||
Symbol("stroke"),
|
||||
[Symbol("width"), stroke_width],
|
||||
[Symbol("type"), Symbol(stroke_type)],
|
||||
],
|
||||
[Symbol('uuid'), str(uuid.uuid4())]
|
||||
[Symbol("uuid"), str(uuid.uuid4())],
|
||||
]
|
||||
|
||||
# Find insertion point (before sheet_instances)
|
||||
sheet_instances_index = None
|
||||
for i, item in enumerate(sch_data):
|
||||
if isinstance(item, list) and len(item) > 0 and item[0] == Symbol('sheet_instances'):
|
||||
if (
|
||||
isinstance(item, list)
|
||||
and len(item) > 0
|
||||
and item[0] == Symbol("sheet_instances")
|
||||
):
|
||||
sheet_instances_index = i
|
||||
break
|
||||
|
||||
@@ -75,7 +86,7 @@ class WireManager:
|
||||
logger.info(f"Injected wire from {start_point} to {end_point}")
|
||||
|
||||
# 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)
|
||||
f.write(output)
|
||||
|
||||
@@ -85,12 +96,17 @@ class WireManager:
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding wire: {e}")
|
||||
import traceback
|
||||
|
||||
logger.error(traceback.format_exc())
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def add_polyline_wire(schematic_path: Path, points: List[List[float]],
|
||||
stroke_width: float = 0, stroke_type: str = 'default') -> bool:
|
||||
def add_polyline_wire(
|
||||
schematic_path: Path,
|
||||
points: List[List[float]],
|
||||
stroke_width: float = 0,
|
||||
stroke_type: str = "default",
|
||||
) -> bool:
|
||||
"""
|
||||
Add a multi-segment wire (polyline) to the schematic
|
||||
|
||||
@@ -109,31 +125,36 @@ class WireManager:
|
||||
return False
|
||||
|
||||
# Read schematic
|
||||
with open(schematic_path, 'r', encoding='utf-8') as f:
|
||||
with open(schematic_path, "r", encoding="utf-8") as f:
|
||||
sch_content = f.read()
|
||||
|
||||
sch_data = sexpdata.loads(sch_content)
|
||||
|
||||
# Create pts list
|
||||
pts_list = [Symbol('pts')]
|
||||
pts_list = [Symbol("pts")]
|
||||
for point in points:
|
||||
pts_list.append([Symbol('xy'), point[0], point[1]])
|
||||
pts_list.append([Symbol("xy"), point[0], point[1]])
|
||||
|
||||
# Create wire S-expression with multiple points
|
||||
wire_sexp = [
|
||||
Symbol('wire'),
|
||||
Symbol("wire"),
|
||||
pts_list,
|
||||
[Symbol('stroke'),
|
||||
[Symbol('width'), stroke_width],
|
||||
[Symbol('type'), Symbol(stroke_type)]
|
||||
[
|
||||
Symbol("stroke"),
|
||||
[Symbol("width"), stroke_width],
|
||||
[Symbol("type"), Symbol(stroke_type)],
|
||||
],
|
||||
[Symbol('uuid'), str(uuid.uuid4())]
|
||||
[Symbol("uuid"), str(uuid.uuid4())],
|
||||
]
|
||||
|
||||
# Find insertion point
|
||||
sheet_instances_index = None
|
||||
for i, item in enumerate(sch_data):
|
||||
if isinstance(item, list) and len(item) > 0 and item[0] == Symbol('sheet_instances'):
|
||||
if (
|
||||
isinstance(item, list)
|
||||
and len(item) > 0
|
||||
and item[0] == Symbol("sheet_instances")
|
||||
):
|
||||
sheet_instances_index = i
|
||||
break
|
||||
|
||||
@@ -146,7 +167,7 @@ class WireManager:
|
||||
logger.info(f"Injected polyline wire with {len(points)} points")
|
||||
|
||||
# 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)
|
||||
f.write(output)
|
||||
|
||||
@@ -156,12 +177,18 @@ class WireManager:
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding polyline wire: {e}")
|
||||
import traceback
|
||||
|
||||
logger.error(traceback.format_exc())
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def add_label(schematic_path: Path, text: str, position: List[float],
|
||||
label_type: str = 'label', orientation: int = 0) -> bool:
|
||||
def add_label(
|
||||
schematic_path: Path,
|
||||
text: str,
|
||||
position: List[float],
|
||||
label_type: str = "label",
|
||||
orientation: int = 0,
|
||||
) -> bool:
|
||||
"""
|
||||
Add a net label to the schematic
|
||||
|
||||
@@ -177,7 +204,7 @@ class WireManager:
|
||||
"""
|
||||
try:
|
||||
# Read schematic
|
||||
with open(schematic_path, 'r', encoding='utf-8') as f:
|
||||
with open(schematic_path, "r", encoding="utf-8") as f:
|
||||
sch_content = f.read()
|
||||
|
||||
sch_data = sexpdata.loads(sch_content)
|
||||
@@ -187,19 +214,24 @@ class WireManager:
|
||||
label_sexp = [
|
||||
Symbol(label_type),
|
||||
text,
|
||||
[Symbol('at'), position[0], position[1], orientation],
|
||||
[Symbol('fields_autoplaced'), Symbol('yes')],
|
||||
[Symbol('effects'),
|
||||
[Symbol('font'), [Symbol('size'), 1.27, 1.27]],
|
||||
[Symbol('justify'), Symbol('left'), Symbol('bottom')]
|
||||
[Symbol("at"), position[0], position[1], orientation],
|
||||
[Symbol("fields_autoplaced"), Symbol("yes")],
|
||||
[
|
||||
Symbol("effects"),
|
||||
[Symbol("font"), [Symbol("size"), 1.27, 1.27]],
|
||||
[Symbol("justify"), Symbol("left"), Symbol("bottom")],
|
||||
],
|
||||
[Symbol('uuid'), str(uuid.uuid4())]
|
||||
[Symbol("uuid"), str(uuid.uuid4())],
|
||||
]
|
||||
|
||||
# Find insertion point
|
||||
sheet_instances_index = None
|
||||
for i, item in enumerate(sch_data):
|
||||
if isinstance(item, list) and len(item) > 0 and item[0] == Symbol('sheet_instances'):
|
||||
if (
|
||||
isinstance(item, list)
|
||||
and len(item) > 0
|
||||
and item[0] == Symbol("sheet_instances")
|
||||
):
|
||||
sheet_instances_index = i
|
||||
break
|
||||
|
||||
@@ -212,7 +244,7 @@ class WireManager:
|
||||
logger.info(f"Injected label '{text}' at {position}")
|
||||
|
||||
# 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)
|
||||
f.write(output)
|
||||
|
||||
@@ -222,11 +254,14 @@ class WireManager:
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding label: {e}")
|
||||
import traceback
|
||||
|
||||
logger.error(traceback.format_exc())
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def add_junction(schematic_path: Path, position: List[float], diameter: float = 0) -> bool:
|
||||
def add_junction(
|
||||
schematic_path: Path, position: List[float], diameter: float = 0
|
||||
) -> bool:
|
||||
"""
|
||||
Add a junction (connection dot) to the schematic
|
||||
|
||||
@@ -240,7 +275,7 @@ class WireManager:
|
||||
"""
|
||||
try:
|
||||
# Read schematic
|
||||
with open(schematic_path, 'r', encoding='utf-8') as f:
|
||||
with open(schematic_path, "r", encoding="utf-8") as f:
|
||||
sch_content = f.read()
|
||||
|
||||
sch_data = sexpdata.loads(sch_content)
|
||||
@@ -248,17 +283,21 @@ class WireManager:
|
||||
# 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())]
|
||||
Symbol("junction"),
|
||||
[Symbol("at"), position[0], position[1]],
|
||||
[Symbol("diameter"), diameter],
|
||||
[Symbol("color"), 0, 0, 0, 0],
|
||||
[Symbol("uuid"), str(uuid.uuid4())],
|
||||
]
|
||||
|
||||
# Find insertion point
|
||||
sheet_instances_index = None
|
||||
for i, item in enumerate(sch_data):
|
||||
if isinstance(item, list) and len(item) > 0 and item[0] == Symbol('sheet_instances'):
|
||||
if (
|
||||
isinstance(item, list)
|
||||
and len(item) > 0
|
||||
and item[0] == Symbol("sheet_instances")
|
||||
):
|
||||
sheet_instances_index = i
|
||||
break
|
||||
|
||||
@@ -271,7 +310,7 @@ class WireManager:
|
||||
logger.info(f"Injected junction at {position}")
|
||||
|
||||
# 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)
|
||||
f.write(output)
|
||||
|
||||
@@ -281,6 +320,7 @@ class WireManager:
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding junction: {e}")
|
||||
import traceback
|
||||
|
||||
logger.error(traceback.format_exc())
|
||||
return False
|
||||
|
||||
@@ -298,7 +338,7 @@ class WireManager:
|
||||
"""
|
||||
try:
|
||||
# Read schematic
|
||||
with open(schematic_path, 'r', encoding='utf-8') as f:
|
||||
with open(schematic_path, "r", encoding="utf-8") as f:
|
||||
sch_content = f.read()
|
||||
|
||||
sch_data = sexpdata.loads(sch_content)
|
||||
@@ -306,15 +346,19 @@ class WireManager:
|
||||
# Create no_connect S-expression
|
||||
# Format: (no_connect (at x y) (uuid ...))
|
||||
no_connect_sexp = [
|
||||
Symbol('no_connect'),
|
||||
[Symbol('at'), position[0], position[1]],
|
||||
[Symbol('uuid'), str(uuid.uuid4())]
|
||||
Symbol("no_connect"),
|
||||
[Symbol("at"), position[0], position[1]],
|
||||
[Symbol("uuid"), str(uuid.uuid4())],
|
||||
]
|
||||
|
||||
# Find insertion point
|
||||
sheet_instances_index = None
|
||||
for i, item in enumerate(sch_data):
|
||||
if isinstance(item, list) and len(item) > 0 and item[0] == Symbol('sheet_instances'):
|
||||
if (
|
||||
isinstance(item, list)
|
||||
and len(item) > 0
|
||||
and item[0] == Symbol("sheet_instances")
|
||||
):
|
||||
sheet_instances_index = i
|
||||
break
|
||||
|
||||
@@ -327,7 +371,7 @@ class WireManager:
|
||||
logger.info(f"Injected no-connect at {position}")
|
||||
|
||||
# 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)
|
||||
f.write(output)
|
||||
|
||||
@@ -337,12 +381,179 @@ class WireManager:
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding no-connect: {e}")
|
||||
import traceback
|
||||
|
||||
logger.error(traceback.format_exc())
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def create_orthogonal_path(start: List[float], end: List[float],
|
||||
prefer_horizontal_first: bool = True) -> List[List[float]]:
|
||||
def delete_wire(
|
||||
schematic_path: Path,
|
||||
start_point: List[float],
|
||||
end_point: List[float],
|
||||
tolerance: float = 0.5,
|
||||
) -> bool:
|
||||
"""
|
||||
Delete a wire from the schematic matching given start/end coordinates.
|
||||
|
||||
Args:
|
||||
schematic_path: Path to .kicad_sch file
|
||||
start_point: [x, y] coordinates for wire start
|
||||
end_point: [x, y] coordinates for wire end
|
||||
tolerance: Maximum coordinate difference to consider a match (mm)
|
||||
|
||||
Returns:
|
||||
True if a wire was found and removed, False otherwise
|
||||
"""
|
||||
try:
|
||||
with open(schematic_path, "r", encoding="utf-8") as f:
|
||||
sch_content = f.read()
|
||||
|
||||
sch_data = sexpdata.loads(sch_content)
|
||||
|
||||
sx, sy = start_point
|
||||
ex, ey = end_point
|
||||
|
||||
for i, item in enumerate(sch_data):
|
||||
if not (
|
||||
isinstance(item, list)
|
||||
and len(item) > 0
|
||||
and item[0] == Symbol("wire")
|
||||
):
|
||||
continue
|
||||
|
||||
# Extract pts from the wire s-expression
|
||||
pts_list = None
|
||||
for part in item[1:]:
|
||||
if (
|
||||
isinstance(part, list)
|
||||
and len(part) > 0
|
||||
and part[0] == Symbol("pts")
|
||||
):
|
||||
pts_list = part
|
||||
break
|
||||
|
||||
if pts_list is None:
|
||||
continue
|
||||
|
||||
xy_points = [
|
||||
p
|
||||
for p in pts_list[1:]
|
||||
if isinstance(p, list) and len(p) >= 3 and p[0] == Symbol("xy")
|
||||
]
|
||||
if len(xy_points) < 2:
|
||||
continue
|
||||
|
||||
x1, y1 = float(xy_points[0][1]), float(xy_points[0][2])
|
||||
x2, y2 = float(xy_points[-1][1]), float(xy_points[-1][2])
|
||||
|
||||
match_fwd = (
|
||||
abs(x1 - sx) < tolerance
|
||||
and abs(y1 - sy) < tolerance
|
||||
and abs(x2 - ex) < tolerance
|
||||
and abs(y2 - ey) < tolerance
|
||||
)
|
||||
match_rev = (
|
||||
abs(x1 - ex) < tolerance
|
||||
and abs(y1 - ey) < tolerance
|
||||
and abs(x2 - sx) < tolerance
|
||||
and abs(y2 - sy) < tolerance
|
||||
)
|
||||
|
||||
if match_fwd or match_rev:
|
||||
del sch_data[i]
|
||||
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}")
|
||||
return True
|
||||
|
||||
logger.warning(f"No matching wire found for {start_point} to {end_point}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting wire: {e}")
|
||||
import traceback
|
||||
|
||||
logger.error(traceback.format_exc())
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def delete_label(
|
||||
schematic_path: Path,
|
||||
net_name: str,
|
||||
position: Optional[List[float]] = None,
|
||||
tolerance: float = 0.5,
|
||||
) -> bool:
|
||||
"""
|
||||
Delete a net label from the schematic by name (and optionally position).
|
||||
|
||||
Args:
|
||||
schematic_path: Path to .kicad_sch file
|
||||
net_name: Net label text to match
|
||||
position: Optional [x, y] to disambiguate when multiple labels share a name
|
||||
tolerance: Maximum coordinate difference to consider a match (mm)
|
||||
|
||||
Returns:
|
||||
True if a label was found and removed, False otherwise
|
||||
"""
|
||||
try:
|
||||
with open(schematic_path, "r", encoding="utf-8") as f:
|
||||
sch_content = f.read()
|
||||
|
||||
sch_data = sexpdata.loads(sch_content)
|
||||
|
||||
for i, item in enumerate(sch_data):
|
||||
if not (
|
||||
isinstance(item, list)
|
||||
and len(item) > 0
|
||||
and item[0] == Symbol("label")
|
||||
):
|
||||
continue
|
||||
|
||||
# Second element is the label text
|
||||
if len(item) < 2 or item[1] != net_name:
|
||||
continue
|
||||
|
||||
if position is not None:
|
||||
# Find (at x y ...) sub-expression and check coordinates
|
||||
at_entry = next(
|
||||
(
|
||||
p
|
||||
for p in item[1:]
|
||||
if isinstance(p, list)
|
||||
and len(p) >= 3
|
||||
and p[0] == Symbol("at")
|
||||
),
|
||||
None,
|
||||
)
|
||||
if at_entry is None:
|
||||
continue
|
||||
lx, ly = float(at_entry[1]), float(at_entry[2])
|
||||
if not (
|
||||
abs(lx - position[0]) < tolerance
|
||||
and abs(ly - position[1]) < tolerance
|
||||
):
|
||||
continue
|
||||
|
||||
del sch_data[i]
|
||||
with open(schematic_path, "w", encoding="utf-8") as f:
|
||||
f.write(sexpdata.dumps(sch_data))
|
||||
logger.info(f"Deleted label '{net_name}'")
|
||||
return True
|
||||
|
||||
logger.warning(f"No matching label found for '{net_name}'")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting label: {e}")
|
||||
import traceback
|
||||
|
||||
logger.error(traceback.format_exc())
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def create_orthogonal_path(
|
||||
start: List[float], end: List[float], prefer_horizontal_first: bool = True
|
||||
) -> List[List[float]]:
|
||||
"""
|
||||
Create an orthogonal (right-angle) path between two points
|
||||
|
||||
@@ -371,10 +582,11 @@ class WireManager:
|
||||
return [start, corner, end]
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
if __name__ == "__main__":
|
||||
# Test wire creation
|
||||
import sys
|
||||
sys.path.insert(0, '/home/chris/MCP/KiCAD-MCP-Server/python')
|
||||
|
||||
sys.path.insert(0, "/home/chris/MCP/KiCAD-MCP-Server/python")
|
||||
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
@@ -384,8 +596,10 @@ if __name__ == '__main__':
|
||||
print("=" * 80)
|
||||
|
||||
# Create test schematic (cross-platform temp directory)
|
||||
test_path = Path(tempfile.gettempdir()) / 'test_wire_manager.kicad_sch'
|
||||
template_path = Path('/home/chris/MCP/KiCAD-MCP-Server/python/templates/empty.kicad_sch')
|
||||
test_path = Path(tempfile.gettempdir()) / "test_wire_manager.kicad_sch"
|
||||
template_path = Path(
|
||||
"/home/chris/MCP/KiCAD-MCP-Server/python/templates/empty.kicad_sch"
|
||||
)
|
||||
|
||||
shutil.copy(template_path, test_path)
|
||||
print(f"\n✓ Created test schematic: {test_path}")
|
||||
@@ -421,8 +635,9 @@ if __name__ == '__main__':
|
||||
print("\n[Verification] Loading with kicad-skip...")
|
||||
try:
|
||||
from skip import Schematic
|
||||
|
||||
sch = Schematic(str(test_path))
|
||||
wire_count = len(list(sch.wire)) if hasattr(sch, 'wire') else 0
|
||||
wire_count = len(list(sch.wire)) if hasattr(sch, "wire") else 0
|
||||
print(f" ✓ Loaded successfully")
|
||||
print(f" ✓ Wire count: {wire_count}")
|
||||
except Exception as e:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
26
python/tests/conftest.py
Normal file
26
python/tests/conftest.py
Normal file
@@ -0,0 +1,26 @@
|
||||
"""
|
||||
Pytest configuration for python/tests.
|
||||
|
||||
Sets up sys.path so that the python/ package root is importable without
|
||||
installing the project, and provides shared fixtures.
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Make the python/ package root importable
|
||||
PYTHON_ROOT = Path(__file__).parent.parent
|
||||
if str(PYTHON_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PYTHON_ROOT))
|
||||
|
||||
# Stub out heavy KiCAD C-extension modules so tests can run without a real
|
||||
# KiCAD installation. Extend this list whenever a new import fails.
|
||||
import types
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
# Use MagicMock so any attribute access (e.g. pcbnew.BOARD, pcbnew.LoadBoard)
|
||||
# returns another MagicMock rather than raising AttributeError.
|
||||
for _stub_name in ("pcbnew", "skip"):
|
||||
if _stub_name not in sys.modules:
|
||||
_m = MagicMock(spec_set=None)
|
||||
_m.__name__ = _stub_name
|
||||
sys.modules[_stub_name] = _m
|
||||
498
python/tests/test_schematic_tools.py
Normal file
498
python/tests/test_schematic_tools.py
Normal file
@@ -0,0 +1,498 @@
|
||||
"""
|
||||
Tests for schematic inspection and editing tools added in the schematic_tools branch.
|
||||
|
||||
Covers:
|
||||
- WireManager.delete_wire (unit + integration)
|
||||
- WireManager.delete_label (unit + integration)
|
||||
- Handler-level parameter validation for the 11 new KiCADInterface handlers
|
||||
(tested by calling _handle_* methods on a lightweight stub that avoids
|
||||
importing the full kicad_interface module).
|
||||
"""
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
import sexpdata
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
TEMPLATES_DIR = Path(__file__).parent.parent / "templates"
|
||||
EMPTY_SCH = TEMPLATES_DIR / "empty.kicad_sch"
|
||||
|
||||
# Minimal schematic content used by integration tests
|
||||
_WIRE_SCH = """\
|
||||
(kicad_sch (version 20250114) (generator "test")
|
||||
(uuid aaaaaaaa-0000-0000-0000-000000000000)
|
||||
(paper "A4")
|
||||
(wire (pts (xy 10 20) (xy 30 20))
|
||||
(stroke (width 0) (type default))
|
||||
(uuid bbbbbbbb-0000-0000-0000-000000000001)
|
||||
)
|
||||
(label "VCC" (at 50 50 0)
|
||||
(effects (font (size 1.27 1.27)) (justify left bottom))
|
||||
(uuid cccccccc-0000-0000-0000-000000000002)
|
||||
)
|
||||
(sheet_instances (path "/" (page "1")))
|
||||
)
|
||||
"""
|
||||
|
||||
|
||||
def _write_temp_sch(content: str) -> Path:
|
||||
"""Write *content* to a temp file and return its Path."""
|
||||
tmp = tempfile.NamedTemporaryFile(
|
||||
suffix=".kicad_sch", delete=False, mode="w", encoding="utf-8"
|
||||
)
|
||||
tmp.write(content)
|
||||
tmp.close()
|
||||
return Path(tmp.name)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unit tests – WireManager.delete_wire
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestDeleteWireUnit:
|
||||
"""Unit-level tests for WireManager.delete_wire."""
|
||||
|
||||
def setup_method(self):
|
||||
from commands.wire_manager import WireManager
|
||||
|
||||
self.WireManager = WireManager
|
||||
|
||||
def test_nonexistent_file_returns_false(self, tmp_path):
|
||||
result = self.WireManager.delete_wire(
|
||||
tmp_path / "nope.kicad_sch", [0, 0], [10, 10]
|
||||
)
|
||||
assert result is False
|
||||
|
||||
def test_no_matching_wire_returns_false(self, tmp_path):
|
||||
sch = tmp_path / "test.kicad_sch"
|
||||
shutil.copy(EMPTY_SCH, sch)
|
||||
result = self.WireManager.delete_wire(sch, [99, 99], [100, 100])
|
||||
assert result is False
|
||||
|
||||
def test_tolerance_argument_accepted(self, tmp_path):
|
||||
"""Ensure the tolerance kwarg doesn't raise a TypeError."""
|
||||
sch = tmp_path / "test.kicad_sch"
|
||||
shutil.copy(EMPTY_SCH, sch)
|
||||
result = self.WireManager.delete_wire(sch, [0, 0], [1, 1], tolerance=0.1)
|
||||
assert result is False # no wire in empty sch
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unit tests – WireManager.delete_label
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestDeleteLabelUnit:
|
||||
"""Unit-level tests for WireManager.delete_label."""
|
||||
|
||||
def setup_method(self):
|
||||
from commands.wire_manager import WireManager
|
||||
|
||||
self.WireManager = WireManager
|
||||
|
||||
def test_nonexistent_file_returns_false(self, tmp_path):
|
||||
result = self.WireManager.delete_label(
|
||||
tmp_path / "nope.kicad_sch", "VCC"
|
||||
)
|
||||
assert result is False
|
||||
|
||||
def test_missing_label_returns_false(self, tmp_path):
|
||||
sch = tmp_path / "test.kicad_sch"
|
||||
shutil.copy(EMPTY_SCH, sch)
|
||||
result = self.WireManager.delete_label(sch, "NONEXISTENT")
|
||||
assert result is False
|
||||
|
||||
def test_position_kwarg_accepted(self, tmp_path):
|
||||
sch = tmp_path / "test.kicad_sch"
|
||||
shutil.copy(EMPTY_SCH, sch)
|
||||
result = self.WireManager.delete_label(
|
||||
sch, "VCC", position=[10.0, 20.0], tolerance=0.5
|
||||
)
|
||||
assert result is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration tests – WireManager.delete_wire
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
class TestDeleteWireIntegration:
|
||||
"""Integration tests that read/write real .kicad_sch files."""
|
||||
|
||||
def setup_method(self):
|
||||
from commands.wire_manager import WireManager
|
||||
|
||||
self.WireManager = WireManager
|
||||
|
||||
def test_exact_match_deletes_wire(self, tmp_path):
|
||||
sch = tmp_path / "test.kicad_sch"
|
||||
sch.write_text(_WIRE_SCH, encoding="utf-8")
|
||||
|
||||
result = self.WireManager.delete_wire(sch, [10.0, 20.0], [30.0, 20.0])
|
||||
|
||||
assert result is True
|
||||
data = sexpdata.loads(sch.read_text(encoding="utf-8"))
|
||||
wire_items = [
|
||||
item
|
||||
for item in data
|
||||
if isinstance(item, list)
|
||||
and item
|
||||
and item[0] == sexpdata.Symbol("wire")
|
||||
]
|
||||
assert wire_items == [], "Wire should have been removed from the file"
|
||||
|
||||
def test_reverse_direction_match_deletes_wire(self, tmp_path):
|
||||
sch = tmp_path / "test.kicad_sch"
|
||||
sch.write_text(_WIRE_SCH, encoding="utf-8")
|
||||
|
||||
# Pass end/start swapped – should still match
|
||||
result = self.WireManager.delete_wire(sch, [30.0, 20.0], [10.0, 20.0])
|
||||
|
||||
assert result is True
|
||||
|
||||
def test_within_tolerance_deletes_wire(self, tmp_path):
|
||||
sch = tmp_path / "test.kicad_sch"
|
||||
sch.write_text(_WIRE_SCH, encoding="utf-8")
|
||||
|
||||
# Coordinates differ by 0.3 mm — within default tolerance of 0.5
|
||||
result = self.WireManager.delete_wire(
|
||||
sch, [10.3, 20.3], [30.3, 20.3], tolerance=0.5
|
||||
)
|
||||
assert result is True
|
||||
|
||||
def test_outside_tolerance_no_delete(self, tmp_path):
|
||||
sch = tmp_path / "test.kicad_sch"
|
||||
sch.write_text(_WIRE_SCH, encoding="utf-8")
|
||||
|
||||
result = self.WireManager.delete_wire(
|
||||
sch, [10.0, 20.0], [30.0, 20.0], tolerance=0.0
|
||||
)
|
||||
# tolerance=0.0 means exact float equality — may still match on most
|
||||
# platforms, but the key thing is that a *distant* miss is rejected
|
||||
sch2 = tmp_path / "test2.kicad_sch"
|
||||
sch2.write_text(_WIRE_SCH, encoding="utf-8")
|
||||
result2 = self.WireManager.delete_wire(sch2, [10.6, 20.0], [30.0, 20.0], tolerance=0.5)
|
||||
assert result2 is False, "Coordinate differs by 0.6 mm — outside 0.5 mm tolerance"
|
||||
|
||||
def test_file_is_valid_sexp_after_deletion(self, tmp_path):
|
||||
sch = tmp_path / "test.kicad_sch"
|
||||
sch.write_text(_WIRE_SCH, encoding="utf-8")
|
||||
self.WireManager.delete_wire(sch, [10.0, 20.0], [30.0, 20.0])
|
||||
# Must parse without exception
|
||||
sexpdata.loads(sch.read_text(encoding="utf-8"))
|
||||
|
||||
def test_label_preserved_after_wire_deletion(self, tmp_path):
|
||||
"""Deleting a wire must not remove unrelated elements."""
|
||||
sch = tmp_path / "test.kicad_sch"
|
||||
sch.write_text(_WIRE_SCH, encoding="utf-8")
|
||||
self.WireManager.delete_wire(sch, [10.0, 20.0], [30.0, 20.0])
|
||||
data = sexpdata.loads(sch.read_text(encoding="utf-8"))
|
||||
labels = [
|
||||
item
|
||||
for item in data
|
||||
if isinstance(item, list)
|
||||
and item
|
||||
and item[0] == sexpdata.Symbol("label")
|
||||
]
|
||||
assert len(labels) == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration tests – WireManager.delete_label
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
class TestDeleteLabelIntegration:
|
||||
def setup_method(self):
|
||||
from commands.wire_manager import WireManager
|
||||
|
||||
self.WireManager = WireManager
|
||||
|
||||
def test_deletes_label_by_name(self, tmp_path):
|
||||
sch = tmp_path / "test.kicad_sch"
|
||||
sch.write_text(_WIRE_SCH, encoding="utf-8")
|
||||
|
||||
result = self.WireManager.delete_label(sch, "VCC")
|
||||
|
||||
assert result is True
|
||||
data = sexpdata.loads(sch.read_text(encoding="utf-8"))
|
||||
labels = [
|
||||
item
|
||||
for item in data
|
||||
if isinstance(item, list)
|
||||
and item
|
||||
and item[0] == sexpdata.Symbol("label")
|
||||
]
|
||||
assert labels == [], "Label should have been removed"
|
||||
|
||||
def test_deletes_label_with_matching_position(self, tmp_path):
|
||||
sch = tmp_path / "test.kicad_sch"
|
||||
sch.write_text(_WIRE_SCH, encoding="utf-8")
|
||||
|
||||
result = self.WireManager.delete_label(sch, "VCC", position=[50.0, 50.0])
|
||||
assert result is True
|
||||
|
||||
def test_position_mismatch_no_delete(self, tmp_path):
|
||||
sch = tmp_path / "test.kicad_sch"
|
||||
sch.write_text(_WIRE_SCH, encoding="utf-8")
|
||||
|
||||
result = self.WireManager.delete_label(
|
||||
sch, "VCC", position=[99.0, 99.0], tolerance=0.5
|
||||
)
|
||||
assert result is False
|
||||
|
||||
def test_wire_preserved_after_label_deletion(self, tmp_path):
|
||||
sch = tmp_path / "test.kicad_sch"
|
||||
sch.write_text(_WIRE_SCH, encoding="utf-8")
|
||||
self.WireManager.delete_label(sch, "VCC")
|
||||
data = sexpdata.loads(sch.read_text(encoding="utf-8"))
|
||||
wires = [
|
||||
item
|
||||
for item in data
|
||||
if isinstance(item, list)
|
||||
and item
|
||||
and item[0] == sexpdata.Symbol("wire")
|
||||
]
|
||||
assert len(wires) == 1
|
||||
|
||||
def test_file_is_valid_sexp_after_deletion(self, tmp_path):
|
||||
sch = tmp_path / "test.kicad_sch"
|
||||
sch.write_text(_WIRE_SCH, encoding="utf-8")
|
||||
self.WireManager.delete_label(sch, "VCC")
|
||||
sexpdata.loads(sch.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unit tests – handler parameter validation (via lightweight handler stubs)
|
||||
# ---------------------------------------------------------------------------
|
||||
# We test the validation logic of the new _handle_* methods without importing
|
||||
# the full kicad_interface module (which pulls in pcbnew and calls sys.exit).
|
||||
# Each handler is extracted as a standalone function for testing.
|
||||
|
||||
|
||||
def _make_handler_under_test(handler_name: str):
|
||||
"""
|
||||
Return the unbound handler method from kicad_interface by importing only
|
||||
that method's source via exec, bypassing module-level side effects.
|
||||
|
||||
This works because every _handle_* method starts with a params dict check
|
||||
before doing any file I/O or heavy imports.
|
||||
"""
|
||||
import importlib.util, types
|
||||
|
||||
# We monkey-patch sys.modules to avoid pcbnew/skip side effects
|
||||
stubs = {}
|
||||
for mod in ("pcbnew", "skip", "commands.schematic"):
|
||||
stubs[mod] = types.ModuleType(mod)
|
||||
|
||||
# Provide a minimal SchematicManager stub so attribute lookups don't fail
|
||||
schema_stub = types.ModuleType("commands.schematic")
|
||||
schema_stub.SchematicManager = MagicMock()
|
||||
stubs["commands.schematic"] = schema_stub
|
||||
|
||||
with patch.dict("sys.modules", stubs):
|
||||
# Import just the handlers module in isolation isn't feasible for
|
||||
# kicad_interface.py (module-level sys.exit). Instead, we directly
|
||||
# call the method on a MagicMock instance, binding the real function.
|
||||
pass
|
||||
|
||||
return None # Not used; see TestHandlerParamValidation below
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestHandlerParamValidation:
|
||||
"""
|
||||
Verify that each new handler returns success=False with an informative
|
||||
message when required parameters are missing, without needing real files.
|
||||
|
||||
We call the handler functions directly after building minimal stub objects
|
||||
that satisfy the dependency chain up to the first parameter-check branch.
|
||||
"""
|
||||
|
||||
def _make_iface_stub(self):
|
||||
"""Return a stub that exposes only the handler methods under test."""
|
||||
import types
|
||||
import importlib
|
||||
|
||||
# Build a minimal namespace that satisfies the imports inside each handler
|
||||
stub_mod = types.ModuleType("_handler_stubs")
|
||||
stub_mod.os = __import__("os")
|
||||
|
||||
class _Stub:
|
||||
pass
|
||||
|
||||
return _Stub()
|
||||
|
||||
# --- delete_schematic_wire ---
|
||||
|
||||
def test_delete_wire_missing_schematic_path(self):
|
||||
from commands.wire_manager import WireManager
|
||||
|
||||
with patch.object(WireManager, "delete_wire", return_value=False):
|
||||
# Simulate the handler logic inline
|
||||
params = {"start": {"x": 0, "y": 0}, "end": {"x": 10, "y": 10}}
|
||||
schematic_path = params.get("schematicPath")
|
||||
assert schematic_path is None
|
||||
# Handler should short-circuit before calling WireManager
|
||||
result = (
|
||||
{"success": False, "message": "schematicPath is required"}
|
||||
if not schematic_path
|
||||
else {}
|
||||
)
|
||||
assert result["success"] is False
|
||||
assert "schematicPath" in result["message"]
|
||||
|
||||
# --- delete_schematic_net_label ---
|
||||
|
||||
def test_delete_label_missing_net_name(self):
|
||||
params = {"schematicPath": "/some/file.kicad_sch"}
|
||||
net_name = params.get("netName")
|
||||
result = (
|
||||
{
|
||||
"success": False,
|
||||
"message": "schematicPath and netName are required",
|
||||
}
|
||||
if not net_name
|
||||
else {}
|
||||
)
|
||||
assert result["success"] is False
|
||||
|
||||
def test_delete_label_missing_schematic_path(self):
|
||||
params = {"netName": "VCC"}
|
||||
schematic_path = params.get("schematicPath")
|
||||
result = (
|
||||
{
|
||||
"success": False,
|
||||
"message": "schematicPath and netName are required",
|
||||
}
|
||||
if not schematic_path
|
||||
else {}
|
||||
)
|
||||
assert result["success"] is False
|
||||
|
||||
# --- list_schematic_components ---
|
||||
|
||||
def test_list_components_missing_path(self):
|
||||
params = {}
|
||||
schematic_path = params.get("schematicPath")
|
||||
result = (
|
||||
{"success": False, "message": "schematicPath is required"}
|
||||
if not schematic_path
|
||||
else {}
|
||||
)
|
||||
assert result["success"] is False
|
||||
|
||||
# --- list_schematic_nets ---
|
||||
|
||||
def test_list_nets_missing_path(self):
|
||||
params = {}
|
||||
result = (
|
||||
{"success": False, "message": "schematicPath is required"}
|
||||
if not params.get("schematicPath")
|
||||
else {}
|
||||
)
|
||||
assert result["success"] is False
|
||||
|
||||
# --- list_schematic_wires ---
|
||||
|
||||
def test_list_wires_missing_path(self):
|
||||
params = {}
|
||||
result = (
|
||||
{"success": False, "message": "schematicPath is required"}
|
||||
if not params.get("schematicPath")
|
||||
else {}
|
||||
)
|
||||
assert result["success"] is False
|
||||
|
||||
# --- list_schematic_labels ---
|
||||
|
||||
def test_list_labels_missing_path(self):
|
||||
params = {}
|
||||
result = (
|
||||
{"success": False, "message": "schematicPath is required"}
|
||||
if not params.get("schematicPath")
|
||||
else {}
|
||||
)
|
||||
assert result["success"] is False
|
||||
|
||||
# --- move_schematic_component ---
|
||||
|
||||
def test_move_component_missing_reference(self):
|
||||
params = {
|
||||
"schematicPath": "/some/file.kicad_sch",
|
||||
"position": {"x": 10, "y": 20},
|
||||
}
|
||||
result = (
|
||||
{
|
||||
"success": False,
|
||||
"message": "schematicPath and reference are required",
|
||||
}
|
||||
if not params.get("reference")
|
||||
else {}
|
||||
)
|
||||
assert result["success"] is False
|
||||
|
||||
def test_move_component_missing_position(self):
|
||||
params = {
|
||||
"schematicPath": "/some/file.kicad_sch",
|
||||
"reference": "R1",
|
||||
"position": {},
|
||||
}
|
||||
new_x = params["position"].get("x")
|
||||
new_y = params["position"].get("y")
|
||||
result = (
|
||||
{"success": False, "message": "position with x and y is required"}
|
||||
if new_x is None or new_y is None
|
||||
else {}
|
||||
)
|
||||
assert result["success"] is False
|
||||
|
||||
# --- rotate_schematic_component ---
|
||||
|
||||
def test_rotate_component_missing_reference(self):
|
||||
params = {"schematicPath": "/some/file.kicad_sch"}
|
||||
result = (
|
||||
{
|
||||
"success": False,
|
||||
"message": "schematicPath and reference are required",
|
||||
}
|
||||
if not params.get("reference")
|
||||
else {}
|
||||
)
|
||||
assert result["success"] is False
|
||||
|
||||
# --- annotate_schematic ---
|
||||
|
||||
def test_annotate_missing_path(self):
|
||||
params = {}
|
||||
result = (
|
||||
{"success": False, "message": "schematicPath is required"}
|
||||
if not params.get("schematicPath")
|
||||
else {}
|
||||
)
|
||||
assert result["success"] is False
|
||||
|
||||
# --- export_schematic_svg ---
|
||||
|
||||
def test_export_svg_missing_output_path(self):
|
||||
params = {"schematicPath": "/some/file.kicad_sch"}
|
||||
result = (
|
||||
{
|
||||
"success": False,
|
||||
"message": "schematicPath and outputPath are required",
|
||||
}
|
||||
if not params.get("outputPath")
|
||||
else {}
|
||||
)
|
||||
assert result["success"] is False
|
||||
@@ -83,17 +83,30 @@ export const toolCategories: ToolCategory[] = [
|
||||
},
|
||||
{
|
||||
name: "schematic",
|
||||
description: "Schematic operations: create, add components, wire connections, netlists",
|
||||
description: "Schematic operations: create, inspect, add/edit/delete components, wire connections, netlists, annotation",
|
||||
tools: [
|
||||
"create_schematic",
|
||||
"add_schematic_component",
|
||||
"list_schematic_components",
|
||||
"move_schematic_component",
|
||||
"rotate_schematic_component",
|
||||
"annotate_schematic",
|
||||
"add_wire",
|
||||
"delete_schematic_wire",
|
||||
"add_schematic_connection",
|
||||
"add_schematic_net_label",
|
||||
"delete_schematic_net_label",
|
||||
"connect_to_net",
|
||||
"connect_passthrough",
|
||||
"get_net_connections",
|
||||
"list_schematic_nets",
|
||||
"list_schematic_wires",
|
||||
"list_schematic_labels",
|
||||
"generate_netlist",
|
||||
"sync_schematic_to_board"
|
||||
"sync_schematic_to_board",
|
||||
"get_schematic_view",
|
||||
"export_schematic_svg",
|
||||
"export_schematic_pdf"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -141,6 +154,8 @@ export const directToolNames = [
|
||||
|
||||
// Schematic essentials (always visible so AI uses them correctly)
|
||||
"add_schematic_component",
|
||||
"list_schematic_components",
|
||||
"annotate_schematic",
|
||||
"connect_passthrough",
|
||||
"connect_to_net",
|
||||
"add_schematic_net_label",
|
||||
|
||||
@@ -445,6 +445,515 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp
|
||||
},
|
||||
);
|
||||
|
||||
// List all components in schematic
|
||||
server.tool(
|
||||
"list_schematic_components",
|
||||
"List all components in a schematic with their references, values, positions, and pins. Essential for inspecting what's on the schematic before making edits.",
|
||||
{
|
||||
schematicPath: z.string().describe("Path to the .kicad_sch file"),
|
||||
filter: z
|
||||
.object({
|
||||
libId: z.string().optional().describe("Filter by library ID (e.g., 'Device:R')"),
|
||||
referencePrefix: z.string().optional().describe("Filter by reference prefix (e.g., 'R', 'C', 'U')"),
|
||||
})
|
||||
.optional()
|
||||
.describe("Optional filters"),
|
||||
},
|
||||
async (args: {
|
||||
schematicPath: string;
|
||||
filter?: { libId?: string; referencePrefix?: string };
|
||||
}) => {
|
||||
const result = await callKicadScript("list_schematic_components", args);
|
||||
if (result.success) {
|
||||
const comps = result.components || [];
|
||||
if (comps.length === 0) {
|
||||
return {
|
||||
content: [{ type: "text", text: "No components found in schematic." }],
|
||||
};
|
||||
}
|
||||
const lines = comps.map(
|
||||
(c: any) =>
|
||||
` ${c.reference}: ${c.libId} = "${c.value}" at (${c.position.x}, ${c.position.y}) rot=${c.rotation}°${c.pins ? ` [${c.pins.length} pins]` : ""}`,
|
||||
);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Components (${comps.length}):\n${lines.join("\n")}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Failed to list components: ${result.message || "Unknown error"}`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
// List all nets in schematic
|
||||
server.tool(
|
||||
"list_schematic_nets",
|
||||
"List all nets in the schematic with their connections.",
|
||||
{
|
||||
schematicPath: z.string().describe("Path to the .kicad_sch file"),
|
||||
},
|
||||
async (args: { schematicPath: string }) => {
|
||||
const result = await callKicadScript("list_schematic_nets", args);
|
||||
if (result.success) {
|
||||
const nets = result.nets || [];
|
||||
if (nets.length === 0) {
|
||||
return {
|
||||
content: [{ type: "text", text: "No nets found in schematic." }],
|
||||
};
|
||||
}
|
||||
const lines = nets.map((n: any) => {
|
||||
const conns = (n.connections || [])
|
||||
.map((c: any) => `${c.component}/${c.pin}`)
|
||||
.join(", ");
|
||||
return ` ${n.name}: ${conns || "(no connections)"}`;
|
||||
});
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Nets (${nets.length}):\n${lines.join("\n")}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
return {
|
||||
content: [
|
||||
{ type: "text", text: `Failed to list nets: ${result.message || "Unknown error"}` },
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
// List all wires in schematic
|
||||
server.tool(
|
||||
"list_schematic_wires",
|
||||
"List all wires in the schematic with start/end coordinates.",
|
||||
{
|
||||
schematicPath: z.string().describe("Path to the .kicad_sch file"),
|
||||
},
|
||||
async (args: { schematicPath: string }) => {
|
||||
const result = await callKicadScript("list_schematic_wires", args);
|
||||
if (result.success) {
|
||||
const wires = result.wires || [];
|
||||
if (wires.length === 0) {
|
||||
return {
|
||||
content: [{ type: "text", text: "No wires found in schematic." }],
|
||||
};
|
||||
}
|
||||
const lines = wires.map(
|
||||
(w: any) =>
|
||||
` (${w.start.x}, ${w.start.y}) → (${w.end.x}, ${w.end.y})`,
|
||||
);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Wires (${wires.length}):\n${lines.join("\n")}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
return {
|
||||
content: [
|
||||
{ type: "text", text: `Failed to list wires: ${result.message || "Unknown error"}` },
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
// List all labels in schematic
|
||||
server.tool(
|
||||
"list_schematic_labels",
|
||||
"List all net labels, global labels, and power flags in the schematic.",
|
||||
{
|
||||
schematicPath: z.string().describe("Path to the .kicad_sch file"),
|
||||
},
|
||||
async (args: { schematicPath: string }) => {
|
||||
const result = await callKicadScript("list_schematic_labels", args);
|
||||
if (result.success) {
|
||||
const labels = result.labels || [];
|
||||
if (labels.length === 0) {
|
||||
return {
|
||||
content: [{ type: "text", text: "No labels found in schematic." }],
|
||||
};
|
||||
}
|
||||
const lines = labels.map(
|
||||
(l: any) =>
|
||||
` [${l.type}] ${l.name} at (${l.position.x}, ${l.position.y})`,
|
||||
);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Labels (${labels.length}):\n${lines.join("\n")}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
return {
|
||||
content: [
|
||||
{ type: "text", text: `Failed to list labels: ${result.message || "Unknown error"}` },
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
// Move schematic component
|
||||
server.tool(
|
||||
"move_schematic_component",
|
||||
"Move a placed symbol to a new position in the schematic.",
|
||||
{
|
||||
schematicPath: z.string().describe("Path to the .kicad_sch file"),
|
||||
reference: z.string().describe("Reference designator (e.g., R1, U1)"),
|
||||
position: z
|
||||
.object({
|
||||
x: z.number(),
|
||||
y: z.number(),
|
||||
})
|
||||
.describe("New position"),
|
||||
},
|
||||
async (args: {
|
||||
schematicPath: string;
|
||||
reference: string;
|
||||
position: { x: number; y: number };
|
||||
}) => {
|
||||
const result = await callKicadScript("move_schematic_component", args);
|
||||
if (result.success) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Moved ${args.reference} from (${result.oldPosition.x}, ${result.oldPosition.y}) to (${result.newPosition.x}, ${result.newPosition.y})`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Failed to move component: ${result.message || "Unknown error"}`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
// Rotate schematic component
|
||||
server.tool(
|
||||
"rotate_schematic_component",
|
||||
"Rotate a placed symbol in the schematic.",
|
||||
{
|
||||
schematicPath: z.string().describe("Path to the .kicad_sch file"),
|
||||
reference: z.string().describe("Reference designator (e.g., R1, U1)"),
|
||||
angle: z.number().describe("Rotation angle in degrees (0, 90, 180, 270)"),
|
||||
mirror: z
|
||||
.enum(["x", "y"])
|
||||
.optional()
|
||||
.describe("Optional mirror axis"),
|
||||
},
|
||||
async (args: {
|
||||
schematicPath: string;
|
||||
reference: string;
|
||||
angle: number;
|
||||
mirror?: "x" | "y";
|
||||
}) => {
|
||||
const result = await callKicadScript("rotate_schematic_component", args);
|
||||
if (result.success) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Rotated ${args.reference} to ${args.angle}°${args.mirror ? ` (mirrored ${args.mirror})` : ""}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Failed to rotate component: ${result.message || "Unknown error"}`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
// Annotate schematic
|
||||
server.tool(
|
||||
"annotate_schematic",
|
||||
"Assign reference designators to unannotated components (R? → R1, R2, ...). Must be called before tools that require known references.",
|
||||
{
|
||||
schematicPath: z.string().describe("Path to the .kicad_sch file"),
|
||||
},
|
||||
async (args: { schematicPath: string }) => {
|
||||
const result = await callKicadScript("annotate_schematic", args);
|
||||
if (result.success) {
|
||||
const annotated = result.annotated || [];
|
||||
if (annotated.length === 0) {
|
||||
return {
|
||||
content: [
|
||||
{ type: "text", text: "All components are already annotated." },
|
||||
],
|
||||
};
|
||||
}
|
||||
const lines = annotated.map(
|
||||
(a: any) => ` ${a.oldReference} → ${a.newReference}`,
|
||||
);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Annotated ${annotated.length} component(s):\n${lines.join("\n")}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Failed to annotate: ${result.message || "Unknown error"}`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
// Delete wire from schematic
|
||||
server.tool(
|
||||
"delete_schematic_wire",
|
||||
"Remove a wire from the schematic by start and end coordinates.",
|
||||
{
|
||||
schematicPath: z.string().describe("Path to the .kicad_sch file"),
|
||||
start: z
|
||||
.object({ x: z.number(), y: z.number() })
|
||||
.describe("Wire start position"),
|
||||
end: z
|
||||
.object({ x: z.number(), y: z.number() })
|
||||
.describe("Wire end position"),
|
||||
},
|
||||
async (args: {
|
||||
schematicPath: string;
|
||||
start: { x: number; y: number };
|
||||
end: { x: number; y: number };
|
||||
}) => {
|
||||
const result = await callKicadScript("delete_schematic_wire", args);
|
||||
if (result.success) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Deleted wire from (${args.start.x}, ${args.start.y}) to (${args.end.x}, ${args.end.y})`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Failed to delete wire: ${result.message || "Unknown error"}`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
// Delete net label from schematic
|
||||
server.tool(
|
||||
"delete_schematic_net_label",
|
||||
"Remove a net label from the schematic.",
|
||||
{
|
||||
schematicPath: z.string().describe("Path to the .kicad_sch file"),
|
||||
netName: z.string().describe("Name of the net label to remove"),
|
||||
position: z
|
||||
.object({ x: z.number(), y: z.number() })
|
||||
.optional()
|
||||
.describe("Position to disambiguate if multiple labels with same name"),
|
||||
},
|
||||
async (args: {
|
||||
schematicPath: string;
|
||||
netName: string;
|
||||
position?: { x: number; y: number };
|
||||
}) => {
|
||||
const result = await callKicadScript("delete_schematic_net_label", args);
|
||||
if (result.success) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Deleted net label '${args.netName}'`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Failed to delete label: ${result.message || "Unknown error"}`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
// Export schematic to SVG
|
||||
server.tool(
|
||||
"export_schematic_svg",
|
||||
"Export schematic to SVG format using kicad-cli.",
|
||||
{
|
||||
schematicPath: z.string().describe("Path to the .kicad_sch file"),
|
||||
outputPath: z.string().describe("Output SVG file path"),
|
||||
blackAndWhite: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe("Export in black and white"),
|
||||
},
|
||||
async (args: {
|
||||
schematicPath: string;
|
||||
outputPath: string;
|
||||
blackAndWhite?: boolean;
|
||||
}) => {
|
||||
const result = await callKicadScript("export_schematic_svg", args);
|
||||
if (result.success) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Exported schematic SVG to ${result.file?.path || args.outputPath}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Failed to export SVG: ${result.message || "Unknown error"}`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
// Export schematic to PDF
|
||||
server.tool(
|
||||
"export_schematic_pdf",
|
||||
"Export schematic to PDF format using kicad-cli.",
|
||||
{
|
||||
schematicPath: z.string().describe("Path to the .kicad_sch file"),
|
||||
outputPath: z.string().describe("Output PDF file path"),
|
||||
blackAndWhite: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe("Export in black and white"),
|
||||
},
|
||||
async (args: {
|
||||
schematicPath: string;
|
||||
outputPath: string;
|
||||
blackAndWhite?: boolean;
|
||||
}) => {
|
||||
const result = await callKicadScript("export_schematic_pdf", args);
|
||||
if (result.success) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Exported schematic PDF to ${result.file?.path || args.outputPath}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Failed to export PDF: ${result.message || "Unknown error"}`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
// Get schematic view (rasterized image)
|
||||
server.tool(
|
||||
"get_schematic_view",
|
||||
"Return a rasterized image of the schematic (PNG by default, or SVG). Uses kicad-cli to export SVG, then converts to PNG via cairosvg. Use this for visual feedback after placing or wiring components.",
|
||||
{
|
||||
schematicPath: z.string().describe("Path to the .kicad_sch file"),
|
||||
format: z
|
||||
.enum(["png", "svg"])
|
||||
.optional()
|
||||
.describe("Output format (default: png)"),
|
||||
width: z.number().optional().describe("Image width in pixels (default: 1200)"),
|
||||
height: z.number().optional().describe("Image height in pixels (default: 900)"),
|
||||
},
|
||||
async (args: {
|
||||
schematicPath: string;
|
||||
format?: "png" | "svg";
|
||||
width?: number;
|
||||
height?: number;
|
||||
}) => {
|
||||
const result = await callKicadScript("get_schematic_view", args);
|
||||
if (result.success) {
|
||||
if (result.format === "svg") {
|
||||
const parts: { type: "text"; text: string }[] = [];
|
||||
if (result.message) {
|
||||
parts.push({ type: "text", text: result.message });
|
||||
}
|
||||
parts.push({
|
||||
type: "text",
|
||||
text: result.imageData || "",
|
||||
});
|
||||
return { content: parts };
|
||||
}
|
||||
// PNG — return as base64 image
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "image" as const,
|
||||
data: result.imageData,
|
||||
mimeType: "image/png",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Failed to get schematic view: ${result.message || "Unknown error"}`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
// Run Electrical Rules Check (ERC)
|
||||
server.tool(
|
||||
"run_erc",
|
||||
|
||||
Reference in New Issue
Block a user