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:
Ravi
2026-06-02 19:38:54 -07:00
committed by mixelpixx
parent 2dcfc0f4c2
commit 7c385b993f
7 changed files with 1073 additions and 0 deletions

View 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)}

View 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

View File

@@ -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,

View File

@@ -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));

View File

@@ -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",

View 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"}`,
},
],
};
},
);
}

View 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