codefig 0.17.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
codefig/regions.py ADDED
@@ -0,0 +1,727 @@
1
+ """Regions over arbitrary members (v0.7.0) — overlapping / non-contiguous groupings.
2
+
3
+ A :class:`Region` is drawn *behind* a :class:`~codefig.edges.Diagram`'s content around
4
+ any set of member nodes, wherever the layout put them: ``Region([prefill_cpu,
5
+ decode_cpu, xfer], label="KVCache pool", style="dashed")``. Unlike a
6
+ :class:`~codefig.primitives.Group` it does not own or move its members, so two regions
7
+ may share members and overlap (Mooncake / S-LoRA "pool spanning two instances").
8
+
9
+ * ``shape="rect"`` (rounded bounding box) or ``"hull"`` (rounded convex hull — hugs an
10
+ L-shaped or diagonal member set without swallowing non-members).
11
+ * ``style="filled"`` (translucent tint + thin outline, default), ``"dashed"`` /
12
+ ``"dotted"`` (outline only), or ``"outline"``.
13
+ * The label is placed at the candidate spot that collides least with boxes, edges and
14
+ other regions' labels: a top / bottom *strip* (left, centre or right; the outline
15
+ grows to include it) or *on the border* (``"border-top-left"`` …, a legend-style
16
+ label sitting on the outline with a knockout). ``label_pos=`` forces a spot.
17
+ * Non-members never sit inside a region (v0.8.0, ``exclude="auto"``): the panel layout
18
+ first *pushes* an intruding box out of the region along its cheapest axis (relational
19
+ constraints, see :mod:`codefig.constraints`); if that is impossible (the box is
20
+ pinned between members by a Row / Grid) the outline is *carved* — an orthogonal,
21
+ possibly non-contiguous outline that notches around the intruder.
22
+ ``exclude="push"`` / ``"carve"`` pick one mechanism, ``"none"`` disables both (lint
23
+ CF-REG-001 then warns).
24
+ * v0.9.0: ``exclude="auto"`` also considers *reordering* the intruder within its flex
25
+ container (just before / after the members' span) and keeps whichever candidate —
26
+ reorder or one of the four push sides — scores best on intrusions, edges through
27
+ boxes, edge crossings, then total edge length + footprint. ``"push"`` never
28
+ reorders. Reordering mutates the container's child order (see
29
+ ``Panel._region_moves`` for what was done). Carved outlines keep the full corner
30
+ radius outside and use a tighter one in the notches.
31
+ * Nested regions (members ⊂ another region's members) get extra padding automatically
32
+ so their outlines never coincide.
33
+
34
+ Regions are layout-neutral: the Diagram grows its margin when a region or its label
35
+ sticks out of the content box. They are not obstacles for edge routing.
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ import math
41
+ from typing import Any, Dict, List, Optional, Sequence, Tuple
42
+
43
+ from .node import Node
44
+ from .styles import themed
45
+ from .textutil import measure_text
46
+
47
+ Rect = Tuple[float, float, float, float]
48
+ Pt = Tuple[float, float]
49
+
50
+ _CORNERS = ("top-left", "top-right", "bottom-left", "bottom-right")
51
+ _STRIP = ("top-left", "top-right", "bottom-left", "bottom-right", "top-center", "bottom-center")
52
+ _SPOTS = _STRIP + tuple("border-" + c for c in _STRIP)
53
+ _EXCLUDE = ("auto", "push", "carve", "none")
54
+
55
+
56
+ def _hull(pts: Sequence[Pt]) -> List[Pt]:
57
+ """Convex hull (monotone chain), counter-clockwise in SVG coordinates."""
58
+ p = sorted(set((round(x, 3), round(y, 3)) for x, y in pts))
59
+ if len(p) <= 2:
60
+ return list(p)
61
+
62
+ def cross(o, a, b):
63
+ return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0])
64
+
65
+ lo: List[Pt] = []
66
+ for q in p:
67
+ while len(lo) >= 2 and cross(lo[-2], lo[-1], q) <= 0:
68
+ lo.pop()
69
+ lo.append(q)
70
+ up: List[Pt] = []
71
+ for q in reversed(p):
72
+ while len(up) >= 2 and cross(up[-2], up[-1], q) <= 0:
73
+ up.pop()
74
+ up.append(q)
75
+ return lo[:-1] + up[:-1]
76
+
77
+
78
+ def _signed_area(poly: Sequence[Pt]) -> float:
79
+ return 0.5 * sum(poly[i - 1][0] * poly[i][1] - poly[i][0] * poly[i - 1][1]
80
+ for i in range(len(poly)))
81
+
82
+
83
+ def _rounded_path(poly: Sequence[Pt], r: float, r_concave: Optional[float] = None) -> str:
84
+ """Closed path through ``poly`` with corners rounded by up to ``r``.
85
+
86
+ ``r_concave`` (v0.9.0) caps the radius of reflex (inward) corners — the notches
87
+ of a carved outline — so outer corners keep the full radius while a notch hugs
88
+ the excluded box without cutting into its clearance."""
89
+ n = len(poly)
90
+ if n < 3:
91
+ return ""
92
+ orient = 1.0 if _signed_area(poly) >= 0 else -1.0
93
+ out = []
94
+ for i in range(n):
95
+ p0, p1, p2 = poly[i - 1], poly[i], poly[(i + 1) % n]
96
+ v1 = (p0[0] - p1[0], p0[1] - p1[1])
97
+ v2 = (p2[0] - p1[0], p2[1] - p1[1])
98
+ l1, l2 = math.hypot(*v1), math.hypot(*v2)
99
+ rc = r
100
+ if r_concave is not None:
101
+ turn = (p1[0] - p0[0]) * (p2[1] - p1[1]) - (p1[1] - p0[1]) * (p2[0] - p1[0])
102
+ if turn * orient < 0:
103
+ rc = min(r, r_concave)
104
+ rr = min(rc, l1 / 2, l2 / 2)
105
+ a = (p1[0] + v1[0] / max(l1, 1e-9) * rr, p1[1] + v1[1] / max(l1, 1e-9) * rr)
106
+ b = (p1[0] + v2[0] / max(l2, 1e-9) * rr, p1[1] + v2[1] / max(l2, 1e-9) * rr)
107
+ out.append(("L" if out else "M") + f" {a[0]:.2f},{a[1]:.2f}")
108
+ out.append(f"Q {p1[0]:.2f},{p1[1]:.2f} {b[0]:.2f},{b[1]:.2f}")
109
+ return " ".join(out) + " Z"
110
+
111
+
112
+ def _overlap(a: Rect, b: Rect) -> float:
113
+ w = min(a[0] + a[2], b[0] + b[2]) - max(a[0], b[0])
114
+ h = min(a[1] + a[3], b[1] + b[3]) - max(a[1], b[1])
115
+ return w * h if w > 0 and h > 0 else 0.0
116
+
117
+
118
+ def _seg_hits(r: Rect, p: Pt, q: Pt) -> bool:
119
+ x, y, w, h = r
120
+ if max(p[0], q[0]) < x or min(p[0], q[0]) > x + w or max(p[1], q[1]) < y or min(p[1], q[1]) > y + h:
121
+ return False
122
+ if abs(p[0] - q[0]) < 1e-6 or abs(p[1] - q[1]) < 1e-6:
123
+ return True # axis-aligned segment whose bbox meets the rect
124
+ for t in (i / 12 for i in range(13)):
125
+ sx, sy = p[0] + (q[0] - p[0]) * t, p[1] + (q[1] - p[1]) * t
126
+ if x <= sx <= x + w and y <= sy <= y + h:
127
+ return True
128
+ return False
129
+
130
+
131
+ def _pip(x: float, y: float, poly: Sequence[Pt]) -> bool:
132
+ """Point in polygon (even-odd ray cast)."""
133
+ inside = False
134
+ n = len(poly)
135
+ for i in range(n):
136
+ ax, ay = poly[i]
137
+ bx, by = poly[(i + 1) % n]
138
+ if (ay > y) != (by > y):
139
+ xc = ax + (y - ay) * (bx - ax) / (by - ay)
140
+ if xc > x:
141
+ inside = not inside
142
+ return inside
143
+
144
+
145
+ def _poly_rect_area(poly: Sequence[Pt], r: Rect) -> float:
146
+ """Area of a convex polygon ∩ rect (Sutherland–Hodgman clip + shoelace)."""
147
+ if len(poly) < 3:
148
+ return 0.0
149
+ x0, y0, x1, y1 = r[0], r[1], r[0] + r[2], r[1] + r[3]
150
+ pts = list(poly)
151
+ for inside, inter in (
152
+ (lambda p: p[0] >= x0, lambda p, q: (x0, p[1] + (q[1] - p[1]) * (x0 - p[0]) / (q[0] - p[0]))),
153
+ (lambda p: p[0] <= x1, lambda p, q: (x1, p[1] + (q[1] - p[1]) * (x1 - p[0]) / (q[0] - p[0]))),
154
+ (lambda p: p[1] >= y0, lambda p, q: (p[0] + (q[0] - p[0]) * (y0 - p[1]) / (q[1] - p[1]), y0)),
155
+ (lambda p: p[1] <= y1, lambda p, q: (p[0] + (q[0] - p[0]) * (y1 - p[1]) / (q[1] - p[1]), y1)),
156
+ ):
157
+ out: List[Pt] = []
158
+ for i in range(len(pts)):
159
+ p, q = pts[i - 1], pts[i]
160
+ if inside(q):
161
+ if not inside(p):
162
+ out.append(inter(p, q))
163
+ out.append(q)
164
+ elif inside(p):
165
+ out.append(inter(p, q))
166
+ pts = out
167
+ if not pts:
168
+ return 0.0
169
+ a = 0.0
170
+ for i in range(len(pts)):
171
+ a += pts[i - 1][0] * pts[i][1] - pts[i][0] * pts[i - 1][1]
172
+ return abs(a) / 2.0
173
+
174
+
175
+ def _in_any(x: float, y: float, rects: Sequence[Rect]) -> bool:
176
+ return any(r[0] <= x <= r[0] + r[2] and r[1] <= y <= r[1] + r[3] for r in rects)
177
+
178
+
179
+ def _open_cut(c: Rect, bb: Tuple[float, float, float, float], keep: Sequence[Rect]) -> Rect:
180
+ """Extend an interior cut to the nearest outline side that no kept rect blocks, so an
181
+ intruder becomes a notch (or splits the region) instead of a framed hole."""
182
+ x, y, w, h = c
183
+ bx0, by0, bx1, by1 = bb
184
+ e = 1.0
185
+ opts = [(x - bx0, (bx0 - e, y, x + w - bx0 + e, h)),
186
+ (bx1 - x - w, (x, y, bx1 - x + e, h)),
187
+ (y - by0, (x, by0 - e, w, y + h - by0 + e)),
188
+ (by1 - y - h, (x, y, w, by1 - y + e))]
189
+ if min(d for d, _ in opts) <= 0:
190
+ return c # already reaches the outline
191
+ for _d, r in sorted(opts, key=lambda t: t[0]):
192
+ if not any(_overlap(r, k) > 0.0 for k in keep):
193
+ return r
194
+ return c
195
+
196
+
197
+ def _orth_carve(base: Sequence[Rect], keep: Sequence[Rect], cut: Sequence[Rect],
198
+ bridge: bool = False) -> List[List[Pt]]:
199
+ """Orthogonal outline(s) of (∪base − ∪cut) ∪ ∪keep, as closed loops (holes included).
200
+
201
+ ``bridge=True`` closes the area orthogonally (a cell between two inside cells of the
202
+ same row / column is inside unless cut) — the rectilinear analogue of a convex hull.
203
+ """
204
+ xs = sorted({v for r in list(base) + list(keep) + list(cut) for v in (r[0], r[0] + r[2])})
205
+ ys = sorted({v for r in list(base) + list(keep) + list(cut) for v in (r[1], r[1] + r[3])})
206
+ nx, ny = len(xs) - 1, len(ys) - 1
207
+ if nx < 1 or ny < 1:
208
+ return []
209
+ cx = [(xs[i] + xs[i + 1]) / 2 for i in range(nx)]
210
+ cy = [(ys[j] + ys[j + 1]) / 2 for j in range(ny)]
211
+ kept = [[_in_any(cx[i], cy[j], keep) for j in range(ny)] for i in range(nx)]
212
+ cutc = [[_in_any(cx[i], cy[j], cut) and not kept[i][j] for j in range(ny)] for i in range(nx)]
213
+ ins = [[(kept[i][j] or (_in_any(cx[i], cy[j], base) and not cutc[i][j]))
214
+ for j in range(ny)] for i in range(nx)]
215
+ if bridge:
216
+ for _ in range(4):
217
+ changed = False
218
+ for j in range(ny):
219
+ idx = [i for i in range(nx) if ins[i][j]]
220
+ if idx:
221
+ for i in range(idx[0], idx[-1] + 1):
222
+ if not ins[i][j] and not cutc[i][j]:
223
+ ins[i][j] = changed = True
224
+ for i in range(nx):
225
+ idx = [j for j in range(ny) if ins[i][j]]
226
+ if idx:
227
+ for j in range(idx[0], idx[-1] + 1):
228
+ if not ins[i][j] and not cutc[i][j]:
229
+ ins[i][j] = changed = True
230
+ if not changed:
231
+ break
232
+
233
+ def inside(i: int, j: int) -> bool:
234
+ return 0 <= i < nx and 0 <= j < ny and ins[i][j]
235
+
236
+ out_edges: Dict[Tuple[int, int], List[Tuple[int, int]]] = {}
237
+
238
+ def add(a, b):
239
+ out_edges.setdefault(a, []).append(b)
240
+
241
+ for i in range(nx):
242
+ for j in range(ny):
243
+ if not ins[i][j]:
244
+ continue
245
+ if not inside(i, j - 1):
246
+ add((i, j), (i + 1, j))
247
+ if not inside(i + 1, j):
248
+ add((i + 1, j), (i + 1, j + 1))
249
+ if not inside(i, j + 1):
250
+ add((i + 1, j + 1), (i, j + 1))
251
+ if not inside(i - 1, j):
252
+ add((i, j + 1), (i, j))
253
+ loops: List[List[Pt]] = []
254
+ while out_edges:
255
+ start = next(iter(out_edges))
256
+ loop = [start]
257
+ cur = start
258
+ prev_dir = None
259
+ while True:
260
+ nxt_list = out_edges.get(cur)
261
+ if not nxt_list:
262
+ break
263
+ if len(nxt_list) > 1 and prev_dir is not None:
264
+ # saddle: take the right turn (keeps diagonal cells apart)
265
+ def turn(b):
266
+ d = (b[0] - cur[0], b[1] - cur[1])
267
+ return -(prev_dir[0] * d[1] - prev_dir[1] * d[0])
268
+ nxt_list.sort(key=turn)
269
+ nxt = nxt_list.pop(0)
270
+ if not nxt_list:
271
+ del out_edges[cur]
272
+ prev_dir = (nxt[0] - cur[0], nxt[1] - cur[1])
273
+ cur = nxt
274
+ if cur == start:
275
+ break
276
+ loop.append(cur)
277
+ pts = [(xs[i], ys[j]) for i, j in loop]
278
+ # drop collinear vertices
279
+ simp: List[Pt] = []
280
+ n = len(pts)
281
+ for k in range(n):
282
+ a, b, c = pts[k - 1], pts[k], pts[(k + 1) % n]
283
+ if abs((b[0] - a[0]) * (c[1] - b[1]) - (b[1] - a[1]) * (c[0] - b[0])) > 1e-9:
284
+ simp.append(b)
285
+ if len(simp) >= 4:
286
+ loops.append(simp)
287
+ # outer loops (largest |area|) first
288
+ def area(lp):
289
+ return abs(sum(lp[k - 1][0] * lp[k][1] - lp[k][0] * lp[k - 1][1] for k in range(len(lp)))) / 2
290
+ loops.sort(key=lambda lp: -area(lp))
291
+ return loops
292
+
293
+
294
+ class Region:
295
+ """Translucent / dashed area around arbitrary member nodes (see module docs).
296
+
297
+ members: nodes (or anything with ``.geom``) inside one Diagram.
298
+ label: text in a strip or on the border (``label_pos`` auto | top-left | top-center |
299
+ top-right | bottom-… | border-top-left | …); ``label_color`` defaults to the region colour.
300
+ exclude: auto | push | carve | none — how non-members are kept out (module docs).
301
+ color: outline / tint colour (default: the theme's accents, cycled per region).
302
+ style: ``"filled"`` (tint + thin outline, default) | ``"dashed"`` | ``"dotted"`` |
303
+ ``"outline"`` (the last three: outline only, no tint).
304
+ fill_opacity: tint strength for ``style="filled"`` (default 0.10).
305
+ pad: clearance around members (theme length); ``radius``: corner rounding.
306
+ """
307
+
308
+ reg_pad = themed(7.0)
309
+ reg_nest = themed(6.0)
310
+
311
+ def __init__(self, members: Sequence[Any], label: Optional[str] = None, *,
312
+ shape: str = "rect", style: str = "filled", color: Optional[str] = None,
313
+ fill_opacity: float = 0.10, pad: Optional[float] = None,
314
+ radius: Optional[float] = None, label_pos: str = "auto",
315
+ label_color: Optional[str] = None, stroke_width: Optional[float] = None,
316
+ exclude: str = "auto", theme: Any = None) -> None:
317
+ if not members:
318
+ raise ValueError("Region needs at least one member")
319
+ from ._validate import check_enum
320
+ shape = check_enum("Region(shape)", shape, ("rect", "hull"))
321
+ style = check_enum("Region(style)", style, ("filled", "dashed", "dotted", "outline"))
322
+ label_pos = check_enum("Region(label_pos)", label_pos, ("auto",) + _SPOTS,
323
+ {"top": "top-center", "bottom": "bottom-center",
324
+ "left": "top-left", "right": "top-right"})
325
+ exclude = check_enum("Region(exclude)", exclude, _EXCLUDE)
326
+ self.exclude = exclude
327
+ self.members = list(members)
328
+ self.label = label
329
+ self.shape = shape
330
+ self.style = style
331
+ self.color = color
332
+ self.fill_opacity = float(fill_opacity)
333
+ self.reg_pad = pad
334
+ self.radius = radius
335
+ self.label_pos = label_pos
336
+ self.label_color = label_color
337
+ self.stroke_width = stroke_width
338
+ self._theme = theme
339
+ self._extra_pad = 0.0
340
+ self._geo: Optional[Dict[str, Any]] = None
341
+
342
+ # themed() looks for .theme
343
+ @property
344
+ def theme(self):
345
+ from .styles import current_theme
346
+
347
+ if self._theme is not None:
348
+ return self._theme
349
+ for m in self.members:
350
+ th = getattr(m, "theme", None)
351
+ if th is not None:
352
+ return th
353
+ return current_theme()
354
+
355
+ def _rects(self) -> List[Rect]:
356
+ out = []
357
+ for m in self.members:
358
+ g = getattr(m, "geom", None)
359
+ if g is not None and g.w > 0 and g.h > 0:
360
+ out.append((g.x, g.y, g.w, g.h))
361
+ return out
362
+
363
+ def _label_size(self) -> Tuple[float, float]:
364
+ if not self.label:
365
+ return 0.0, 0.0
366
+ th = self.theme
367
+ return measure_text(self.label, th.small_size, weight="bold")
368
+
369
+ def _member_ids(self) -> set:
370
+ ids = set()
371
+
372
+ def add(n):
373
+ ids.add(id(n))
374
+ for k in getattr(n, "children", lambda: ())():
375
+ add(k)
376
+
377
+ for m in self.members:
378
+ add(m)
379
+ return ids
380
+
381
+ # ---- geometry ---------------------------------------------------------------
382
+ def _pad(self) -> float:
383
+ return float(self.reg_pad) + self._extra_pad
384
+
385
+ def base_rect(self) -> Rect:
386
+ """Padded bounding box of the members (no label) in layout coordinates."""
387
+ rects = self._rects()
388
+ pad = self._pad()
389
+ x0 = min(r[0] for r in rects) - pad
390
+ y0 = min(r[1] for r in rects) - pad
391
+ x1 = max(r[0] + r[2] for r in rects) + pad
392
+ y1 = max(r[1] + r[3] for r in rects) + pad
393
+ return (x0, y0, x1 - x0, y1 - y0)
394
+
395
+ def _hull_poly(self, extra: Sequence[Rect] = ()) -> List[Pt]:
396
+ pad = self._pad()
397
+ pts: List[Pt] = []
398
+ for r in self._rects():
399
+ pts += [(r[0] - pad, r[1] - pad), (r[0] + r[2] + pad, r[1] - pad),
400
+ (r[0] + r[2] + pad, r[1] + r[3] + pad), (r[0] - pad, r[1] + r[3] + pad)]
401
+ for r in extra:
402
+ pts += [(r[0], r[1]), (r[0] + r[2], r[1]), (r[0] + r[2], r[1] + r[3]),
403
+ (r[0], r[1] + r[3])]
404
+ return _hull(pts)
405
+
406
+ def intrudes(self, rect: Rect, *, min_area: float = 0.5) -> bool:
407
+ """Would ``rect`` sit inside this region's *un-carved* area (padded bbox / hull)?"""
408
+ if self.shape == "hull":
409
+ return _poly_rect_area(self._hull_poly(), rect) > min_area
410
+ return _overlap(self.base_rect(), rect) > min_area
411
+
412
+ def solve(self, boxes: Sequence[Tuple[Any, Rect]], segs: Sequence[Tuple[Pt, Pt]],
413
+ taken: Sequence[Rect]) -> Dict[str, Any]:
414
+ """Pick the label spot and outline. ``boxes``: (node, rect) of drawn non-ancestor nodes."""
415
+ th = self.theme
416
+ rects = self._rects()
417
+ x0, y0, bw, bh = self.base_rect()
418
+ x1, y1 = x0 + bw, y0 + bh
419
+ lw, lh = self._label_size()
420
+ mine = self._member_ids()
421
+ others = [r for n, r in boxes if id(n) not in mine]
422
+ lp = th.L(3.0)
423
+ best = None
424
+ cands = _SPOTS if self.label_pos == "auto" else (self.label_pos,)
425
+ for k, c in enumerate(cands):
426
+ if not self.label:
427
+ break
428
+ border = c.startswith("border-")
429
+ cc = c[7:] if border else c
430
+ if cc.endswith("left"):
431
+ lx = x0 + lp + (th.L(3.0) if border else 0.0)
432
+ elif cc.endswith("right"):
433
+ lx = x1 - lp - lw - (th.L(3.0) if border else 0.0)
434
+ else:
435
+ lx = (x0 + x1) / 2 - lw / 2
436
+ top = cc.startswith("top")
437
+ if border:
438
+ ly = (y0 if top else y1) - lh / 2
439
+ ext = (min(x0, lx - lp), y0, max(x1, lx + lw + lp), y1)
440
+ strip = (lx - lp * 0.5, ly, lw + lp, lh)
441
+ else:
442
+ ly = y0 - lh - th.L(1.0) if top else y1 + th.L(1.0)
443
+ ext = (min(x0, lx - lp), min(y0, ly - lp * 0.6), max(x1, lx + lw + lp),
444
+ max(y1, ly + lh + lp * 0.6))
445
+ # the label pulls the outline outwards: what does that strip newly cover?
446
+ strip = (ext[0], ly - lp * 0.6, ext[2] - ext[0], lh + lp * 1.2)
447
+ lab = (lx, ly, lw, lh)
448
+ score = sum(_overlap(lab, r) for r in others) * 4.0
449
+ score += sum(_overlap(strip, r) for r in others)
450
+ score += sum(_overlap(lab, r) for r in taken) * 4.0
451
+ score += sum(1 for p, q in segs if _seg_hits(lab, p, q)) * lh * lw * 0.5
452
+ # mild preferences: strip before border, corners before centres, stable order
453
+ score += (lh * lw * 0.02 if border else 0.0) + k * 1e-3
454
+ if best is None or score < best[0]:
455
+ best = (score, c, lab, ext, border)
456
+ if best is not None:
457
+ _s, corner, lab, ext, border = best
458
+ ex0, ey0, ex1, ey1 = ext
459
+ else:
460
+ corner, lab, border = None, None, False
461
+ ex0, ey0, ex1, ey1 = x0, y0, x1, y1
462
+ lab_box = None
463
+ if lab is not None and not border:
464
+ lx, ly, lw_, lh_ = lab
465
+ lab_box = (lx - lp, ly - lp * 0.6, lw_ + 2 * lp, lh_ + lp * 1.2)
466
+ # non-members still inside the outline (push failed / exclude="carve")
467
+ if self.shape == "hull":
468
+ hull = self._hull_poly([lab_box] if lab_box else [])
469
+ inside = [r for r in others if _poly_rect_area(hull, r) > 0.5]
470
+ else:
471
+ ext_r = (ex0, ey0, ex1 - ex0, ey1 - ey0)
472
+ inside = [r for r in others if _overlap(ext_r, r) > 0.5]
473
+ loops: List[List[Pt]]
474
+ carved = bool(inside) and self.exclude in ("auto", "carve")
475
+ if carved:
476
+ gap = th.L(3.0)
477
+ keep = [(r[0] - 0.5, r[1] - 0.5, r[2] + 1.0, r[3] + 1.0) for r in rects]
478
+ if lab is not None:
479
+ keep.append((lab[0] - 0.5, lab[1] - 0.5, lab[2] + 1.0, lab[3] + 1.0))
480
+ cut = [(r[0] - gap, r[1] - gap, r[2] + 2 * gap, r[3] + 2 * gap) for r in inside]
481
+ if self.shape == "hull":
482
+ bb = (min(r[0] for r in rects) - self._pad(), min(r[1] for r in rects) - self._pad(),
483
+ max(r[0] + r[2] for r in rects) + self._pad(),
484
+ max(r[1] + r[3] for r in rects) + self._pad())
485
+ else:
486
+ bb = (ex0, ey0, ex1, ey1)
487
+ cut = [_open_cut(c, bb, keep) for c in cut]
488
+ if self.shape == "hull":
489
+ pad = self._pad()
490
+ base = [(r[0] - pad, r[1] - pad, r[2] + 2 * pad, r[3] + 2 * pad) for r in rects]
491
+ if lab_box:
492
+ base.append(lab_box)
493
+ loops = _orth_carve(base, keep, cut, bridge=True)
494
+ else:
495
+ loops = _orth_carve([(ex0, ey0, ex1 - ex0, ey1 - ey0)], keep, cut)
496
+ elif self.shape == "hull":
497
+ loops = [hull]
498
+ else:
499
+ loops = [[(ex0, ey0), (ex1, ey0), (ex1, ey1), (ex0, ey1)]]
500
+ xs = [p[0] for lp_ in loops for p in lp_]
501
+ ys = [p[1] for lp_ in loops for p in lp_]
502
+ self._geo = {"poly": loops[0], "loops": loops, "label": lab, "corner": corner,
503
+ "border": border, "carved": carved,
504
+ "bbox": (min(xs), min(ys), max(xs) - min(xs), max(ys) - min(ys))}
505
+ return self._geo
506
+
507
+ def covers(self, rect: Rect, frac: float = 0.15) -> bool:
508
+ """Does the *drawn* outline cover more than ``frac`` of ``rect``? (lint CF-REG-001)"""
509
+ if self._geo is None:
510
+ return False
511
+ x, y, w, h = rect
512
+ if w <= 0 or h <= 0:
513
+ return False
514
+ n = 7
515
+ hit = 0
516
+ for i in range(n):
517
+ for j in range(n):
518
+ px = x + w * (i + 0.5) / n
519
+ py = y + h * (j + 0.5) / n
520
+ inside = False
521
+ for loop in self._geo["loops"]:
522
+ if _pip(px, py, loop):
523
+ inside = not inside
524
+ hit += inside
525
+ return hit / (n * n) > frac
526
+
527
+ @property
528
+ def label_rect(self) -> Optional[Rect]:
529
+ """(x, y, w, h) of the placed label after layout (None if unlabeled / not laid out)."""
530
+ geo = getattr(self, "_geo", None)
531
+ return geo["label"] if geo else None
532
+
533
+ @property
534
+ def bbox(self) -> Optional[Rect]:
535
+ """Outline bounding box after the Diagram laid it out (None before)."""
536
+ return None if self._geo is None else self._geo["bbox"]
537
+
538
+ def _color(self, index: int) -> str:
539
+ if self.color:
540
+ return self.color
541
+ acc = list(self.theme.accents) or ["#555555"]
542
+ return acc[index % len(acc)]
543
+
544
+ def draw(self, buf: List[str], ox: float, oy: float, index: int = 0) -> None:
545
+ from .render_svg import draw_path
546
+
547
+ if self._geo is None:
548
+ return
549
+ th = self.theme
550
+ col = self._color(index)
551
+ r = self.radius if self.radius is not None else th.L(8.0)
552
+ loops = self._geo["loops"]
553
+ if self._geo.get("carved"):
554
+ # v0.9.0: outer corners keep the full radius; reflex notch corners and
555
+ # hole loops get a tighter one so they hug the excluded box
556
+ rn = min(r, th.L(4.0))
557
+ big = max(range(len(loops)), key=lambda i: abs(_signed_area(loops[i]))) if loops else 0
558
+ d = " ".join(_rounded_path([(x + ox, y + oy) for x, y in lp_],
559
+ r if i == big else rn, rn)
560
+ for i, lp_ in enumerate(loops))
561
+ else:
562
+ d = " ".join(_rounded_path([(x + ox, y + oy) for x, y in lp_], r) for lp_ in loops)
563
+ sw = self.stroke_width if self.stroke_width is not None else th.stroke_width
564
+ dash = None
565
+ if self.style == "dashed":
566
+ dash = " ".join(f"{v:g}" for v in th.dash) if getattr(th, "dash", None) else "4 3"
567
+ elif self.style == "dotted":
568
+ dash = " ".join(f"{v:g}" for v in th.dot) if getattr(th, "dot", None) else "1 2"
569
+ if self.style == "filled":
570
+ draw_path(buf, d, fill=col, stroke="none", stroke_width=0, opacity=self.fill_opacity,
571
+ fill_rule="evenodd")
572
+ draw_path(buf, d, fill="none", stroke=col, stroke_width=sw, dasharray=dash)
573
+
574
+ def draw_label(self, buf: List[str], ox: float, oy: float, index: int = 0) -> None:
575
+ from .render_svg import draw_text
576
+
577
+ if self._geo is None or self._geo["label"] is None:
578
+ return
579
+ th = self.theme
580
+ lx, ly, lw, lh = self._geo["label"]
581
+ fs = th.small_size
582
+ col = self.label_color or self._color(index)
583
+ if self._geo.get("border"): # legend-style: knock the outline out behind the text
584
+ from .render_svg import draw_rect
585
+
586
+ kp = th.L(1.5)
587
+ draw_rect(buf, lx + ox - kp, ly + oy, lw + 2 * kp, lh, fill="#ffffff",
588
+ stroke="none", stroke_width=0)
589
+ draw_text(buf, self.label, lx + ox, ly + oy + lh / 2 + fs * 0.35, font_size=fs,
590
+ weight="bold", fill=col)
591
+
592
+
593
+ def diagram_boxes(diagram: Any) -> Tuple[List[Tuple[Any, Rect]], Dict[int, Any]]:
594
+ """(node, rect) of every drawn box inside ``diagram`` + a child-id → parent map."""
595
+ from .policy import role_of
596
+
597
+ parent: Dict[int, Any] = {}
598
+ boxes: List[Tuple[Any, Rect]] = []
599
+
600
+ def rec(n: Any) -> None:
601
+ kids = list(n.children())
602
+ for k in kids:
603
+ parent[id(k)] = n
604
+ if n is not diagram.content:
605
+ role = role_of(n)
606
+ if (role in ("hard", "soft") and not kids) or role == "hard":
607
+ g = n.geom
608
+ if g.w > 0 and g.h > 0:
609
+ boxes.append((n, (g.x, g.y, g.w, g.h)))
610
+ for k in kids:
611
+ rec(k)
612
+
613
+ rec(diagram.content)
614
+ return boxes, parent
615
+
616
+
617
+ def _ancestors(region: Region, parent: Dict[int, Any]) -> set:
618
+ out = set()
619
+ for m in region.members:
620
+ p = parent.get(id(m))
621
+ while p is not None and id(p) not in out:
622
+ out.add(id(p))
623
+ p = parent.get(id(p))
624
+ return out
625
+
626
+
627
+ def region_others(diagram: Any, region: Region,
628
+ pre: Optional[Tuple[List[Tuple[Any, Rect]], Dict[int, Any]]] = None
629
+ ) -> List[Tuple[Any, Rect]]:
630
+ """Drawn boxes that are neither members (nor inside one) nor ancestors of members."""
631
+ boxes, parent = pre or diagram_boxes(diagram)
632
+ skip = region._member_ids() | _ancestors(region, parent)
633
+ return [(n, r) for n, r in boxes if id(n) not in skip]
634
+
635
+
636
+ def region_intruders(diagram: Any, region: Region) -> List[Any]:
637
+ """Non-member boxes inside the region's un-carved area (targets of the layout push)."""
638
+ if not region._rects():
639
+ return []
640
+ out = []
641
+ for n, r in region_others(diagram, region):
642
+ if region.intrudes(r):
643
+ out.append(n)
644
+ # keep only outermost intruders (a Group intruding implies its children do)
645
+ ids = {id(n) for n in out}
646
+ _b, parent = diagram_boxes(diagram)
647
+
648
+ def has_intruding_ancestor(n):
649
+ p = parent.get(id(n))
650
+ while p is not None:
651
+ if id(p) in ids:
652
+ return True
653
+ p = parent.get(id(p))
654
+ return False
655
+
656
+ return [n for n in out if not has_intruding_ancestor(n)]
657
+
658
+
659
+ def push_constraints(region: Region, intruder: Any, side: str, margin: float) -> list:
660
+ """no_overlap constraints that move ``intruder`` to ``side`` of all members."""
661
+ from .constraints import no_overlap
662
+
663
+ axis = "x" if side in ("left", "right") else "y"
664
+ out = []
665
+ for m in region.members:
666
+ if side in ("right", "bottom"):
667
+ c = no_overlap(m, intruder, axis, margin=margin, strength="strong")
668
+ else:
669
+ c = no_overlap(intruder, m, axis, margin=margin, strength="strong")
670
+ c.subjects, c.refs = [intruder], [m]
671
+ out.append(c)
672
+ return out
673
+
674
+
675
+ def push_sides(region: Region, intruder: Any) -> List[Tuple[float, str]]:
676
+ """Candidate push directions for ``intruder``, cheapest displacement first."""
677
+ x0, y0, w, h = region.base_rect()
678
+ g = intruder.geom
679
+ moves = [(x0 + w - g.x, "right"), (g.x + g.w - x0, "left"),
680
+ (y0 + h - g.y, "bottom"), (g.y + g.h - y0, "top")]
681
+ return sorted(moves)
682
+
683
+
684
+ def nest_pads(regions: Sequence[Region]) -> None:
685
+ """Regions that strictly contain another region's members get extra padding."""
686
+ sets = [r._member_ids() for r in regions]
687
+ for i, r in enumerate(regions):
688
+ inner = sum(1 for j, s in enumerate(sets) if j != i and s < sets[i])
689
+ r._extra_pad = inner * float(r.reg_nest)
690
+
691
+
692
+ def layout_regions(diagram: Any, regions: Sequence[Region]) -> None:
693
+ """Solve every region of ``diagram`` (nest padding, label corners)."""
694
+ if not regions:
695
+ return
696
+ nest_pads(regions)
697
+ pre = diagram_boxes(diagram)
698
+ segs: List[Tuple[Pt, Pt]] = []
699
+ try:
700
+ for _e, pts in diagram._routed()[0]:
701
+ segs += [(pts[k], pts[k + 1]) for k in range(len(pts) - 1)]
702
+ except Exception: # pragma: no cover - routing failures are reported elsewhere
703
+ pass
704
+ taken: List[Rect] = []
705
+ # outer (bigger) regions first so inner labels avoid them
706
+ order = sorted(regions, key=lambda rr: -len(rr._member_ids()))
707
+ for r in order:
708
+ geo = r.solve(region_others(diagram, r, pre), segs, taken)
709
+ if geo["label"] is not None:
710
+ taken.append(geo["label"])
711
+ if len(order) > 1 and any(r.label for r in order):
712
+ # v0.12: second pass — each label also avoids the *outlines* of the other
713
+ # regions (an overlapping region's border used to run through a label)
714
+ def outline(rr: Region) -> List[Tuple[Pt, Pt]]:
715
+ g = rr._geo or {}
716
+ out = []
717
+ for lp_ in g.get("loops", []):
718
+ out += [(lp_[k], lp_[(k + 1) % len(lp_)]) for k in range(len(lp_))]
719
+ return out
720
+
721
+ for r in order:
722
+ if not r.label:
723
+ continue
724
+ others_segs = [sg for o in order if o is not r for sg in outline(o)]
725
+ others_lab = [o._geo["label"] for o in order
726
+ if o is not r and o._geo and o._geo["label"] is not None]
727
+ r.solve(region_others(diagram, r, pre), segs + others_segs, others_lab)