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.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,
|
||||
|
||||
Reference in New Issue
Block a user