feat(schematic): expose justify directive on field labels via MCP

Add optional `justify` property to `fieldPositions` entries in
`edit_schematic_component` and to `set_schematic_component_property`.

Changes:
- `python/kicad_interface.py`: new `_set_justify_on_property()` helper
  that adds/replaces/removes the `(justify ...)` token inside a property's
  `(effects ...)` block. Passing "center" (the KiCad default) removes the
  directive entirely. Integrated into `_set_property_in_block()` (for the
  `properties` dict path) and into the `field_positions` loop in
  `_handle_edit_schematic_component()`. `_handle_set_schematic_component_property()`
  now forwards `justify` from params through to the spec dict.
  Also fixes pre-existing mypy type-ignore on `circle.radius` (kipy stub).
- `src/tools/schematic.ts`: extend the `fieldPositions` Zod schema to accept
  `justify?: string | string[]` (array form normalised to a space-separated
  string before the Python call). Add `justify?: string` to
  `set_schematic_component_property`.
- `python/commands/routing.py`: fix pre-existing mypy error — annotate `ex`
  and `ey` as `float` in `_point_to_segment_distance_nm`.
- `python/commands/pin_locator.py`: fix pre-existing mypy error — use explicit
  `str()` cast on `pin_data["number"]` before `dict.get()` call.
- `tests/test_schematic_field_justify.py`: 14 unit + integration tests
  covering add/replace/remove of the justify directive and backward
  compatibility (calls without justify leave existing directives untouched).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gavin Colonese
2026-05-22 13:43:43 -04:00
parent d2ea43d061
commit 7313037dc3
4 changed files with 409 additions and 7 deletions

View File

@@ -1539,7 +1539,10 @@ class KiCADInterface:
Args:
block: The full text of the (symbol ...) block.
name: Property name (e.g. "MPN", "Manufacturer").
spec: Dict that may contain keys: value, x, y, angle, hide, fontSize.
spec: Dict that may contain keys: value, x, y, angle, hide, fontSize,
justify. ``justify`` is a space-separated string of KiCad
alignment keywords (e.g. "left", "right top"). "center" removes
the directive (KiCad default).
default_position: (x, y) of the parent symbol — used as the default
location for newly-created properties so the field is anchored
near the component, not at (0, 0).
@@ -1554,6 +1557,7 @@ class KiCADInterface:
new_y = spec.get("y")
new_angle = spec.get("angle")
new_hide = spec.get("hide")
new_justify = spec.get("justify")
font_size = spec.get("fontSize", 1.27)
existing_match = re.search(
@@ -1594,6 +1598,9 @@ class KiCADInterface:
if new_hide is not None:
block = self._set_hide_on_property(block, name, bool(new_hide))
if new_justify is not None:
block = self._set_justify_on_property(block, name, str(new_justify))
return block, "updated"
# Property does not exist — append a new one after the last existing property
@@ -1612,9 +1619,16 @@ class KiCADInterface:
escaped = self._escape_sexpr_string(str(new_value))
escaped_name = self._escape_sexpr_string(str(name))
# Build optional (justify ...) token for the effects block.
justify_str = ""
if new_justify is not None:
tokens = str(new_justify).strip().split()
if not all(t == "center" for t in tokens):
justify_str = f" (justify {str(new_justify).strip()})"
new_prop = (
f' (property "{escaped_name}" "{escaped}" (at {cx} {cy} {ca})\n'
f" (effects (font (size {font_size} {font_size})) {hide_str})\n"
f" (effects (font (size {font_size} {font_size})){justify_str} {hide_str})\n"
f" )"
)
@@ -1675,6 +1689,53 @@ class KiCADInterface:
new_effects = "(" + eff_inner + ")"
return block[:eff_start] + new_effects + block[eff_end + 1 :]
def _set_justify_on_property(self, block: str, name: str, justify: str) -> str:
"""Set or clear the (justify ...) directive on a named property's effects clause.
``justify`` is a space-separated string of KiCad alignment keywords:
horizontal: "left" | "right" | "center"
vertical: "top" | "bottom" | "center"
Any combination of one or two tokens is accepted, e.g. "left", "right top".
Passing "center" (the KiCad default) removes the (justify ...) directive
entirely, which is how KiCad represents centered alignment.
Handles effects clauses that already contain a (justify ...) token, and
those that do not.
"""
import re
prop_match = re.search(
r'\(property\s+"' + re.escape(name) + r'"',
block,
)
if not prop_match:
return block
prop_start = prop_match.start()
prop_end = self._find_matching_paren(block, prop_start)
if prop_end < 0:
return block
prop_segment = block[prop_start : prop_end + 1]
eff_match = re.search(r"\(effects\b", prop_segment)
if not eff_match:
return block
eff_start = prop_start + eff_match.start()
eff_end = self._find_matching_paren(block, eff_start)
if eff_end < 0:
return block
eff_inner = block[eff_start + 1 : eff_end] # 'effects (font ...) ...'
# Remove any pre-existing (justify ...) token
eff_inner = re.sub(r"\s*\(justify\b[^)]*\)", "", eff_inner)
# "center" is the KiCad default — omitting the directive means centered
tokens = justify.strip().split()
is_center_only = all(t == "center" for t in tokens)
if not is_center_only:
eff_inner = eff_inner.rstrip() + f" (justify {justify.strip()})"
new_effects = "(" + eff_inner + ")"
return block[:eff_start] + new_effects + block[eff_end + 1 :]
def _remove_property_from_block(self, block: str, name: str) -> Tuple[str, bool]:
"""Remove a property from the symbol block. Returns (new_block, removed_bool)."""
import re
@@ -1885,6 +1946,11 @@ class KiCADInterface:
rf"\1(at {x} {y} {angle})",
block_text,
)
justify = pos.get("justify")
if justify is not None:
block_text = self._set_justify_on_property(
block_text, field_name, str(justify)
)
properties_added: Dict[str, Any] = {}
properties_updated: Dict[str, Any] = {}
@@ -1973,7 +2039,7 @@ class KiCADInterface:
return {"success": False, "message": "value is required"}
spec: Dict[str, Any] = {"value": params["value"]}
for key in ("x", "y", "angle", "hide", "fontSize"):
for key in ("x", "y", "angle", "hide", "fontSize", "justify"):
if params.get(key) is not None:
spec[key] = params[key]
@@ -6002,7 +6068,7 @@ print("ok")
# Create circle on Edge.Cuts layer for the hole
circle = BoardCircle()
circle.center = Vector2.from_xy(from_mm(x), from_mm(y))
circle.radius = from_mm(diameter / 2)
circle.radius = from_mm(diameter / 2) # type: ignore[assignment,method-assign]
circle.layer = BoardLayer.BL_Edge_Cuts
circle.attributes.stroke.width = from_mm(0.1)