#!/usr/bin/env python3 """gen_layout.py: layout JSON (from dump_layout.js) -> Data_.luau modules for a Builder. Minimal, game-agnostic version of the generate step: - converts nodes to Roblox-ready dicts (class, name, position/size in design px, props), - applies TextSize = CSS font-size * 1.215 (Roblox TextSize is line height, not em), - dedupes raster art by content hash and names files by hash (stable across re-exports), - flags images over Roblox's 1024 px cap (slice them; see html-pipeline.md), - writes `return [==[]==]` so the Builder can HttpService:JSONDecode(require(module)). Usage: python3 gen_layout.py out/ src/ui/ # reads out/.json + out/raw/_.png """ import hashlib import json import os import shutil import sys FONT_FIX = 1.215 MAX_IMG = 1024 CAPTURE_SCALE = 2 # rasters were screenshotted at device scale 2 GUI_PROPERTY_NAMES = {"Active", "Selected", "Visible", "Position", "Size", "Rotation", "Parent", "Name", "ZIndex", "AnchorPoint", "Text", "Image", "Style", "Font"} def safe_name(name): # a child named after a property is shadowed: frame.Active returns the boolean, not the child return "N" + name if name in GUI_PROPERTY_NAMES else name def art_key(path, upload_dir, art): data = open(path, "rb").read() key = "a" + hashlib.md5(data).hexdigest()[:10] if key not in art: dst = os.path.join(upload_dir, key + ".png") shutil.copyfile(path, dst) try: from PIL import Image w, h = Image.open(dst).size if max(w, h) > MAX_IMG: print(f"WARN {key}: {w}x{h} exceeds {MAX_IMG}px; 3-slice or 9-slice it", file=sys.stderr) except ImportError: pass art[key] = True return "art:" + key def convert(n, page, raw_dir, upload_dir, art, origin): x, y, w, h = n["r"] ox, oy = origin out = {"n": safe_name(n.get("n", "Node")), "x": round(x - ox, 2), "y": round(y - oy, 2), "w": round(w, 2), "h": round(h, 2)} k = n["k"] if k == "raster": out["c"] = "ImageLabel" out["img"] = art_key(os.path.join(raw_dir, f"{page}_{n['rid']}.png"), upload_dir, art) elif k == "image": out["c"] = "ImageLabel" out["img"] = "icon:" + os.path.splitext(os.path.basename(n["src"]))[0] out["scaleType"] = "Fit" # never Stretch for icons elif k == "text": out["c"] = "TextLabel" out["text"] = n["text"] f = n["font"] out["font"] = f["family"] out["weight"] = f["weight"] out["textSize"] = round(f["size"] * FONT_FIX, 2) # Builder rounds; build at 2x to keep fractions out["color"] = n.get("color") out["align"] = n.get("align", "center") if "tstroke" in n: out["stroke"] = {"w": n["tstroke"]["w"], "c": n["tstroke"]["c"], "mode": "Contextual"} else: out["c"] = "Frame" if "bg" in n: out["bg"] = n["bg"] if "grad" in n: out["grad"] = n["grad"] # Builder: base colour white, UIGradient carries the colours if "radius" in n: out["radius"] = n["radius"] if "stroke" in n and out["c"] != "TextLabel": out["stroke"] = {"w": n["stroke"]["w"], "c": n["stroke"]["c"], "mode": "Border"} # children are positioned relative to this node kids = [convert(c, page, raw_dir, upload_dir, art, (x, y)) for c in n.get("ch", [])] if kids: out["ch"] = kids return out def main(out_dir, src_dir): raw_dir = os.path.join(out_dir, "raw") upload_dir = os.path.join(out_dir, "upload") os.makedirs(upload_dir, exist_ok=True) os.makedirs(src_dir, exist_ok=True) art = {} for fn in sorted(os.listdir(out_dir)): if not fn.endswith(".json") or fn.startswith("upload"): continue page = fn[:-5] tree = json.load(open(os.path.join(out_dir, fn))) groups = [] for g in tree: node = convert(g, page, raw_dir, upload_dir, art, (0, 0)) node["anchor"] = g.get("anchor", "center") groups.append(node) screen = page[0].upper() + page[1:] body = json.dumps({"screen": screen, "groups": groups}, separators=(",", ":")) assert "]==]" not in body with open(os.path.join(src_dir, f"Data_{screen}.luau"), "w") as f: f.write(f"-- generated by gen_layout.py from {fn}; do not edit by hand\nreturn [==[{body}]==]\n") print(f"{screen}: {len(groups)} groups") json.dump({"art": sorted(art)}, open(os.path.join(out_dir, "upload_list.json"), "w"), indent=1) print(f"{len(art)} unique art files -> {upload_dir}") if __name__ == "__main__": if len(sys.argv) != 3: sys.exit(__doc__) main(sys.argv[1], sys.argv[2])