"""Render a Solution to a PNG image. The renderer draws a grid mirroring the in-game inventory: each cell is a fixed-size square; cells with slabs show the slab image (downloaded from the CDN on first run and cached locally). Effect numbers from `compute_effects` are overlaid on each cell. """ from __future__ import annotations import os from io import BytesIO from typing import Dict, Optional from PIL import Image, ImageDraw, ImageFont from .slabs import ( GRID_COLS, Placement, SLABS_BY_VALUE, compute_effects, ) from .solver import Solution CACHE_DIR = os.path.join( os.environ.get("LOCALAPPDATA") or os.environ.get("XDG_CACHE_HOME") or os.path.expanduser("~/.cache"), "sephiria_inv", "slabs", ) CDN_BASE = "https://img.sephiria.wiki" CELL = 96 # pixels per slot PAD = 12 HEADER = 56 FOOTER = 40 BG_COLOR = (29, 16, 27) CELL_BG = (50, 30, 50) CELL_BORDER = (88, 60, 90) POS_COLOR = (132, 220, 132) NEG_COLOR = (240, 110, 110) ZERO_COLOR = (160, 160, 160) IGNORE_COLOR = (160, 160, 160) TEXT_COLOR = (236, 226, 232) # --------------------------------------------------------------------------- # Asset cache # --------------------------------------------------------------------------- def _ensure_cache_dir() -> None: os.makedirs(CACHE_DIR, exist_ok=True) def _local_path(slab_image: str) -> str: # slab.image is like "slabs/foo.png"; flatten to filename return os.path.join(CACHE_DIR, os.path.basename(slab_image)) def _fetch_image(rel_or_url: str, timeout: float = 10.0) -> Optional[Image.Image]: """Fetch an image from the CDN. Accepts a full URL or a 'slabs/foo.png' path.""" _ensure_cache_dir() path = _local_path(rel_or_url) if os.path.exists(path): try: return Image.open(path).convert("RGBA") except Exception: pass try: import requests url = rel_or_url if rel_or_url.startswith("http") else f"{CDN_BASE}/{rel_or_url.lstrip('/')}" r = requests.get(url, timeout=timeout) if r.status_code != 200: return None with open(path, "wb") as f: f.write(r.content) return Image.open(BytesIO(r.content)).convert("RGBA") except Exception: return None def fetch_slab_image(slab_image: str, timeout: float = 10.0) -> Optional[Image.Image]: """Return a PIL Image for the slab. Caches under CACHE_DIR.""" return _fetch_image(slab_image, timeout=timeout) def fetch_artifact_image(url: str, timeout: float = 10.0) -> Optional[Image.Image]: """Return a PIL Image for an artifact (full URL from artifacts.json).""" return _fetch_image(url, timeout=timeout) # --------------------------------------------------------------------------- # Font # --------------------------------------------------------------------------- def _load_font(size: int) -> ImageFont.ImageFont: candidates = [ "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", "/usr/share/fonts/TTF/DejaVuSans-Bold.ttf", "C:\\Windows\\Fonts\\malgun.ttf", "C:\\Windows\\Fonts\\arial.ttf", "/System/Library/Fonts/Helvetica.ttc", ] for c in candidates: if os.path.exists(c): try: return ImageFont.truetype(c, size) except Exception: continue return ImageFont.load_default() # --------------------------------------------------------------------------- # Public API # --------------------------------------------------------------------------- def render_solution( sol: Solution, *, download: bool = True, title: Optional[str] = None, ) -> Image.Image: """Render the solution to a PIL Image.""" grid = sol.grid rows = len(grid) width = PAD * 2 + GRID_COLS * CELL height = PAD * 2 + HEADER + rows * CELL + FOOTER img = Image.new("RGB", (width, height), BG_COLOR) draw = ImageDraw.Draw(img) title_font = _load_font(22) cell_font = _load_font(20) eff_font = _load_font(28) foot_font = _load_font(16) # Title txt = title or "Sephiria Inventory Optimizer" draw.text((PAD, PAD), txt, fill=TEXT_COLOR, font=title_font) draw.text( (PAD, PAD + 26), f"slots {sol.slot_num} · placed {len(sol.placements)} · score {sol.score}", fill=(180, 180, 180), font=foot_font, ) # Effects map effects, flags = compute_effects(sol.placements, grid) by_slot: Dict[str, Placement] = {p.slot_id: p for p in sol.placements} grid_top = PAD + HEADER for row_cfg in grid: y = row_cfg["rows"] for x in range(row_cfg["cols"]): cx0 = PAD + x * CELL cy0 = grid_top + y * CELL cx1 = cx0 + CELL - 4 cy1 = cy0 + CELL - 4 # cell background draw.rounded_rectangle( (cx0, cy0, cx1, cy1), radius=8, fill=CELL_BG, outline=CELL_BORDER ) slot_id = f"{y}-{x}" p = by_slot.get(slot_id) if p is not None: slab = SLABS_BY_VALUE.get(p.value) placed = False if slab is not None and download: img_s = fetch_slab_image(slab.image) if img_s is not None: img_s = img_s.resize((CELL - 16, CELL - 16)) if p.rotation: # PIL rotates counter-clockwise; sephiria rotation # is 90°-CW per step. img_s = img_s.rotate(-90 * p.rotation, expand=False) img.paste(img_s, (cx0 + 6, cy0 + 6), img_s) placed = True if not placed and slab is not None: # text fallback draw.text( (cx0 + 6, cy0 + 6), slab.ko_label, fill=TEXT_COLOR, font=cell_font, ) # effect overlay (top-right) v = effects.get(slot_id, 0) if flags.get(slot_id) == "ignore": label = "—" color = IGNORE_COLOR else: label = f"{v:+d}" if v else "0" color = ( POS_COLOR if v > 0 else NEG_COLOR if v < 0 else ZERO_COLOR ) tw = draw.textlength(label, font=eff_font) draw.text( (cx1 - tw - 6, cy0 + 4), label, fill=color, font=eff_font, ) # Footer draw.text( (PAD, height - FOOTER + 8), "Generated by sephiria_inv_program · data from img.sephiria.wiki", fill=(120, 120, 120), font=foot_font, ) return img def save_solution(sol: Solution, out_path: str, **kwargs) -> str: img = render_solution(sol, **kwargs) img.save(out_path) return out_path