Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
8c8d6fdc8This file is pinned to the selected release. Download the original to open it in your design tool.
"""Schematic label declutter — re-orient overlapping net/global labels so their
text lands in free space and becomes readable.
Phase 1: net/global labels only. Free-form text, Ref/Value fields, and symbol
spreading + wire reroute are deliberately separate, later phases.
Why labels and not "placement": a net label's (at x y) IS its electrical
connection point. Moving the anchor off its wire would disconnect the net. So
this tool holds every label's anchor FIXED and only changes its orientation
(0/90/180/270) and justification — which side the text renders toward. That
throws the text into empty space without touching connectivity.
Dry run by default (mirrors suggest_placement): returns proposals
{name, at, from_angle -> to_angle} plus an overlap score, WITHOUT modifying the
schematic. Set apply=true to rewrite the label blocks.
Symbol spreading + wire reroute is a deliberately separate, later phase — it
changes geometry and must rebuild wires/junctions, which is far riskier.
The geometry is kept as module-level pure functions so it is unit-testable
without a live KiCad / kicad-skip environment.
"""
from __future__ import annotations
import logging
import re
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
logger = logging.getLogger("kicad_interface")
_GRID = 1.27 # 50-mil KiCad schematic grid (mm)
_CHARS_PER_MM = 1.5 # matches schematic_field_layout's label_bbox model
_TEXT_HEIGHT = 1.27
_ORIENTATIONS = (0, 90, 180, 270)
# ──────────────────────────────────────────────────────────────────────────
# Pure geometry (unit-testable, no KiCad deps)
# ──────────────────────────────────────────────────────────────────────────
def _label_bbox(lx: float, ly: float, angle: float, name: str) -> Dict[str, float]:
"""Axis-aligned bbox of a label's text, anchored at (lx, ly), for one of the
four orientations. Mirrors schematic_field_layout.label_bbox so overlap
results stay consistent with the rest of the codebase."""
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: # text extends +x (justify left)
return {"x_min": lx, "y_min": ly - half_h, "x_max": lx + length, "y_max": ly + half_h}
if a == 90: # text extends -y (up)
return {"x_min": lx - half_h, "y_min": ly - length, "x_max": lx + half_h, "y_max": ly}
if a == 180: # text extends -x (justify right)
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 _bbox_overlaps(a: Dict[str, float], b: Dict[str, float], margin: float = 0.0) -> bool:
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"]
)
def _justify_for_angle(angle: float) -> str:
"""KiCad flips horizontal justification for the left-pointing orientations."""
a = round(angle / 90) * 90 % 360
return "right" if a in (180, 270) else "left"
def _best_orientation(
label: Dict[str, Any],
obstacles: List[Dict[str, float]],
margin: float,
) -> Tuple[int, int]:
"""Pick the orientation whose text bbox collides with the fewest obstacles.
Ties prefer the label's CURRENT angle (stability), then 0/180 over 90/270
(horizontal text reads easier). Returns (angle, collision_count)."""
lx, ly, name = label["x"], label["y"], label["name"]
cur = round(label["angle"] / 90) * 90 % 360
def collisions(angle: int) -> int:
bb = _label_bbox(lx, ly, angle, name)
return sum(1 for ob in obstacles if _bbox_overlaps(bb, ob, margin))
best_angle, best_n, best_rank = cur, collisions(cur), (0, 0)
for angle in _ORIENTATIONS:
n = collisions(angle)
# rank: prefer current angle, then horizontal orientations
rank = (0 if angle == cur else 1, 0 if angle in (0, 180) else 1)
if n < best_n or (n == best_n and rank < best_rank):
best_angle, best_n, best_rank = angle, n, rank
return best_angle, best_n
def _count_label_overlaps(
labels: List[Dict[str, Any]],
static_obstacles: List[Dict[str, float]],
margin: float,
) -> int:
"""Total collision incidents: label–label pairs plus label–obstacle hits,
using each label's current angle."""
bbs = [_label_bbox(lb["x"], lb["y"], lb["angle"], lb["name"]) for lb in labels]
n = 0
for i in range(len(bbs)):
for j in range(i + 1, len(bbs)):
if _bbox_overlaps(bbs[i], bbs[j], margin):
n += 1
for ob in static_obstacles:
if _bbox_overlaps(bbs[i], ob, margin):
n += 1
return n
def plan_label_declutter(
labels: List[Dict[str, Any]],
static_obstacles: List[Dict[str, float]],
margin: float,
) -> Tuple[List[Dict[str, Any]], int, int]:
"""Core solver (pure). `labels` = [{name,x,y,angle,type}]; `static_obstacles`
= fixed bboxes (component bodies, free text, pins). Greedily re-orients each
label that currently collides, accounting for the labels already re-oriented
this pass. Returns (proposals, overlaps_before, overlaps_after)."""
before = _count_label_overlaps(labels, static_obstacles, margin)
# Work on a copy of angles; treat other labels (with their working angle) as
# obstacles too, so we don't just shove the collision next door.
work = [dict(lb) for lb in labels]
proposals: List[Dict[str, Any]] = []
# Process most-collided labels first for a better greedy outcome.
def cur_collisions(idx: int) -> int:
lb = work[idx]
bb = _label_bbox(lb["x"], lb["y"], lb["angle"], lb["name"])
obs = static_obstacles + [
_label_bbox(o["x"], o["y"], o["angle"], o["name"])
for k, o in enumerate(work)
if k != idx
]
return sum(1 for ob in obs if _bbox_overlaps(bb, ob, margin))
order = sorted(range(len(work)), key=cur_collisions, reverse=True)
for idx in order:
lb = work[idx]
if cur_collisions(idx) == 0:
continue
others = static_obstacles + [
_label_bbox(o["x"], o["y"], o["angle"], o["name"])
for k, o in enumerate(work)
if k != idx
]
new_angle, _ = _best_orientation(lb, others, margin)
if new_angle != round(lb["angle"] / 90) * 90 % 360:
proposals.append(
{
"type": lb.get("type", "net"),
"name": lb["name"],
"at": [round(lb["x"], 3), round(lb["y"], 3)],
"from_angle": round(lb["angle"] / 90) * 90 % 360,
"to_angle": new_angle,
"from_justify": _justify_for_angle(lb["angle"]),
"to_justify": _justify_for_angle(new_angle),
}
)
lb["angle"] = new_angle
after = _count_label_overlaps(work, static_obstacles, margin)
return proposals, before, after
# ──────────────────────────────────────────────────────────────────────────
# Raw-text editing for apply
# ──────────────────────────────────────────────────────────────────────────
def _reorient_label_in_text(
content: str, name: str, x: float, y: float, new_angle: int, new_justify: str
) -> Tuple[str, bool]:
"""Rewrite the (at x y angle) and (justify ...) of the label whose name and
anchor match (x, y) within grid tolerance. Returns (new_content, changed)."""
# Match a label / global_label / hierarchical_label block opening with the
# given quoted name, then its (at X Y A). Tolerant float compare on X/Y.
name_esc = re.escape(name)
pat = re.compile(
r'(\((?:label|global_label|hierarchical_label)\s+"'
+ name_esc
+ r'"\s+\(at\s+)(-?[0-9.]+)\s+(-?[0-9.]+)\s+(-?[0-9.]+)(\))'
)
out = []
last = 0
changed = False
for m in pat.finditer(content):
lx, ly = float(m.group(2)), float(m.group(3))
if abs(lx - x) > _GRID / 2 or abs(ly - y) > _GRID / 2:
continue
# Replace the angle in this (at ...).
out.append(content[last : m.start()])
out.append(f"{m.group(1)}{m.group(2)} {m.group(3)} {new_angle}{m.group(5)}")
last = m.end()
changed = True
out.append(content[last:])
content = "".join(out)
if changed:
# Update the FIRST justify after this label's anchor. Best-effort: rewrite
# the justify token nearest the matched anchor coordinates.
content = _set_justify_near(content, name, x, y, new_justify)
return content, changed
def _set_justify_near(content: str, name: str, x: float, y: float, justify: str) -> str:
"""Set the horizontal justify of the label block identified by name+anchor.
Replaces an existing (justify left|right ...) keeping any vertical token; if
the label has NO justify token (KiCad omits it for default-left labels),
injects one as the last child of the (effects ...) group so a 180/270
reorientation actually flips the text."""
name_esc = re.escape(name)
block_pat = re.compile(
r'\((?:label|global_label|hierarchical_label)\s+"'
+ name_esc
+ r'"\s+\(at\s+(-?[0-9.]+)\s+(-?[0-9.]+)\s+-?[0-9.]+\).*?\(uuid',
re.DOTALL,
)
just_pat = re.compile(r"\(justify\s+(left|right)([^)]*)\)")
def repl_block(bm: "re.Match[str]") -> str:
lx, ly = float(bm.group(1)), float(bm.group(2))
if abs(lx - x) > _GRID / 2 or abs(ly - y) > _GRID / 2:
return bm.group(0)
block = bm.group(0)
if just_pat.search(block):
return just_pat.sub(lambda jm: f"(justify {justify}{jm.group(2)})", block, count=1)
return _inject_justify_into_effects(block, justify)
return block_pat.sub(repl_block, content)
def _inject_justify_into_effects(block: str, justify: str) -> str:
"""Insert (justify <justify>) as the last child of the first (effects ...)
group in `block`, by paren-matching (quote-aware). Returns block unchanged
if there is no effects group."""
idx = block.find("(effects")
if idx == -1:
return block
depth = 0
in_str = False
i = idx
while i < len(block):
c = block[i]
if c == '"' and block[i - 1] != "\\":
in_str = not in_str
elif not in_str:
if c == "(":
depth += 1
elif c == ")":
depth -= 1
if depth == 0:
return block[:i] + f" (justify {justify})" + block[i:]
i += 1
return block
# ──────────────────────────────────────────────────────────────────────────
# Command handler
# ──────────────────────────────────────────────────────────────────────────
class SchematicDeclutterCommands:
"""Handler for suggest_schematic_declutter."""
def suggest_schematic_declutter(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Re-orient overlapping net/global labels so their text is readable.
Args:
schematicPath: Path to the .kicad_sch file (required).
margin: Extra clearance in mm when testing overlap (default 0.3).
references: Optional list limiting which component bodies count as
obstacles / which sheet area to consider (default: whole sheet).
apply: If true, rewrite the label orientations. Default false (dry
run — returns proposals only, schematic untouched).
Returns:
{success, proposals:[{name, at, from_angle, to_angle, ...}],
score:{overlaps_before, overlaps_after}, applied, summary}
"""
try:
schematic_path = params.get("schematicPath")
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}"}
margin = float(params.get("margin", 0.3))
apply = bool(params.get("apply", False))
labels, obstacles = self._gather(sch_path, params.get("references"))
if len(labels) < 1:
return {
"success": True,
"proposals": [],
"score": {"overlaps_before": 0, "overlaps_after": 0},
"applied": False,
"summary": {"labels": 0, "note": "No net/global labels found."},
}
proposals, before, after = plan_label_declutter(labels, obstacles, margin)
applied = False
if apply and proposals:
content = sch_path.read_text(encoding="utf-8")
n = 0
for p in proposals:
content, changed = _reorient_label_in_text(
content,
p["name"],
p["at"][0],
p["at"][1],
p["to_angle"],
p["to_justify"],
)
n += 1 if changed else 0
if n:
sch_path.write_text(content, encoding="utf-8")
applied = True
return {
"success": True,
"proposals": proposals,
"score": {"overlaps_before": before, "overlaps_after": after},
"applied": applied,
"summary": {
"labels": len(labels),
"obstacles": len(obstacles),
"reoriented": len(proposals),
"margin_mm": margin,
"note": (
(
"Dry run — schematic unchanged. Re-run with apply=true "
"to write the new label orientations."
)
if not applied
else "Applied (label anchors unchanged; nets intact)."
),
},
}
except Exception as e: # noqa: BLE001
logger.error(f"suggest_schematic_declutter failed: {e}", exc_info=True)
return {
"success": False,
"message": "suggest_schematic_declutter failed",
"errorDetails": str(e),
}
# -- data gathering (kept thin; geometry lives in the pure functions) ----
def _gather(
self, sch_path: Path, references: Optional[List[str]]
) -> Tuple[List[Dict[str, Any]], List[Dict[str, float]]]:
"""Collect net/global labels and the static obstacle bboxes (component
bodies + free text). Reuses the field-layout gatherers."""
from commands.pin_locator import PinLocator
from commands.schematic import SchematicManager
from commands.schematic_field_layout import _gather_components, _gather_labels
schematic = SchematicManager.load_schematic(str(sch_path))
if not schematic:
raise RuntimeError("Failed to load schematic")
raw = sch_path.read_text(encoding="utf-8")
locator = PinLocator()
labels = [
{
"name": lb["name"],
"x": lb["position"]["x"],
"y": lb["position"]["y"],
"angle": lb.get("angle", 0),
"type": lb.get("type", "net"),
}
for lb in _gather_labels(schematic)
]
ref_set = set(references) if references else None
obstacles: List[Dict[str, float]] = []
for comp in _gather_components(schematic, sch_path, raw, locator):
if ref_set is not None and comp["reference"] not in ref_set:
continue
bb = comp.get("body_bbox")
if bb:
obstacles.append(dict(bb))
return labels, obstacles