""" Schematic floor plan of a demo-map export or of a layout file: one outline per kit piece, coloured by role. python build/schematic.py [px_per_m] [x0,y0,x1,y1] Floors are filled by pack (hallways blue, interiors green, props orange, command centre violet), walls are grey outlines, doorways and doors red, windows cyan. Coordinates are Blender metres (x right, y up). """ import json import math import os import sys from PIL import Image, ImageDraw, ImageFont HERE = os.path.dirname(os.path.abspath(__file__)) KIT = os.path.join(os.path.dirname(HERE), "kit_export") src, dst = sys.argv[1], sys.argv[2] ppm = float(sys.argv[3]) if len(sys.argv) > 3 else 14.0 box = [float(v) for v in sys.argv[4].split(",")] if len(sys.argv) > 4 and sys.argv[4] != "-" else None zrange = [float(v) for v in sys.argv[5].split(",")] if len(sys.argv) > 5 else None # keep pieces whose origin z is inside with open(os.path.join(KIT, "meshes.json"), "r", encoding="utf-8") as f: MESHES = json.load(f) with open(src, "r", encoding="utf-8") as f: data = json.load(f) PACK_COL = {"ModularScifiHallways": (70, 130, 255), "ModularSci_Int": (60, 190, 110), "ModularScifiProps": (255, 160, 50), "ModularSci_Comm": (170, 100, 230)} def instances(): if "actors" in data: for a in data["actors"]: if a.get("hidden") or a.get("hidden_in_game"): continue for m in a.get("meshes", []): for rows in (m.get("instances") or [m["matrix"]]): yield m["mesh"], rows else: for it in data["instances"]: yield it["mesh"], it["matrix"] def footprint(mesh, rows): rec = MESHES.get(mesh) if rec is None: return None mn, mx = rec["min"], rec["max"] X, Y, Z, W = rows pts = [] zs = [] for cx in (mn[0], mx[0]): for cy in (mn[1], mx[1]): for cz in (mn[2], mx[2]): wx = X[0] * cx + Y[0] * cy + Z[0] * cz + W[0] wy = X[1] * cx + Y[1] * cy + Z[1] * cz + W[1] wz = X[2] * cx + Y[2] * cy + Z[2] * cz + W[2] zs.append(wz / 100.0) if cz == mn[2]: pts.append((wx / 100.0, -wy / 100.0)) # order the four bottom corners around the rectangle cxm = sum(p[0] for p in pts) / 4 cym = sum(p[1] for p in pts) / 4 pts.sort(key=lambda p: math.atan2(p[1] - cym, p[0] - cxm)) return pts, min(zs), max(zs) items = [] for mesh, rows in instances(): if not mesh.startswith("/Game/StarterBundle/"): continue pack = mesh.split("/")[3] if pack not in PACK_COL: continue if zrange and not (zrange[0] <= rows[3][2] / 100.0 <= zrange[1]): continue fp = footprint(mesh, rows) if fp is None: continue # the command centre's rings and shells are single meshes around the room centre: their boxes say nothing if pack == "ModularSci_Comm" and max(abs(fp[0][0][0] - fp[0][2][0]), abs(fp[0][0][1] - fp[0][2][1])) > 9.5: if "Floor_Center_A3" not in mesh: continue items.append((mesh.split("/")[-1], pack, fp[0], fp[1], fp[2])) allp = [p for it in items for p in it[2]] if box: x0, y0, x1, y1 = box else: x0, y0 = min(p[0] for p in allp) - 2, min(p[1] for p in allp) - 2 x1, y1 = max(p[0] for p in allp) + 2, max(p[1] for p in allp) + 2 W, H = int((x1 - x0) * ppm), int((y1 - y0) * ppm) im = Image.new("RGB", (W, H), (250, 250, 250)) d = ImageDraw.Draw(im, "RGBA") def px(p): return ((p[0] - x0) * ppm, (y1 - p[1]) * ppm) def role(name): n = name.lower() if "doorway" in n or "door_l" in n or "glassdoor" in n: return "door" if "window" in n or "win_frame" in n or "windows_" in n or "_glass" in n or "win_post" in n or "win_divider" in n: return "window" if "ceiling" in n or "light" in n or "pipe" in n or "wire" in n: return "over" if "floor" in n or "stairs" in n: return "floor" if "wall" in n or "corner" in n: return "wall" return "prop" order = {"floor": 0, "over": 1, "prop": 2, "wall": 3, "window": 4, "door": 5} items.sort(key=lambda it: order[role(it[0])]) for name, pack, pts, zlo, zhi in items: r = role(name) col = PACK_COL[pack] poly = [px(p) for p in pts] if r == "floor": d.polygon(poly, fill=col + (70,), outline=col + (150,)) elif r == "over": continue elif r == "wall": d.polygon(poly, fill=(60, 60, 60, 150), outline=(30, 30, 30, 255)) elif r == "window": d.polygon(poly, fill=(0, 200, 230, 150), outline=(0, 150, 190, 255)) elif r == "door": d.polygon(poly, fill=(240, 40, 40, 170), outline=(160, 0, 0, 255)) else: if zlo < 2.5: d.polygon(poly, outline=col + (255,)) try: font = ImageFont.truetype("arial.ttf", 12) except Exception: # noqa: BLE001 font = ImageFont.load_default() step = 5.0 gx = math.ceil(x0 / step) * step while gx <= x1: p = px((gx, 0))[0] d.line([(p, 0), (p, H)], fill=(0, 0, 0, 40)) d.text((p + 2, 2), "%g" % gx, fill=(160, 0, 0, 255), font=font) gx += step gy = math.ceil(y0 / step) * step while gy <= y1: p = px((0, gy))[1] d.line([(0, p), (W, p)], fill=(0, 0, 0, 40)) d.text((2, p + 1), "%g" % gy, fill=(0, 40, 180, 255), font=font) gy += step im.save(dst) print("wrote", dst, im.size, len(items), "pieces")