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.schematic import SchematicManager
from commands.schematic_hierarchy import SchematicHierarchyCommands
from commands.schematic_field_layout import SchematicFieldLayoutCommands
from commands.symbol_creator import SymbolCreator
from commands.symbol_pins import SymbolPinCommands
@@ -450,6 +451,8 @@ class KiCADInterface:
self.symbol_pin_commands = SymbolPinCommands()
# Schematic hierarchy commands (insert sheets, scaffold sub-sheets)
self.hierarchy_commands = SchematicHierarchyCommands(self)
# Schematic field placement / layout-check commands
self.field_layout_commands = SchematicFieldLayoutCommands()
# Initialize JLCPCB API integration
self.jlcpcb_client = JLCPCBClient() # Official API (requires auth)
@@ -540,6 +543,10 @@ class KiCADInterface:
# Schematic hierarchy commands (sheet insertion + subsheet scaffolding)
"add_hierarchical_sheet": self.hierarchy_commands.add_hierarchical_sheet,
"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)
"download_jlcpcb_database": self._handle_download_jlcpcb_database,
"search_jlcpcb_parts": self._handle_search_jlcpcb_parts,