Join the RoClicks Discord
RoClicks

gen_layout.py

Python, 121 lines from UI overview

Download gen_layout.py
#!/usr/bin/env python3
"""gen_layout.py: layout JSON (from dump_layout.js) -> Data_<Screen>.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 [==[<json>]==]` so the Builder can HttpService:JSONDecode(require(module)).

Usage:
  python3 gen_layout.py out/ src/ui/          # reads out/<screen>.json + out/raw/<screen>_<rid>.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])