from dataclasses import dataclass
from typing import List, Tuple, Optional, Dict
import math, random
# ---------- Data model ----------
[docs]
@dataclass
class Rect:
cx: float
cy: float
w: float
h: float
fill: str # preview hint only
role: str # "mask" | "void" | "led"
angle: float = 0.0 # orientation in *JITX coords*: 0:+X, 90:+Y (up), 180:-X, 270:-Y
# ---------- Helpers ----------
def _split_rect(rect, orient, t):
x, y, w, h = rect
if orient == "V":
x_cut = x + w * t
return (x, y, w * t, h), (x_cut, y, w * (1 - t), h)
else:
y_cut = y + h * t
return (x, y, w, h * t), (x, y_cut, w, h * (1 - t))
def _angle_to_vec(angle_deg: float) -> Tuple[float, float]:
a = math.radians(angle_deg % 360.0)
return math.cos(a), math.sin(a) # JITX y-up
def _faces(angle_deg: float, to_unit: Tuple[float, float]) -> bool:
"""Is the orientation within 45° of the given unit vector?"""
vx, vy = _angle_to_vec(angle_deg)
ux, uy = to_unit
return vx * ux + vy * uy >= math.sqrt(2) / 2.0 # cos 45°
# ---------- LED orientation / validation ----------
[docs]
def compute_led_orientations(
rects: List[Rect],
width: float,
height: float,
led_diam: float = 6.0,
led_extra: float = 2.0,
clearance: float = 0.25,
rng: Optional[random.Random] = None,
) -> None:
"""Assign Rect.angle so Øled_diam + led_extra tail avoids edges/other LEDs. Coordinates are JITX (y-up)."""
if rng is None:
rng = random.Random()
sites = [r for r in rects if r.role == "led"]
if not sites:
return
R = led_diam / 2.0
L = led_extra
left_edge, right_edge = -width / 2.0, width / 2.0
bottom_edge, top_edge = -height / 2.0, height / 2.0
def edge_allowed(r: Rect, angle: float) -> bool:
right_clear = right_edge - r.cx
left_clear = r.cx - left_edge
up_clear = top_edge - r.cy
down_clear = r.cy - bottom_edge
if min(right_clear, left_clear, up_clear, down_clear) < R + clearance:
return False
if angle % 360 == 0:
return right_clear >= R + L + clearance
if angle % 360 == 90:
return up_clear >= R + L + clearance
if angle % 360 == 180:
return left_clear >= R + L + clearance
if angle % 360 == 270:
return down_clear >= R + L + clearance
return False
def pair_clear(ri: Rect, ai: float, rj: Rect, aj: float) -> bool:
dx, dy = (rj.cx - ri.cx), (rj.cy - ri.cy)
d = math.hypot(dx, dy)
if d == 0:
return False
base = 2 * (R + clearance)
ux, uy = dx / d, dy / d
add = (L if _faces(ai, (ux, uy)) else 0.0) + (
L if _faces(aj, (-ux, -uy)) else 0.0
)
return d >= base + add
# allowed orientations per site (edge-safe)
allowed = {
id(r): [a for a in (0, 90, 180, 270) if edge_allowed(r, a)] for r in sites
}
for r in sites:
if not allowed[id(r)]:
allowed[id(r)] = [0, 90, 180, 270]
# greedy assign then repair
assigned: List[Tuple[Rect, float]] = []
for r in sites:
chosen = None
for a in allowed[id(r)]:
if all(pair_clear(r, a, rj, aj) for (rj, aj) in assigned):
chosen = a
break
if chosen is None:
# pick one that seems least interactive (heuristic)
chosen = rng.choice(allowed[id(r)])
r.angle = chosen
assigned.append((r, chosen))
for _ in range(8):
changed = False
for i in range(len(sites)):
for j in range(i + 1, len(sites)):
ri, rj = sites[i], sites[j]
if not pair_clear(ri, ri.angle, rj, rj.angle):
# try alternative for j
for a in allowed[id(rj)]:
if a != rj.angle and pair_clear(ri, ri.angle, rj, a):
rj.angle = a
changed = True
break
if not changed:
break
[docs]
def validate_led_layout(
rects: List[Rect],
width: float,
height: float,
led_diam: float = 6.0,
led_extra: float = 2.0,
clearance: float = 0.25,
) -> List[str]:
"""Return list of violations (empty if OK). Coordinates are JITX (y-up)."""
R = led_diam / 2.0
L = led_extra
left_edge, right_edge = -width / 2.0, width / 2.0
bottom_edge, top_edge = -height / 2.0, height / 2.0
def edge_ok(r: Rect) -> bool:
right_clear = right_edge - r.cx
left_clear = r.cx - left_edge
up_clear = top_edge - r.cy
down_clear = r.cy - bottom_edge
if min(right_clear, left_clear, up_clear, down_clear) < R + clearance:
return False
if r.angle % 360 == 0:
return right_clear >= R + L + clearance
if r.angle % 360 == 90:
return up_clear >= R + L + clearance
if r.angle % 360 == 180:
return left_clear >= R + L + clearance
if r.angle % 360 == 270:
return down_clear >= R + L + clearance
return False
def pair_ok(ri: Rect, rj: Rect) -> bool:
dx, dy = (rj.cx - ri.cx), (rj.cy - ri.cy)
d = math.hypot(dx, dy)
if d == 0:
return False
base = 2 * (R + clearance)
ux, uy = dx / d, dy / d
add = 0.0
if _faces(ri.angle, (ux, uy)):
add += L
if _faces(rj.angle, (-ux, -uy)):
add += L
return d >= base + add
errs: List[str] = []
leds = [r for r in rects if r.role == "led"]
for i, r in enumerate(leds):
if not edge_ok(r):
errs.append(f"LED {i} edge-clearance violation (angle={r.angle})")
for i in range(len(leds)):
for j in range(i + 1, len(leds)):
if not pair_ok(leds[i], leds[j]):
errs.append(f"LEDs {i} & {j} overlap")
return errs
# ---------- Generator ----------
[docs]
def mondrian_bsp(
width: float = 180.0,
height: float = 300.0,
line_thickness: float = 3.0,
min_size: float = 22.0,
target_count: int = 70,
irregularity: float = 0.35,
avoid_align_eps: float = 1.5,
role_weights: Optional[Dict[str, float]] = None,
colors: Optional[Dict[str, str]] = None,
seed: Optional[int] = None,
snap_step: float = 0.25,
led_diam: float = 6.0,
led_extra: float = 2.0,
led_clearance: float = 0.25,
led_count: Optional[int] = None,
) -> List[Rect]:
"""Mondrian-ish BSP subdivision. Returns rectangles only, in JITX coords (y-up).
Args:
width: Board width in mm
height: Board height in mm
line_thickness: Thickness of lines between rectangles
min_size: Minimum rectangle size
target_count: Target number of rectangles to generate
irregularity: How irregular the splits should be (0.0-1.0)
avoid_align_eps: Minimum distance between split lines
role_weights: Probability weights for rectangle roles (mask, void, led)
colors: Colors for each role type
seed: Random seed for reproducible results
snap_step: Grid snapping step size
led_diam: LED diameter for clearance calculations
led_extra: LED tail length for clearance calculations
led_clearance: Minimum clearance around LEDs
led_count: Exact number of LEDs to generate (if specified, overrides probability-based assignment)
Returns:
List of Rect objects with assigned roles and orientations
"""
rng = random.Random(seed)
if role_weights is None:
role_weights = {"mask": 0.45, "void": 0.22, "led": 0.15}
if colors is None:
colors = {"mask": "#e5e5e5", "void": "#928647", "led": "#efc221"}
rects_raw = [(-width / 2.0, -height / 2.0, width, height)]
v_splits: List[float] = []
h_splits: List[float] = []
loops = 0
while len(rects_raw) < target_count and loops < target_count * 10:
loops += 1
candidates = [
r for r in rects_raw if r[2] >= 2 * min_size or r[3] >= 2 * min_size
]
if not candidates:
break
r = rng.choices(candidates, weights=[ri[2] * ri[3] for ri in candidates], k=1)[
0
]
rects_raw.remove(r)
x, y, w, h = r
if w > h * 1.15:
orient = "V"
elif h > w * 1.15:
orient = "H"
else:
orient = "V" if rng.random() < 0.5 else "H"
if orient == "V" and w < 2 * min_size:
orient = "H"
if orient == "H" and h < 2 * min_size:
orient = "V"
center_bias = max(min(0.5 + (rng.random() - 0.5) * irregularity, 0.8), 0.2)
if orient == "V":
pos = max(x + min_size, min(x + w * center_bias, x + w - min_size))
if any(abs(pos - xs) < avoid_align_eps for xs in v_splits):
pos += (rng.random() - 0.5) * (avoid_align_eps * 1.2)
pos = max(x + min_size, min(pos, x + w - min_size))
v_splits.append(pos)
else:
pos = max(y + min_size, min(y + h * center_bias, y + h - min_size))
if any(abs(pos - ys) < avoid_align_eps for ys in h_splits):
pos += (rng.random() - 0.5) * (avoid_align_eps * 1.2)
pos = max(y + min_size, min(pos, y + h - min_size))
h_splits.append(pos)
t = (pos - (x if orient == "V" else y)) / (w if orient == "V" else h)
a, b = _split_rect(r, orient, t)
rects_raw.extend([a, b])
# Build rectangles; snap *edges* (not sizes) for uniform line thickness.
margin = line_thickness / 2.0
temp_rects: List[Rect] = []
for x, y, w, h in rects_raw:
x0, y0, x1, y1 = x, y, x + w, y + h
if snap_step:
x0 = round(x0 / snap_step) * snap_step
y0 = round(y0 / snap_step) * snap_step
x1 = round(x1 / snap_step) * snap_step
y1 = round(y1 / snap_step) * snap_step
xx = x0 + margin
yy = y0 + margin
ww = (x1 - x0) - 2 * margin
hh = (y1 - y0) - 2 * margin
if ww <= 0 or hh <= 0:
continue
# Create temporary rect without role assignment
temp_rects.append(Rect(xx + ww / 2.0, yy + hh / 2.0, ww, hh, "", "", 0.0))
# Assign roles
rects: List[Rect] = []
if led_count is not None and led_count > 0 and len(temp_rects) >= led_count:
# Exact LED count specified - randomly select LEDs
led_indices = rng.sample(range(len(temp_rects)), led_count)
non_led_roles = [role for role in role_weights.keys() if role != "led"]
non_led_weights = [role_weights[role] for role in non_led_roles]
for i, temp_rect in enumerate(temp_rects):
if i in led_indices:
role = "led"
else:
role = rng.choices(non_led_roles, weights=non_led_weights, k=1)[0]
fill = colors.get(role, "#cccccc")
rects.append(
Rect(
temp_rect.cx,
temp_rect.cy,
temp_rect.w,
temp_rect.h,
fill,
role,
0.0,
)
)
else:
# Use probability-based assignment (original behavior)
roles = list(role_weights.keys())
role_w = [role_weights[k] for k in roles]
for temp_rect in temp_rects:
role = rng.choices(roles, weights=role_w, k=1)[0]
fill = colors.get(role, "#cccccc")
rects.append(
Rect(
temp_rect.cx,
temp_rect.cy,
temp_rect.w,
temp_rect.h,
fill,
role,
0.0,
)
)
# Choose LED angles and validate in generator space (JITX space)
compute_led_orientations(
rects, width, height, led_diam, led_extra, led_clearance, rng
)
return rects