gen_layout.py
Python, 121 lines from UI overview
#!/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])