Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
2126c3284This file is pinned to the selected release. Download the original to open it in your design tool.
"""Functional-group frames — border rectangle + title per module.
The visual half of the groups feature: membership lives on placed symbols as
the hidden ``Group`` property (written by grouped ops), and this module turns
it back into geometry — per group, the world bounding box of every member
(body ∪ pin tips, the same math ``arrange``/``check --layout`` use) padded by
a margin, emitted as ``add_rectangle`` + ``add_text`` ops through the standard
draw pipeline (atomic write, verify gate, ``.bak``/undo).
Frames refresh in place: both ops carry a ``key`` (``group_frame:<name>`` /
``group_title:<name>``), so their uuids are stable across coordinate changes —
move the parts, re-run ``akcli groups --frame --apply``, and the old border is
replaced, never accumulated.
"""
from __future__ import annotations
import math
from pathlib import Path
from .. import sexpr
from ..readers import kicad as _krd
from ..safety import read_text_guarded
from ..sexpr.query import root_uuid as doc_root_uuid
from ..writers import identity
from .collect import collect as _collect
FRAME_MARGIN_MIL = 200.0
TITLE_LIFT_MIL = 100.0
_GRID_MIL = 50.0
def _frame_key(name: str) -> str:
return f"group_frame:{name}"
def _title_key(name: str) -> str:
return f"group_title:{name}"
def frame_uuid(root_uuid: str | None, name: str) -> str:
"""The deterministic uuid ``add_rectangle {key: group_frame:<name>}`` gets."""
return identity.node_uuid(
root_uuid, identity.seed_annotation_key("rectangle", _frame_key(name)))
def _snap_out(v: float, *, up: bool) -> float:
"""Snap outward (away from the box) to the 50-mil grid."""
f = math.ceil if up else math.floor
return f(v / _GRID_MIL) * _GRID_MIL
def group_boxes(path: str | Path) -> dict[str, dict]:
"""``{group: {"members": [ref, ...], "box": [x0, y0, x1, y1]}}`` (world mils).
Groups come from the ``Group`` symbol property; each member's box is its
drawn body UNION pin tips (via ``arrange._collect``), so the union can
never disagree with where wires actually land.
"""
root = sexpr.parse(read_text_guarded(path))
syms, _anchors = _collect(root)
boxes_by_ref: dict[str, list] = {}
for s in syms:
b = boxes_by_ref.setdefault(s.ref, [s.box.x0, s.box.y0, s.box.x1, s.box.y1])
# multi-unit parts: several placed instances share one ref — union them
b[0] = min(b[0], s.box.x0)
b[1] = min(b[1], s.box.y0)
b[2] = max(b[2], s.box.x1)
b[3] = max(b[3], s.box.y1)
groups: dict[str, dict] = {}
for sym in _krd._placed_symbols(root):
props = _krd._props(sym)
gname = props.get("Group")
ref = props.get("Reference")
if not gname or not ref or ref not in boxes_by_ref:
continue
b = boxes_by_ref[ref]
entry = groups.setdefault(gname, {"members": set(), "box": list(b)})
entry["members"].add(ref)
eb = entry["box"]
eb[0] = min(eb[0], b[0])
eb[1] = min(eb[1], b[1])
eb[2] = max(eb[2], b[2])
eb[3] = max(eb[3], b[3])
for entry in groups.values():
entry["members"] = sorted(entry["members"])
return dict(sorted(groups.items()))
def group_report(path: str | Path) -> list[dict]:
"""Per-group summary rows for ``akcli groups`` (members, box, frame state)."""
root = sexpr.parse(read_text_guarded(path))
root_uuid = doc_root_uuid(root)
present: set[str] = set()
for c in root.children or []:
kids = c.children or []
if not c.is_list or not kids or kids[0].value != "rectangle":
continue
un = c.find("uuid")
ukids = un.children or [] if un is not None else []
if len(ukids) >= 2:
present.add(str(ukids[1].value))
rows: list[dict] = []
for name, info in group_boxes(path).items():
x0, y0, x1, y1 = info["box"]
rows.append({
"name": name,
"members": info["members"],
"box_mil": [x0, y0, x1, y1],
"width_mil": x1 - x0,
"height_mil": y1 - y0,
"has_frame": frame_uuid(root_uuid, name) in present,
})
return rows
def plan_frames(path: str | Path, *, groups_meta: dict | None = None,
margin_mil: float = FRAME_MARGIN_MIL) -> list[dict]:
"""The ``add_rectangle``/``add_text`` ops that (re)draw every group frame.
``groups_meta`` may map a group name to ``{"title": ...}`` (the op-list
envelope shape); the title defaults to the group name. Frame corners snap
OUTWARD to the grid so the border never clips a member.
"""
meta = groups_meta or {}
ops: list[dict] = []
for name, info in group_boxes(path).items():
x0, y0, x1, y1 = info["box"]
start = [_snap_out(x0 - margin_mil, up=False),
_snap_out(y0 - margin_mil, up=False)]
end = [_snap_out(x1 + margin_mil, up=True),
_snap_out(y1 + margin_mil, up=True)]
raw_meta = meta.get(name)
gmeta = raw_meta if isinstance(raw_meta, dict) else {}
title = gmeta.get("title") or name
ops.append({"op": "add_rectangle", "start": start, "end": end,
"key": _frame_key(name)})
ops.append({"op": "add_text", "text": str(title),
"at": [start[0], start[1] - TITLE_LIFT_MIL],
"key": _title_key(name)})
return ops