Source code for jitxexamples.generative_rectangles.rectangles

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