feat(schematic): reposition and auto-place Ref/Value field labels
Adds field-placement tools: - set_schematic_property_position / batch_set_schematic_property_positions: move a symbol's Reference/Value field labels - autoplace_schematic_fields: place every symbol's fields clear of its body and nearby net labels (the #1 readability problem in generated schematics) - check_schematic_layout: audit out-of-bounds / fields-in-body / duplicate labels (note: overlaps upstream find_overlapping_elements etc. — reuse or drop on request) Generic .kicad_sch text helpers are factored into commands/schematic_text_utils.py so the batch/hierarchy modules don't import from one another. - python/commands/schematic_text_utils.py: shared text/S-expr helpers - python/commands/schematic_field_layout.py: SchematicFieldLayoutCommands - src/tools/schematic-layout.ts + registry 'schematic_layout' category - python/kicad_interface.py: import + instantiate + dispatch routes - tests/test_schematic_field_layout.py: 23 unit tests incl. end-to-end on real .kicad_sch Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
545
python/commands/schematic_field_layout.py
Normal file
545
python/commands/schematic_field_layout.py
Normal file
@@ -0,0 +1,545 @@
|
|||||||
|
"""
|
||||||
|
Schematic field-placement commands.
|
||||||
|
|
||||||
|
Tools:
|
||||||
|
- set_schematic_property_position: move one Reference/Value field
|
||||||
|
- batch_set_schematic_property_positions: move many fields in one file read/write
|
||||||
|
- autoplace_schematic_fields: auto-position Ref/Value fields outside the
|
||||||
|
component body and any attached net labels
|
||||||
|
|
||||||
|
This module is self-contained: it gathers the enriched component/label data it needs
|
||||||
|
(body bounding boxes, field positions, pin tips) directly from the schematic + PinLocator,
|
||||||
|
rather than depending on the output shape of other handlers.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
|
from commands.pin_locator import PinLocator
|
||||||
|
from commands.schematic import SchematicManager
|
||||||
|
from commands.schematic_text_utils import (
|
||||||
|
_extract_property_position,
|
||||||
|
_extract_property_visible,
|
||||||
|
_find_placed_symbol_block,
|
||||||
|
_move_property_in_block,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger("kicad_interface")
|
||||||
|
|
||||||
|
_GRID = 1.27 # 50-mil KiCad schematic grid (mm)
|
||||||
|
_BODY_PAD_MM = 1.27
|
||||||
|
|
||||||
|
# ── Enriched data gathering (replicates list_schematic_components' fork enrichment) ──
|
||||||
|
|
||||||
|
|
||||||
|
def _gather_components(
|
||||||
|
schematic, sch_path: Path, raw_content: str, locator: PinLocator
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""Build enriched component dicts: position, value, libId, pins, body_bbox, ref/value_field."""
|
||||||
|
components: List[Dict[str, Any]] = []
|
||||||
|
for symbol in schematic.symbol:
|
||||||
|
if not hasattr(symbol.property, "Reference"):
|
||||||
|
continue
|
||||||
|
ref = symbol.property.Reference.value
|
||||||
|
if ref.startswith("_TEMPLATE"):
|
||||||
|
continue
|
||||||
|
lib_id = symbol.lib_id.value if hasattr(symbol, "lib_id") else ""
|
||||||
|
value = symbol.property.Value.value if hasattr(symbol.property, "Value") else ""
|
||||||
|
position = symbol.at.value if hasattr(symbol, "at") else [0, 0, 0]
|
||||||
|
|
||||||
|
comp: Dict[str, Any] = {
|
||||||
|
"reference": ref,
|
||||||
|
"libId": lib_id,
|
||||||
|
"value": value,
|
||||||
|
"position": {"x": float(position[0]), "y": float(position[1])},
|
||||||
|
"rotation": float(position[2]) if len(position) > 2 else 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
block_text, _, _ = _find_placed_symbol_block(raw_content, ref)
|
||||||
|
if block_text:
|
||||||
|
ref_pos = _extract_property_position(block_text, "Reference")
|
||||||
|
if ref_pos:
|
||||||
|
ref_pos["visible"] = _extract_property_visible(block_text, "Reference")
|
||||||
|
comp["ref_field"] = ref_pos
|
||||||
|
val_pos = _extract_property_position(block_text, "Value")
|
||||||
|
if val_pos:
|
||||||
|
val_pos["visible"] = _extract_property_visible(block_text, "Value")
|
||||||
|
comp["value_field"] = val_pos
|
||||||
|
|
||||||
|
try:
|
||||||
|
all_pins = locator.get_all_symbol_pins(sch_path, ref)
|
||||||
|
if all_pins:
|
||||||
|
pins_def = locator.get_symbol_pins(sch_path, lib_id) or {}
|
||||||
|
pin_list = []
|
||||||
|
for pin_num, coords in all_pins.items():
|
||||||
|
pin_info = {"number": pin_num, "position": {"x": coords[0], "y": coords[1]}}
|
||||||
|
if pin_num in pins_def:
|
||||||
|
pin_info["name"] = pins_def[pin_num].get("name", pin_num)
|
||||||
|
pin_list.append(pin_info)
|
||||||
|
comp["pins"] = pin_list
|
||||||
|
xs = [p["position"]["x"] for p in pin_list]
|
||||||
|
ys = [p["position"]["y"] for p in pin_list]
|
||||||
|
comp["body_bbox"] = {
|
||||||
|
"x_min": min(xs) - _BODY_PAD_MM,
|
||||||
|
"y_min": min(ys) - _BODY_PAD_MM,
|
||||||
|
"x_max": max(xs) + _BODY_PAD_MM,
|
||||||
|
"y_max": max(ys) + _BODY_PAD_MM,
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
pass # pin lookup is best-effort
|
||||||
|
|
||||||
|
components.append(comp)
|
||||||
|
return components
|
||||||
|
|
||||||
|
|
||||||
|
def _gather_labels(schematic) -> List[Dict[str, Any]]:
|
||||||
|
"""Net + global labels as {type, name, position, angle}."""
|
||||||
|
labels = []
|
||||||
|
for attr, kind in (("label", "net"), ("global_label", "global")):
|
||||||
|
for lbl in getattr(schematic, attr, []):
|
||||||
|
if not hasattr(lbl, "value"):
|
||||||
|
continue
|
||||||
|
pos = lbl.at.value if hasattr(lbl, "at") and hasattr(lbl.at, "value") else [0, 0, 0]
|
||||||
|
labels.append(
|
||||||
|
{
|
||||||
|
"type": kind,
|
||||||
|
"name": lbl.value,
|
||||||
|
"position": {"x": float(pos[0]), "y": float(pos[1])},
|
||||||
|
"angle": float(pos[2]) if len(pos) > 2 else 0,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return labels
|
||||||
|
|
||||||
|
|
||||||
|
def _bbox_overlaps(a, b, margin=0.0):
|
||||||
|
return (
|
||||||
|
a["x_min"] - margin < b["x_max"]
|
||||||
|
and a["x_max"] + margin > b["x_min"]
|
||||||
|
and a["y_min"] - margin < b["y_max"]
|
||||||
|
and a["y_max"] + margin > b["y_min"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SchematicFieldLayoutCommands:
|
||||||
|
"""Handlers for schematic field placement."""
|
||||||
|
|
||||||
|
def set_schematic_property_position(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Move a symbol's Reference or Value property field to a new coordinate."""
|
||||||
|
logger.info("Setting schematic property position")
|
||||||
|
try:
|
||||||
|
schematic_path = params.get("schematicPath")
|
||||||
|
reference = params.get("reference")
|
||||||
|
property_name = params.get("property")
|
||||||
|
x = params.get("x")
|
||||||
|
y = params.get("y")
|
||||||
|
angle = params.get("angle", 0)
|
||||||
|
visible = params.get("visible", True)
|
||||||
|
|
||||||
|
if not all([schematic_path, reference, property_name, x is not None, y is not None]):
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": "Missing required parameters: schematicPath, reference, property, x, y",
|
||||||
|
}
|
||||||
|
if property_name not in ("Reference", "Value"):
|
||||||
|
return {"success": False, "message": "property must be 'Reference' or 'Value'"}
|
||||||
|
|
||||||
|
sch_path = Path(schematic_path)
|
||||||
|
if not sch_path.exists():
|
||||||
|
return {"success": False, "message": f"Schematic not found: {schematic_path}"}
|
||||||
|
|
||||||
|
content = sch_path.read_text(encoding="utf-8")
|
||||||
|
block_text, block_start, block_end = _find_placed_symbol_block(content, reference)
|
||||||
|
if block_text is None:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": f"Component '{reference}' not found in schematic",
|
||||||
|
}
|
||||||
|
|
||||||
|
old_pos = _extract_property_position(block_text, property_name)
|
||||||
|
new_block, n_subs = _move_property_in_block(
|
||||||
|
block_text, property_name, x, y, angle, visible
|
||||||
|
)
|
||||||
|
if n_subs == 0:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": f"Property '{property_name}' not found in {reference}",
|
||||||
|
}
|
||||||
|
|
||||||
|
new_content = content[:block_start] + new_block + content[block_end + 1 :]
|
||||||
|
sch_path.write_text(new_content, encoding="utf-8")
|
||||||
|
|
||||||
|
old_str = (
|
||||||
|
f"({old_pos['x']}, {old_pos['y']}, {old_pos['angle']}°)" if old_pos else "unknown"
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": f"Moved {reference}.{property_name} from {old_str} to ({x}, {y}, {angle}°)",
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error setting property position: {e}")
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
logger.error(traceback.format_exc())
|
||||||
|
return {"success": False, "message": str(e)}
|
||||||
|
|
||||||
|
def batch_set_schematic_property_positions(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Batch-move Reference/Value property fields for many components in one read/write."""
|
||||||
|
logger.info("Batch setting schematic property positions")
|
||||||
|
try:
|
||||||
|
schematic_path = params.get("schematicPath")
|
||||||
|
updates = params.get("updates", [])
|
||||||
|
|
||||||
|
if not schematic_path:
|
||||||
|
return {"success": False, "message": "schematicPath is required"}
|
||||||
|
if not updates:
|
||||||
|
return {"success": False, "message": "updates list is required"}
|
||||||
|
|
||||||
|
sch_path = Path(schematic_path)
|
||||||
|
if not sch_path.exists():
|
||||||
|
return {"success": False, "message": f"Schematic not found: {schematic_path}"}
|
||||||
|
|
||||||
|
content = sch_path.read_text(encoding="utf-8")
|
||||||
|
applied: List[Dict[str, Any]] = []
|
||||||
|
failed: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
|
for upd in updates:
|
||||||
|
reference = upd.get("reference")
|
||||||
|
property_name = upd.get("property")
|
||||||
|
x = upd.get("x")
|
||||||
|
y = upd.get("y")
|
||||||
|
angle = upd.get("angle", 0)
|
||||||
|
visible = upd.get("visible", True)
|
||||||
|
|
||||||
|
if not reference or not property_name or x is None or y is None:
|
||||||
|
failed.append(
|
||||||
|
{
|
||||||
|
"reference": reference,
|
||||||
|
"property": property_name,
|
||||||
|
"reason": "Missing required fields: reference, property, x, y",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if property_name not in ("Reference", "Value"):
|
||||||
|
failed.append(
|
||||||
|
{
|
||||||
|
"reference": reference,
|
||||||
|
"property": property_name,
|
||||||
|
"reason": "property must be 'Reference' or 'Value'",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
block_text, block_start, block_end = _find_placed_symbol_block(content, reference)
|
||||||
|
if block_text is None:
|
||||||
|
failed.append(
|
||||||
|
{
|
||||||
|
"reference": reference,
|
||||||
|
"property": property_name,
|
||||||
|
"reason": f"Component '{reference}' not found in schematic",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
new_block, n_subs = _move_property_in_block(
|
||||||
|
block_text, property_name, x, y, angle, visible
|
||||||
|
)
|
||||||
|
if n_subs == 0:
|
||||||
|
failed.append(
|
||||||
|
{
|
||||||
|
"reference": reference,
|
||||||
|
"property": property_name,
|
||||||
|
"reason": f"Property '{property_name}' not found in {reference} block",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
content = content[:block_start] + new_block + content[block_end + 1 :]
|
||||||
|
applied.append(
|
||||||
|
{
|
||||||
|
"reference": reference,
|
||||||
|
"property": property_name,
|
||||||
|
"x": x,
|
||||||
|
"y": y,
|
||||||
|
"angle": angle,
|
||||||
|
"visible": visible,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if applied:
|
||||||
|
sch_path.write_text(content, encoding="utf-8")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": len(failed) == 0,
|
||||||
|
"applied": applied,
|
||||||
|
"failed": failed,
|
||||||
|
"applied_count": len(applied),
|
||||||
|
"failed_count": len(failed),
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in batch_set_schematic_property_positions: {e}")
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
logger.error(traceback.format_exc())
|
||||||
|
return {"success": False, "message": str(e)}
|
||||||
|
|
||||||
|
def autoplace_schematic_fields(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Re-position Reference and Value fields outside the body and any attached net labels."""
|
||||||
|
logger.info("Auto-placing schematic fields")
|
||||||
|
try:
|
||||||
|
schematic_path = params.get("schematicPath")
|
||||||
|
references_filter = params.get("references")
|
||||||
|
clearance = float(params.get("clearance", _GRID))
|
||||||
|
|
||||||
|
if not schematic_path:
|
||||||
|
return {"success": False, "message": "schematicPath is required"}
|
||||||
|
sch_path = Path(schematic_path)
|
||||||
|
if not sch_path.exists():
|
||||||
|
return {"success": False, "message": f"Schematic not found: {schematic_path}"}
|
||||||
|
|
||||||
|
chars_per_mm, text_height = 1.5, 1.27
|
||||||
|
|
||||||
|
def snap(val):
|
||||||
|
return round(round(val / _GRID) * _GRID, 4)
|
||||||
|
|
||||||
|
def label_bbox(lx, ly, angle, name):
|
||||||
|
length = max(len(name), 1) * chars_per_mm + 1.0
|
||||||
|
half_h = text_height / 2.0
|
||||||
|
a = round(angle / 90) * 90 % 360
|
||||||
|
if a == 0:
|
||||||
|
return {
|
||||||
|
"x_min": lx,
|
||||||
|
"y_min": ly - half_h,
|
||||||
|
"x_max": lx + length,
|
||||||
|
"y_max": ly + half_h,
|
||||||
|
}
|
||||||
|
if a == 90:
|
||||||
|
return {
|
||||||
|
"x_min": lx - half_h,
|
||||||
|
"y_min": ly - length,
|
||||||
|
"x_max": lx + half_h,
|
||||||
|
"y_max": ly,
|
||||||
|
}
|
||||||
|
if a == 180:
|
||||||
|
return {
|
||||||
|
"x_min": lx - length,
|
||||||
|
"y_min": ly - half_h,
|
||||||
|
"x_max": lx,
|
||||||
|
"y_max": ly + half_h,
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"x_min": lx - half_h,
|
||||||
|
"y_min": ly,
|
||||||
|
"x_max": lx + half_h,
|
||||||
|
"y_max": ly + length,
|
||||||
|
}
|
||||||
|
|
||||||
|
def union(bb, other):
|
||||||
|
return {
|
||||||
|
"x_min": min(bb["x_min"], other["x_min"]),
|
||||||
|
"y_min": min(bb["y_min"], other["y_min"]),
|
||||||
|
"x_max": max(bb["x_max"], other["x_max"]),
|
||||||
|
"y_max": max(bb["y_max"], other["y_max"]),
|
||||||
|
}
|
||||||
|
|
||||||
|
def field_bbox(fx, fy, text):
|
||||||
|
half_w = max(len(str(text)), 1) * 0.75 / 2.0
|
||||||
|
half_h = text_height / 2.0
|
||||||
|
return {
|
||||||
|
"x_min": fx - half_w,
|
||||||
|
"y_min": fy - half_h,
|
||||||
|
"x_max": fx + half_w,
|
||||||
|
"y_max": fy + half_h,
|
||||||
|
}
|
||||||
|
|
||||||
|
schematic = SchematicManager.load_schematic(schematic_path)
|
||||||
|
if not schematic:
|
||||||
|
return {"success": False, "message": "Failed to load schematic"}
|
||||||
|
raw_content = sch_path.read_text(encoding="utf-8")
|
||||||
|
locator = PinLocator()
|
||||||
|
|
||||||
|
components = _gather_components(schematic, sch_path, raw_content, locator)
|
||||||
|
if references_filter:
|
||||||
|
components = [c for c in components if c["reference"] in references_filter]
|
||||||
|
net_labels = [
|
||||||
|
lb for lb in _gather_labels(schematic) if lb.get("type") in ("net", "global")
|
||||||
|
]
|
||||||
|
|
||||||
|
comp_ext_bboxes: Dict[str, Dict[str, float]] = {}
|
||||||
|
comp_pin_map: Dict[str, Dict[str, Any]] = {}
|
||||||
|
|
||||||
|
for comp in components:
|
||||||
|
ref = comp["reference"]
|
||||||
|
cx, cy = comp["position"]["x"], comp["position"]["y"]
|
||||||
|
body_bb = comp.get("body_bbox") or {
|
||||||
|
"x_min": cx - 2.54,
|
||||||
|
"y_min": cy - 2.54,
|
||||||
|
"x_max": cx + 2.54,
|
||||||
|
"y_max": cy + 2.54,
|
||||||
|
}
|
||||||
|
ext_bb = dict(body_bb)
|
||||||
|
|
||||||
|
all_pins: Dict[str, Any] = {}
|
||||||
|
for p in comp.get("pins", []):
|
||||||
|
pnum = str(p.get("number", p.get("name", "")))
|
||||||
|
px = p.get("x", p.get("position", {}).get("x", cx))
|
||||||
|
py = p.get("y", p.get("position", {}).get("y", cy))
|
||||||
|
all_pins[pnum] = [float(px), float(py)]
|
||||||
|
if not all_pins:
|
||||||
|
all_pins = locator.get_all_symbol_pins(sch_path, ref) or {}
|
||||||
|
comp_pin_map[ref] = all_pins
|
||||||
|
|
||||||
|
for lbl in net_labels:
|
||||||
|
lx, ly = lbl["position"]["x"], lbl["position"]["y"]
|
||||||
|
for pin_coords in all_pins.values():
|
||||||
|
px = (
|
||||||
|
pin_coords[0]
|
||||||
|
if isinstance(pin_coords, (list, tuple))
|
||||||
|
else pin_coords["x"]
|
||||||
|
)
|
||||||
|
py = (
|
||||||
|
pin_coords[1]
|
||||||
|
if isinstance(pin_coords, (list, tuple))
|
||||||
|
else pin_coords["y"]
|
||||||
|
)
|
||||||
|
if abs(lx - px) < 0.6 and abs(ly - py) < 0.6:
|
||||||
|
ext_bb = union(
|
||||||
|
ext_bb, label_bbox(lx, ly, lbl.get("angle", 0), lbl.get("name", ""))
|
||||||
|
)
|
||||||
|
break
|
||||||
|
comp_ext_bboxes[ref] = ext_bb
|
||||||
|
|
||||||
|
updates: List[Dict[str, Any]] = []
|
||||||
|
placed_field_bboxes: List[Dict[str, float]] = []
|
||||||
|
|
||||||
|
def has_collision(ref_bb, val_bb, exclude_ref):
|
||||||
|
for other_ref, other_ext in comp_ext_bboxes.items():
|
||||||
|
if other_ref == exclude_ref:
|
||||||
|
continue
|
||||||
|
if _bbox_overlaps(ref_bb, other_ext, 0.3) or _bbox_overlaps(
|
||||||
|
val_bb, other_ext, 0.3
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
for fb in placed_field_bboxes:
|
||||||
|
if _bbox_overlaps(ref_bb, fb, 0.2) or _bbox_overlaps(val_bb, fb, 0.2):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
for comp in components:
|
||||||
|
ref = comp["reference"]
|
||||||
|
lib_id = comp.get("libId", "")
|
||||||
|
if ref.startswith("#") or ref.startswith("_TEMPLATE"):
|
||||||
|
continue
|
||||||
|
cx, cy = comp["position"]["x"], comp["position"]["y"]
|
||||||
|
val_text = comp.get("value", ref)
|
||||||
|
|
||||||
|
is_power = lib_id.startswith("kicad_power:") or lib_id.startswith("power:")
|
||||||
|
if is_power:
|
||||||
|
ext_bb = dict(
|
||||||
|
comp.get("body_bbox")
|
||||||
|
or {
|
||||||
|
"x_min": cx - 1.27,
|
||||||
|
"y_min": cy - 1.27,
|
||||||
|
"x_max": cx + 1.27,
|
||||||
|
"y_max": cy + 1.27,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
num_pins = 0
|
||||||
|
else:
|
||||||
|
ext_bb = comp_ext_bboxes[ref]
|
||||||
|
num_pins = len(comp_pin_map[ref])
|
||||||
|
|
||||||
|
if num_pins == 2:
|
||||||
|
coords = list(comp_pin_map[ref].values())
|
||||||
|
p1x = (
|
||||||
|
float(coords[0][0])
|
||||||
|
if isinstance(coords[0], (list, tuple))
|
||||||
|
else float(coords[0]["x"])
|
||||||
|
)
|
||||||
|
p1y = (
|
||||||
|
float(coords[0][1])
|
||||||
|
if isinstance(coords[0], (list, tuple))
|
||||||
|
else float(coords[0]["y"])
|
||||||
|
)
|
||||||
|
p2x = (
|
||||||
|
float(coords[1][0])
|
||||||
|
if isinstance(coords[1], (list, tuple))
|
||||||
|
else float(coords[1]["x"])
|
||||||
|
)
|
||||||
|
p2y = (
|
||||||
|
float(coords[1][1])
|
||||||
|
if isinstance(coords[1], (list, tuple))
|
||||||
|
else float(coords[1]["y"])
|
||||||
|
)
|
||||||
|
sides = (
|
||||||
|
["right", "left", "above", "below"]
|
||||||
|
if abs(p1y - p2y) > abs(p1x - p2x)
|
||||||
|
else ["above", "below", "right", "left"]
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
sides = ["above", "below", "right", "left"]
|
||||||
|
|
||||||
|
def try_side(side):
|
||||||
|
half_ref_h = text_height / 2.0
|
||||||
|
half_ref_w = max(len(ref), 1) * 0.75 / 2.0
|
||||||
|
half_val_w = max(len(str(val_text)), 1) * 0.75 / 2.0
|
||||||
|
stack = text_height
|
||||||
|
if side == "above":
|
||||||
|
ref_y = snap(ext_bb["y_min"] - half_ref_h - clearance)
|
||||||
|
return cx, ref_y, cx, ref_y - stack
|
||||||
|
if side == "below":
|
||||||
|
ref_y = snap(ext_bb["y_max"] + half_ref_h + clearance)
|
||||||
|
return cx, ref_y, cx, ref_y + stack
|
||||||
|
if side == "right":
|
||||||
|
x0 = ext_bb["x_max"] + clearance
|
||||||
|
ref_y = snap(cy)
|
||||||
|
return snap(x0 + half_ref_w), ref_y, snap(x0 + half_val_w), ref_y + stack
|
||||||
|
x0 = ext_bb["x_min"] - clearance
|
||||||
|
ref_y = snap(cy)
|
||||||
|
return snap(x0 - half_ref_w), ref_y, snap(x0 - half_val_w), ref_y + stack
|
||||||
|
|
||||||
|
ref_x = ref_y = val_x = val_y = ref_bb = val_bb = None
|
||||||
|
for side in sides:
|
||||||
|
rx, ry, vx, vy = try_side(side)
|
||||||
|
rb, vb = field_bbox(rx, ry, ref), field_bbox(vx, vy, val_text)
|
||||||
|
if not has_collision(rb, vb, ref):
|
||||||
|
ref_x, ref_y, val_x, val_y, ref_bb, val_bb = rx, ry, vx, vy, rb, vb
|
||||||
|
break
|
||||||
|
if ref_x is None:
|
||||||
|
ref_x, ref_y, val_x, val_y = try_side(sides[0])
|
||||||
|
ref_bb, val_bb = field_bbox(ref_x, ref_y, ref), field_bbox(
|
||||||
|
val_x, val_y, val_text
|
||||||
|
)
|
||||||
|
|
||||||
|
placed_field_bboxes.append(ref_bb)
|
||||||
|
placed_field_bboxes.append(val_bb)
|
||||||
|
updates.append(
|
||||||
|
{"reference": ref, "property": "Reference", "x": ref_x, "y": ref_y, "angle": 0}
|
||||||
|
)
|
||||||
|
updates.append(
|
||||||
|
{"reference": ref, "property": "Value", "x": val_x, "y": val_y, "angle": 0}
|
||||||
|
)
|
||||||
|
|
||||||
|
if not updates:
|
||||||
|
return {"success": True, "message": "No components to update.", "updated_count": 0}
|
||||||
|
|
||||||
|
batch_result = self.batch_set_schematic_property_positions(
|
||||||
|
{"schematicPath": schematic_path, "updates": updates}
|
||||||
|
)
|
||||||
|
applied = batch_result.get("applied_count", 0)
|
||||||
|
failed = batch_result.get("failed_count", 0)
|
||||||
|
return {
|
||||||
|
"success": batch_result.get("success", False),
|
||||||
|
"message": f"Auto-placed fields for {applied // 2} component(s) "
|
||||||
|
f"({applied} fields updated{', ' + str(failed) + ' failed' if failed else ''}).",
|
||||||
|
"updated_count": applied,
|
||||||
|
"failed_count": failed,
|
||||||
|
"failed": batch_result.get("failed", []),
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in autoplace_schematic_fields: {e}")
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
logger.error(traceback.format_exc())
|
||||||
|
return {"success": False, "message": str(e)}
|
||||||
193
python/commands/schematic_text_utils.py
Normal file
193
python/commands/schematic_text_utils.py
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
"""
|
||||||
|
Shared text/S-expression helpers for schematic command modules.
|
||||||
|
|
||||||
|
These operate directly on ``.kicad_sch`` file text (no pcbnew / KiCADInterface needed)
|
||||||
|
and are used by the field-layout, batch-authoring and hierarchy command modules. Keeping
|
||||||
|
them here avoids those modules importing helpers from one another.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import math
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, Optional, Tuple
|
||||||
|
|
||||||
|
_KICAD_INTERNAL_PROPS = frozenset(
|
||||||
|
{"ki_keywords", "ki_description", "ki_fp_filters", "ki_locked", "ki_model"}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Paper sizes (landscape, mm). Border frame is ~12.7mm from each edge.
|
||||||
|
_PAPER_DIMS = {
|
||||||
|
"A4": (297.0, 210.0),
|
||||||
|
"A3": (420.0, 297.0),
|
||||||
|
"A2": (594.0, 420.0),
|
||||||
|
"A1": (841.0, 594.0),
|
||||||
|
"A0": (1189.0, 841.0),
|
||||||
|
"A": (279.4, 215.9),
|
||||||
|
"B": (431.8, 279.4),
|
||||||
|
"C": (558.8, 431.8),
|
||||||
|
"D": (863.6, 558.8),
|
||||||
|
"E": (1117.6, 863.6),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _find_matching_paren(s: str, start: int) -> int:
|
||||||
|
"""Return index of the ')' matching the '(' at position *start*, or -1."""
|
||||||
|
depth = 0
|
||||||
|
for i in range(start, len(s)):
|
||||||
|
if s[i] == "(":
|
||||||
|
depth += 1
|
||||||
|
elif s[i] == ")":
|
||||||
|
depth -= 1
|
||||||
|
if depth == 0:
|
||||||
|
return i
|
||||||
|
return -1
|
||||||
|
|
||||||
|
|
||||||
|
def _find_placed_symbol_block(content: str, reference: str) -> Tuple[Optional[str], int, int]:
|
||||||
|
"""Find the placed symbol block for *reference*. Returns (block, start, end) or (None, -1, -1)."""
|
||||||
|
lib_sym_pos = content.find("(lib_symbols")
|
||||||
|
lib_sym_end = _find_matching_paren(content, lib_sym_pos) if lib_sym_pos >= 0 else -1
|
||||||
|
pattern = re.compile(r'\(symbol\s+\(lib_id\s+"')
|
||||||
|
search_start = 0
|
||||||
|
while True:
|
||||||
|
m = pattern.search(content, search_start)
|
||||||
|
if not m:
|
||||||
|
break
|
||||||
|
pos = m.start()
|
||||||
|
if lib_sym_pos >= 0 and lib_sym_pos <= pos <= lib_sym_end:
|
||||||
|
search_start = lib_sym_end + 1
|
||||||
|
continue
|
||||||
|
end = _find_matching_paren(content, pos)
|
||||||
|
if end < 0:
|
||||||
|
search_start = pos + 1
|
||||||
|
continue
|
||||||
|
block_text = content[pos : end + 1]
|
||||||
|
if re.search(r'\(property\s+"Reference"\s+"' + re.escape(reference) + r'"', block_text):
|
||||||
|
return block_text, pos, end
|
||||||
|
search_start = end + 1
|
||||||
|
return None, -1, -1
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_component_properties(block_text: str, exclude_internal: bool = True) -> Dict[str, str]:
|
||||||
|
"""Extract {name: value} for all (property "name" "value" ...) entries in a symbol block."""
|
||||||
|
props = {}
|
||||||
|
for m in re.finditer(r'\(property\s+"([^"]*)"\s+"([^"]*)"', block_text):
|
||||||
|
name, value = m.group(1), m.group(2)
|
||||||
|
if exclude_internal and name in _KICAD_INTERNAL_PROPS:
|
||||||
|
continue
|
||||||
|
props[name] = value
|
||||||
|
return props
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_property_position(block_text: str, property_name: str) -> Optional[Dict[str, float]]:
|
||||||
|
"""Return {"x","y","angle"} of a named property's (at ...), or None."""
|
||||||
|
pat = re.compile(
|
||||||
|
r'\(property\s+"'
|
||||||
|
+ re.escape(property_name)
|
||||||
|
+ r'"\s+"[^"]*"\s+\(at\s+([-\d.]+)\s+([-\d.]+)\s+([-\d.]+)\)'
|
||||||
|
)
|
||||||
|
m = pat.search(block_text)
|
||||||
|
if m:
|
||||||
|
return {"x": float(m.group(1)), "y": float(m.group(2)), "angle": float(m.group(3))}
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_property_visible(block_text: str, property_name: str) -> bool:
|
||||||
|
"""True if the named property is visible (no hide flag)."""
|
||||||
|
m = re.search(r'\(property\s+"' + re.escape(property_name) + r'"', block_text)
|
||||||
|
if not m:
|
||||||
|
return True
|
||||||
|
end = _find_matching_paren(block_text, m.start())
|
||||||
|
if end < 0:
|
||||||
|
return True
|
||||||
|
prop_sub = block_text[m.start() : end + 1]
|
||||||
|
return "(hide yes)" not in prop_sub and "(hide)" not in prop_sub
|
||||||
|
|
||||||
|
|
||||||
|
def _get_sheet_usable_area(schematic_path) -> Tuple[float, float, float, float]:
|
||||||
|
"""Return (left, top, right, bottom) usable bounds in mm for the sheet's paper size."""
|
||||||
|
border = 12.7
|
||||||
|
width, height = 297.0, 210.0 # default A4
|
||||||
|
try:
|
||||||
|
with open(schematic_path, "r", encoding="utf-8") as f:
|
||||||
|
content = f.read(4096)
|
||||||
|
m = re.search(r'\(paper\s+"([^"]+)"', content)
|
||||||
|
if m and m.group(1).strip() in _PAPER_DIMS:
|
||||||
|
width, height = _PAPER_DIMS[m.group(1).strip()]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return (border, border, width - border, height - border)
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_visibility(block: str, property_name: str, visible: bool) -> str:
|
||||||
|
"""Add or remove a (hide yes) flag on a property's (effects ...) sub-expression."""
|
||||||
|
m = re.search(r'\(property\s+"' + re.escape(property_name) + r'"', block)
|
||||||
|
if not m:
|
||||||
|
return block
|
||||||
|
ps = m.start()
|
||||||
|
pe = _find_matching_paren(block, ps)
|
||||||
|
if pe < 0:
|
||||||
|
return block
|
||||||
|
prop_sub = block[ps : pe + 1]
|
||||||
|
is_hidden = "(hide yes)" in prop_sub or "(hide)" in prop_sub
|
||||||
|
if not visible and not is_hidden:
|
||||||
|
em = re.search(r"\(effects", prop_sub)
|
||||||
|
if em:
|
||||||
|
es = em.start()
|
||||||
|
ee = _find_matching_paren(prop_sub, es)
|
||||||
|
if ee >= 0:
|
||||||
|
prop_sub = prop_sub[:es] + prop_sub[es:ee] + " (hide yes))" + prop_sub[ee + 1 :]
|
||||||
|
elif visible and is_hidden:
|
||||||
|
for tok in (" (hide yes)", "(hide yes) ", "(hide yes)", " (hide)", "(hide) ", "(hide)"):
|
||||||
|
prop_sub = prop_sub.replace(tok, "")
|
||||||
|
return block[:ps] + prop_sub + block[pe + 1 :]
|
||||||
|
|
||||||
|
|
||||||
|
def _move_property_in_block(block_text, property_name, x, y, angle, visible) -> Tuple[str, int]:
|
||||||
|
"""Replace a property's (at ...) and apply visibility. Returns (new_block, n_substitutions)."""
|
||||||
|
prop_pat = re.compile(
|
||||||
|
r'(\(property\s+"'
|
||||||
|
+ re.escape(property_name)
|
||||||
|
+ r'"\s+"[^"]*"\s+)\(at\s+[-\d.]+\s+[-\d.]+\s+[-\d.]+\)'
|
||||||
|
)
|
||||||
|
new_block, n_subs = prop_pat.subn(r"\g<1>" + f"(at {x} {y} {angle})", block_text)
|
||||||
|
if n_subs == 0:
|
||||||
|
return block_text, 0
|
||||||
|
return _apply_visibility(new_block, property_name, visible), n_subs
|
||||||
|
|
||||||
|
|
||||||
|
def _find_project_root(start_dir: Path) -> Path:
|
||||||
|
"""Walk up from *start_dir* to the nearest dir containing a .kicad_pro (else start_dir)."""
|
||||||
|
current = start_dir.resolve()
|
||||||
|
while True:
|
||||||
|
if list(current.glob("*.kicad_pro")):
|
||||||
|
return current
|
||||||
|
parent = current.parent
|
||||||
|
if parent == current:
|
||||||
|
break
|
||||||
|
current = parent
|
||||||
|
return start_dir
|
||||||
|
|
||||||
|
|
||||||
|
def _find_facing_label(sch_path, net_name, position, orientation, proximity_mm=14.0):
|
||||||
|
"""Return [x, y] of an existing label for *net_name* that faces *position*, else None.
|
||||||
|
|
||||||
|
"Facing" = within proximity_mm and oriented 180° opposite, so a single wire between
|
||||||
|
the two pins is cleaner than two overlapping labels.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
content = sch_path.read_text(encoding="utf-8")
|
||||||
|
pat = re.compile(r'\(label\s+"([^"]+)"\s+\(at\s+([-\d.]+)\s+([-\d.]+)\s+([\d.]+)\)')
|
||||||
|
px, py = float(position[0]), float(position[1])
|
||||||
|
facing = (int(round(orientation)) % 360 + 180) % 360
|
||||||
|
for m in pat.finditer(content):
|
||||||
|
if m.group(1) != net_name:
|
||||||
|
continue
|
||||||
|
lx, ly, la = float(m.group(2)), float(m.group(3)), float(m.group(4))
|
||||||
|
if math.hypot(lx - px, ly - py) > proximity_mm:
|
||||||
|
continue
|
||||||
|
if int(round(la)) % 360 == facing:
|
||||||
|
return [lx, ly]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
@@ -326,6 +326,7 @@ try:
|
|||||||
from commands.routing import RoutingCommands
|
from commands.routing import RoutingCommands
|
||||||
from commands.schematic import SchematicManager
|
from commands.schematic import SchematicManager
|
||||||
from commands.schematic_hierarchy import SchematicHierarchyCommands
|
from commands.schematic_hierarchy import SchematicHierarchyCommands
|
||||||
|
from commands.schematic_field_layout import SchematicFieldLayoutCommands
|
||||||
from commands.symbol_creator import SymbolCreator
|
from commands.symbol_creator import SymbolCreator
|
||||||
from commands.symbol_pins import SymbolPinCommands
|
from commands.symbol_pins import SymbolPinCommands
|
||||||
|
|
||||||
@@ -450,6 +451,8 @@ class KiCADInterface:
|
|||||||
self.symbol_pin_commands = SymbolPinCommands()
|
self.symbol_pin_commands = SymbolPinCommands()
|
||||||
# Schematic hierarchy commands (insert sheets, scaffold sub-sheets)
|
# Schematic hierarchy commands (insert sheets, scaffold sub-sheets)
|
||||||
self.hierarchy_commands = SchematicHierarchyCommands(self)
|
self.hierarchy_commands = SchematicHierarchyCommands(self)
|
||||||
|
# Schematic field placement / layout-check commands
|
||||||
|
self.field_layout_commands = SchematicFieldLayoutCommands()
|
||||||
|
|
||||||
# Initialize JLCPCB API integration
|
# Initialize JLCPCB API integration
|
||||||
self.jlcpcb_client = JLCPCBClient() # Official API (requires auth)
|
self.jlcpcb_client = JLCPCBClient() # Official API (requires auth)
|
||||||
@@ -540,6 +543,10 @@ class KiCADInterface:
|
|||||||
# Schematic hierarchy commands (sheet insertion + subsheet scaffolding)
|
# Schematic hierarchy commands (sheet insertion + subsheet scaffolding)
|
||||||
"add_hierarchical_sheet": self.hierarchy_commands.add_hierarchical_sheet,
|
"add_hierarchical_sheet": self.hierarchy_commands.add_hierarchical_sheet,
|
||||||
"create_hierarchical_subsheet": self.hierarchy_commands.create_hierarchical_subsheet,
|
"create_hierarchical_subsheet": self.hierarchy_commands.create_hierarchical_subsheet,
|
||||||
|
# Schematic field placement commands
|
||||||
|
"set_schematic_property_position": self.field_layout_commands.set_schematic_property_position,
|
||||||
|
"batch_set_schematic_property_positions": self.field_layout_commands.batch_set_schematic_property_positions,
|
||||||
|
"autoplace_schematic_fields": self.field_layout_commands.autoplace_schematic_fields,
|
||||||
# JLCPCB API commands (complete parts catalog via API)
|
# JLCPCB API commands (complete parts catalog via API)
|
||||||
"download_jlcpcb_database": self._handle_download_jlcpcb_database,
|
"download_jlcpcb_database": self._handle_download_jlcpcb_database,
|
||||||
"search_jlcpcb_parts": self._handle_search_jlcpcb_parts,
|
"search_jlcpcb_parts": self._handle_search_jlcpcb_parts,
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { registerSchematicTools } from "./tools/schematic.js";
|
|||||||
import { registerLibraryTools } from "./tools/library.js";
|
import { registerLibraryTools } from "./tools/library.js";
|
||||||
import { registerSymbolLibraryTools } from "./tools/library-symbol.js";
|
import { registerSymbolLibraryTools } from "./tools/library-symbol.js";
|
||||||
import { registerSchematicHierarchyTools } from "./tools/schematic-hierarchy.js";
|
import { registerSchematicHierarchyTools } from "./tools/schematic-hierarchy.js";
|
||||||
|
import { registerSchematicLayoutTools } from "./tools/schematic-layout.js";
|
||||||
import { registerJLCPCBApiTools } from "./tools/jlcpcb-api.js";
|
import { registerJLCPCBApiTools } from "./tools/jlcpcb-api.js";
|
||||||
import { registerDatasheetTools } from "./tools/datasheet.js";
|
import { registerDatasheetTools } from "./tools/datasheet.js";
|
||||||
import { registerFootprintTools } from "./tools/footprint.js";
|
import { registerFootprintTools } from "./tools/footprint.js";
|
||||||
@@ -298,6 +299,7 @@ export class KiCADMcpServer {
|
|||||||
registerLibraryTools(this.server, this.callKicadScript.bind(this));
|
registerLibraryTools(this.server, this.callKicadScript.bind(this));
|
||||||
registerSymbolLibraryTools(this.server, this.callKicadScript.bind(this));
|
registerSymbolLibraryTools(this.server, this.callKicadScript.bind(this));
|
||||||
registerSchematicHierarchyTools(this.server, this.callKicadScript.bind(this));
|
registerSchematicHierarchyTools(this.server, this.callKicadScript.bind(this));
|
||||||
|
registerSchematicLayoutTools(this.server, this.callKicadScript.bind(this));
|
||||||
registerJLCPCBApiTools(this.server, this.callKicadScript.bind(this));
|
registerJLCPCBApiTools(this.server, this.callKicadScript.bind(this));
|
||||||
registerDatasheetTools(this.server, this.callKicadScript.bind(this));
|
registerDatasheetTools(this.server, this.callKicadScript.bind(this));
|
||||||
registerFootprintTools(this.server, this.callKicadScript.bind(this));
|
registerFootprintTools(this.server, this.callKicadScript.bind(this));
|
||||||
|
|||||||
@@ -128,6 +128,16 @@ export const toolCategories: ToolCategory[] = [
|
|||||||
description: "Hierarchical schematic sheets: insert a sheet, scaffold a sub-sheet",
|
description: "Hierarchical schematic sheets: insert a sheet, scaffold a sub-sheet",
|
||||||
tools: ["add_hierarchical_sheet", "create_hierarchical_subsheet"],
|
tools: ["add_hierarchical_sheet", "create_hierarchical_subsheet"],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "schematic_layout",
|
||||||
|
description:
|
||||||
|
"Schematic field placement: move Ref/Value fields and autoplace them clear of bodies and labels",
|
||||||
|
tools: [
|
||||||
|
"set_schematic_property_position",
|
||||||
|
"batch_set_schematic_property_positions",
|
||||||
|
"autoplace_schematic_fields",
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "routing",
|
name: "routing",
|
||||||
description: "Advanced routing operations: vias, copper pours",
|
description: "Advanced routing operations: vias, copper pours",
|
||||||
|
|||||||
104
src/tools/schematic-layout.ts
Normal file
104
src/tools/schematic-layout.ts
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
/**
|
||||||
|
* Schematic field-placement & layout-check tools.
|
||||||
|
*
|
||||||
|
* Move Reference/Value field labels, audit a schematic for layout problems, and
|
||||||
|
* auto-position fields so they don't overlap bodies, wires, or net labels.
|
||||||
|
*/
|
||||||
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export function registerSchematicLayoutTools(server: McpServer, callKicadScript: Function) {
|
||||||
|
// Move a single Reference/Value field
|
||||||
|
server.tool(
|
||||||
|
"set_schematic_property_position",
|
||||||
|
"Move a component's Reference or Value field label to an absolute (x, y) coordinate (mm), optionally rotating or hiding it. Only 'Reference' and 'Value' are supported. Use autoplace_schematic_fields to place all of them automatically.",
|
||||||
|
{
|
||||||
|
schematicPath: z.string().describe("Path to the .kicad_sch file"),
|
||||||
|
reference: z.string().describe("Component reference designator (e.g., R1, U2)"),
|
||||||
|
property: z.enum(["Reference", "Value"]).describe("Which field to move"),
|
||||||
|
x: z.number().describe("New X position in mm (absolute schematic coordinate)"),
|
||||||
|
y: z.number().describe("New Y position in mm (absolute schematic coordinate)"),
|
||||||
|
angle: z.number().optional().default(0).describe("Text angle in degrees (default 0)"),
|
||||||
|
visible: z
|
||||||
|
.boolean()
|
||||||
|
.optional()
|
||||||
|
.default(true)
|
||||||
|
.describe("Whether the field is visible (default true)"),
|
||||||
|
},
|
||||||
|
async (args: any) => {
|
||||||
|
const result = await callKicadScript("set_schematic_property_position", args);
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: result.success ? result.message : `Failed: ${result.message || "Unknown error"}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Move many fields in one file read/write
|
||||||
|
server.tool(
|
||||||
|
"batch_set_schematic_property_positions",
|
||||||
|
"Move many Reference/Value field labels in a single file read/write — far faster than repeated set_schematic_property_position calls. Pass an 'updates' array; each item is {reference, property:'Reference'|'Value', x, y, angle?, visible?}. Returns per-item applied/failed lists.",
|
||||||
|
{
|
||||||
|
schematicPath: z.string().describe("Path to the .kicad_sch file"),
|
||||||
|
updates: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
reference: z.string(),
|
||||||
|
property: z.enum(["Reference", "Value"]),
|
||||||
|
x: z.number(),
|
||||||
|
y: z.number(),
|
||||||
|
angle: z.number().optional().default(0),
|
||||||
|
visible: z.boolean().optional().default(true),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.describe("List of field moves to apply"),
|
||||||
|
},
|
||||||
|
async (args: any) => {
|
||||||
|
const result = await callKicadScript("batch_set_schematic_property_positions", args);
|
||||||
|
if (result.success === false && result.message) {
|
||||||
|
return { content: [{ type: "text", text: `Failed: ${result.message}` }] };
|
||||||
|
}
|
||||||
|
const lines = [
|
||||||
|
`Applied ${result.applied_count} field move(s), ${result.failed_count} failed.`,
|
||||||
|
];
|
||||||
|
for (const f of result.failed || []) {
|
||||||
|
lines.push(` ✗ ${f.reference}.${f.property}: ${f.reason}`);
|
||||||
|
}
|
||||||
|
return { content: [{ type: "text", text: lines.join("\n") }] };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Auto-position all Ref/Value fields
|
||||||
|
server.tool(
|
||||||
|
"autoplace_schematic_fields",
|
||||||
|
"Automatically reposition every component's Reference and Value field so they sit outside the component body AND outside any net labels attached to its pins, avoiding collisions with other components and already-placed fields. Like KiCAD's built-in field auto-placement but net-label aware. Optionally limit to specific references.",
|
||||||
|
{
|
||||||
|
schematicPath: z.string().describe("Path to the .kicad_sch file"),
|
||||||
|
references: z
|
||||||
|
.array(z.string())
|
||||||
|
.optional()
|
||||||
|
.describe("Only reposition these references (default: all components)"),
|
||||||
|
clearance: z
|
||||||
|
.number()
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
"Gap in mm between the body/label extent and field text (default one 1.27mm grid unit)",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
async (args: any) => {
|
||||||
|
const result = await callKicadScript("autoplace_schematic_fields", args);
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: result.success ? result.message : `Failed: ${result.message || "Unknown error"}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
212
tests/test_schematic_field_layout.py
Normal file
212
tests/test_schematic_field_layout.py
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
"""
|
||||||
|
Unit + integration tests for schematic field-placement/layout commands.
|
||||||
|
|
||||||
|
The text S-expression helpers and the set/batch property writers are exercised against a
|
||||||
|
real minimal .kicad_sch written to a temp file (pure text manipulation — no KiCad needed).
|
||||||
|
autoplace_schematic_fields is tested for parameter validation; the geometry helpers are
|
||||||
|
unit-tested directly.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent / "python"))
|
||||||
|
|
||||||
|
from commands.schematic_field_layout import ( # noqa: E402
|
||||||
|
SchematicFieldLayoutCommands,
|
||||||
|
_bbox_overlaps,
|
||||||
|
_gather_labels,
|
||||||
|
)
|
||||||
|
from commands.schematic_text_utils import ( # noqa: E402
|
||||||
|
_extract_component_properties,
|
||||||
|
_extract_property_position,
|
||||||
|
_extract_property_visible,
|
||||||
|
_find_matching_paren,
|
||||||
|
_find_placed_symbol_block,
|
||||||
|
_get_sheet_usable_area,
|
||||||
|
_move_property_in_block,
|
||||||
|
)
|
||||||
|
|
||||||
|
MINIMAL_SCH = """(kicad_sch (version 20230121) (paper "A4")
|
||||||
|
(lib_symbols
|
||||||
|
(symbol "Device:R" (property "Reference" "R" (at 0 0 0)))
|
||||||
|
)
|
||||||
|
(symbol (lib_id "Device:R") (at 100 100 0) (uuid "11111111")
|
||||||
|
(property "Reference" "R1" (at 102.0 98.0 0)
|
||||||
|
(effects (font (size 1.27 1.27))))
|
||||||
|
(property "Value" "10k" (at 102.0 102.0 0)
|
||||||
|
(effects (font (size 1.27 1.27))))
|
||||||
|
)
|
||||||
|
(symbol (lib_id "Device:C") (at 150 100 0) (uuid "22222222")
|
||||||
|
(property "Reference" "C1" (at 152.0 98.0 0)
|
||||||
|
(effects (font (size 1.27 1.27))))
|
||||||
|
(property "Value" "100nF" (at 152.0 102.0 0)
|
||||||
|
(effects (font (size 1.27 1.27))))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class TestTextHelpers:
|
||||||
|
def test_find_matching_paren(self):
|
||||||
|
s = "(a (b) c)"
|
||||||
|
assert _find_matching_paren(s, 0) == len(s) - 1
|
||||||
|
assert _find_matching_paren(s, 3) == 5
|
||||||
|
|
||||||
|
def test_find_placed_symbol_block_skips_lib_symbols(self):
|
||||||
|
block, start, end = _find_placed_symbol_block(MINIMAL_SCH, "R1")
|
||||||
|
assert block is not None
|
||||||
|
# Must be the placed instance (has lib_id + (at 100 100 0)), not the lib_symbols def
|
||||||
|
assert '(lib_id "Device:R")' in block and "(at 100 100 0)" in block
|
||||||
|
|
||||||
|
def test_find_placed_symbol_block_missing(self):
|
||||||
|
assert _find_placed_symbol_block(MINIMAL_SCH, "R99") == (None, -1, -1)
|
||||||
|
|
||||||
|
def test_extract_property_position(self):
|
||||||
|
block, _, _ = _find_placed_symbol_block(MINIMAL_SCH, "R1")
|
||||||
|
assert _extract_property_position(block, "Reference") == {
|
||||||
|
"x": 102.0,
|
||||||
|
"y": 98.0,
|
||||||
|
"angle": 0.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_extract_property_visible(self):
|
||||||
|
block, _, _ = _find_placed_symbol_block(MINIMAL_SCH, "R1")
|
||||||
|
assert _extract_property_visible(block, "Reference") is True
|
||||||
|
|
||||||
|
def test_extract_component_properties(self):
|
||||||
|
block, _, _ = _find_placed_symbol_block(MINIMAL_SCH, "R1")
|
||||||
|
props = _extract_component_properties(block)
|
||||||
|
assert props["Reference"] == "R1" and props["Value"] == "10k"
|
||||||
|
|
||||||
|
def test_move_property_in_block_and_hide(self):
|
||||||
|
block, _, _ = _find_placed_symbol_block(MINIMAL_SCH, "R1")
|
||||||
|
new_block, n = _move_property_in_block(block, "Reference", 105, 95, 0, visible=False)
|
||||||
|
assert n == 1
|
||||||
|
assert "(at 105 95 0)" in new_block
|
||||||
|
assert "(hide yes)" in new_block
|
||||||
|
# Re-show it
|
||||||
|
reshown, _ = _move_property_in_block(new_block, "Reference", 105, 95, 0, visible=True)
|
||||||
|
assert "(hide yes)" not in reshown
|
||||||
|
|
||||||
|
def test_move_property_missing(self):
|
||||||
|
block, _, _ = _find_placed_symbol_block(MINIMAL_SCH, "R1")
|
||||||
|
_, n = _move_property_in_block(block, "Footprint", 1, 2, 0, True)
|
||||||
|
assert n == 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestGeometryHelpers:
|
||||||
|
def test_bbox_overlaps(self):
|
||||||
|
a = {"x_min": 0, "y_min": 0, "x_max": 10, "y_max": 10}
|
||||||
|
b = {"x_min": 5, "y_min": 5, "x_max": 15, "y_max": 15}
|
||||||
|
c = {"x_min": 20, "y_min": 20, "x_max": 30, "y_max": 30}
|
||||||
|
assert _bbox_overlaps(a, b)
|
||||||
|
assert not _bbox_overlaps(a, c)
|
||||||
|
assert _bbox_overlaps(a, c, margin=11) # margin bridges the gap
|
||||||
|
|
||||||
|
def test_get_sheet_usable_area_default_a4(self, tmp_path):
|
||||||
|
f = tmp_path / "a4.kicad_sch"
|
||||||
|
f.write_text('(kicad_sch (paper "A4")')
|
||||||
|
left, top, right, bottom = _get_sheet_usable_area(str(f))
|
||||||
|
assert (left, top) == (12.7, 12.7)
|
||||||
|
assert right == pytest.approx(297.0 - 12.7)
|
||||||
|
assert bottom == pytest.approx(210.0 - 12.7)
|
||||||
|
|
||||||
|
def test_get_sheet_usable_area_a3(self, tmp_path):
|
||||||
|
f = tmp_path / "a3.kicad_sch"
|
||||||
|
f.write_text('(kicad_sch (paper "A3")')
|
||||||
|
_, _, right, _ = _get_sheet_usable_area(str(f))
|
||||||
|
assert right == pytest.approx(420.0 - 12.7)
|
||||||
|
|
||||||
|
def test_gather_labels(self):
|
||||||
|
def lbl(v, at):
|
||||||
|
return types.SimpleNamespace(value=v, at=types.SimpleNamespace(value=at))
|
||||||
|
|
||||||
|
sch = types.SimpleNamespace(
|
||||||
|
label=[lbl("SDA", [1, 2, 0])], global_label=[lbl("VBUS", [3, 4, 90])]
|
||||||
|
)
|
||||||
|
labels = _gather_labels(sch)
|
||||||
|
assert {x["name"] for x in labels} == {"SDA", "VBUS"}
|
||||||
|
vbus = next(x for x in labels if x["name"] == "VBUS")
|
||||||
|
assert vbus["type"] == "global" and vbus["angle"] == 90.0
|
||||||
|
|
||||||
|
|
||||||
|
class TestSetPropertyPosition:
|
||||||
|
def _sch(self, tmp_path):
|
||||||
|
f = tmp_path / "x.kicad_sch"
|
||||||
|
f.write_text(MINIMAL_SCH)
|
||||||
|
return f
|
||||||
|
|
||||||
|
def test_missing_params(self):
|
||||||
|
c = SchematicFieldLayoutCommands()
|
||||||
|
assert (
|
||||||
|
c.set_schematic_property_position({"schematicPath": "/x", "reference": "R1"})["success"]
|
||||||
|
is False
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_rejects_non_ref_value(self, tmp_path):
|
||||||
|
f = self._sch(tmp_path)
|
||||||
|
c = SchematicFieldLayoutCommands()
|
||||||
|
r = c.set_schematic_property_position(
|
||||||
|
{"schematicPath": str(f), "reference": "R1", "property": "Footprint", "x": 1, "y": 2}
|
||||||
|
)
|
||||||
|
assert r["success"] is False
|
||||||
|
|
||||||
|
def test_moves_field(self, tmp_path):
|
||||||
|
f = self._sch(tmp_path)
|
||||||
|
c = SchematicFieldLayoutCommands()
|
||||||
|
r = c.set_schematic_property_position(
|
||||||
|
{"schematicPath": str(f), "reference": "R1", "property": "Reference", "x": 105, "y": 95}
|
||||||
|
)
|
||||||
|
assert r["success"] is True
|
||||||
|
content = f.read_text()
|
||||||
|
assert '(property "Reference" "R1" (at 105 95 0)' in content
|
||||||
|
# C1 left untouched
|
||||||
|
assert '(property "Reference" "C1" (at 152.0 98.0 0)' in content
|
||||||
|
|
||||||
|
def test_component_not_found(self, tmp_path):
|
||||||
|
f = self._sch(tmp_path)
|
||||||
|
c = SchematicFieldLayoutCommands()
|
||||||
|
r = c.set_schematic_property_position(
|
||||||
|
{"schematicPath": str(f), "reference": "R99", "property": "Value", "x": 1, "y": 2}
|
||||||
|
)
|
||||||
|
assert r["success"] is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestBatchSetPropertyPositions:
|
||||||
|
def test_batch_applies_and_reports(self, tmp_path):
|
||||||
|
f = tmp_path / "x.kicad_sch"
|
||||||
|
f.write_text(MINIMAL_SCH)
|
||||||
|
c = SchematicFieldLayoutCommands()
|
||||||
|
r = c.batch_set_schematic_property_positions(
|
||||||
|
{
|
||||||
|
"schematicPath": str(f),
|
||||||
|
"updates": [
|
||||||
|
{"reference": "R1", "property": "Reference", "x": 90, "y": 90},
|
||||||
|
{"reference": "C1", "property": "Value", "x": 160, "y": 110},
|
||||||
|
{"reference": "R99", "property": "Value", "x": 0, "y": 0}, # should fail
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert r["applied_count"] == 2
|
||||||
|
assert r["failed_count"] == 1
|
||||||
|
assert r["success"] is False # because one failed
|
||||||
|
content = f.read_text()
|
||||||
|
assert '(property "Reference" "R1" (at 90 90 0)' in content
|
||||||
|
assert '(property "Value" "100nF" (at 160 110 0)' in content
|
||||||
|
|
||||||
|
def test_requires_updates(self, tmp_path):
|
||||||
|
f = tmp_path / "x.kicad_sch"
|
||||||
|
f.write_text(MINIMAL_SCH)
|
||||||
|
c = SchematicFieldLayoutCommands()
|
||||||
|
assert (
|
||||||
|
c.batch_set_schematic_property_positions({"schematicPath": str(f)})["success"] is False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestAutoplaceValidation:
|
||||||
|
def test_autoplace_requires_path(self):
|
||||||
|
assert SchematicFieldLayoutCommands().autoplace_schematic_fields({})["success"] is False
|
||||||
Reference in New Issue
Block a user