#!/usr/bin/env python3 """Level WAD -> OBJ (+MTL, texture page PNGs, preview images). geom_export.py level.wad outdir [--collision] [--objects] [--no-png] [--size 1200x900] Writes to outdir: level.obj / level.mtl world geometry (TRAK tiles, placed per MAP), with UVs and vertex colours ("v x y z r g b"), one OBJ object per tile page_NN.png texture pages (256x256, from TEXT) collision.obj (--collision) collision polygons, grouped by material objects.obj (--objects) static STPC object models side by side preview_top.png, preview_persp.png textured software renderings for checking (game space, the same way round as in the game) Coordinates: OBJ file space = game space (x, y, z_game): right-handed, y up, z > 0 in the grid, as Blender shows the level the same way round as the game (geomlib.to_file). --export-space writes the raw export space (MAP floats, z < 0; mirrored in other tools), the convention before 2026-11-07. Format: notes/geometry.md """ import argparse import math import os import sys import numpy as np from PIL import Image sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import geomlib as G # noqa: E402 def world_tris(trak, mp, texs, use_alt=False): """Returns a list of triangles: (P[3x3], UV[3x2], C[3x3] 0..1, page, texidx, tileidx).""" out = [] for i in range(mp['N']): x, y, z, rot, mi, fl = mp['A'][i] m = trak[mi] f, fn = G.tile_xform(x, y, z, rot) W = [f(*v[:3]) for v in m.verts] cols = mp['cols'][i][0] if mp['cols'][i] else None for fc in m.faces: t = texs[fc['tex']] if fc['tex'] < len(texs) else None P = [W[k] for k in fc['v']] if cols is not None and not (fc['flags'] & 0x4000): C = [] for k in fc['v']: c = cols[k] C.append((min(255, (c & 255) * 2) / 255, min(255, (c >> 8 & 255) * 2) / 255, min(255, (c >> 16 & 255) * 2) / 255)) else: C = [(1, 1, 1)] * 3 if t is None or (t['flags'] & 1) or t['page'] < 0: uv = [(0, 0)] * 3 page = -1 else: uv = G.face_uvs(fc['flags'], G.tex_uv(t)) page = t['page'] out.append((P, uv, C, page, fc['tex'], i)) return out def flat_color(t): # flat colour (texture flag bit 0): u0,u1,v0 = R,G,B (Renderer FUN_00556510) return (t['u0'] / 255, t['u1'] / 255, t['v0'] / 255) def write_obj(path, tris, texs, mtlname, raw=False): with open(path, 'w') as fo: fo.write('# Ein Koenigreich fuer ein Lama - world geometry (geom_export.py)\n') fo.write(f'mtllib {mtlname}\n') vi = 1 cur_tile = None cur_mat = None for P, uv, C, page, tex, tile in tris: if tile != cur_tile: fo.write(f'o tile_{tile:04d}\n') cur_tile = tile cur_mat = None mat = f'page_{page:02d}' if page >= 0 else f'flat_{tex}' if mat != cur_mat: fo.write(f'usemtl {mat}\n') cur_mat = mat order = [0, 1, 2] if raw else G.file_order(range(3)) for k in order: fo.write('v %.5f %.5f %.5f %.3f %.3f %.3f\n' % (*(P[k] if raw else G.to_file(P[k])), *C[k])) for k in order: fo.write('vt %.6f %.6f\n' % (uv[k][0], 1 - uv[k][1])) fo.write(f'f {vi}/{vi} {vi + 1}/{vi + 1} {vi + 2}/{vi + 2}\n') vi += 3 def write_mtl(path, tris, texs, npages): used = sorted(set((page, tex) for _, _, _, page, tex, _ in tris), key=lambda a: (a[0], a[1])) with open(path, 'w') as fo: for p in range(npages): fo.write(f'newmtl page_{p:02d}\nKd 1 1 1\nmap_Kd page_{p:02d}.png\nmap_d page_{p:02d}.png\n\n') for page, tex in used: if page < 0: c = flat_color(texs[tex]) if tex < len(texs) else (1, 0, 1) fo.write(f'newmtl flat_{tex}\nKd %.3f %.3f %.3f\n\n' % c) # ------------------------------------------------------------ software renderer def game_space_tris(tris): """world_tris (export space) -> game space for the right-handed preview camera.""" return [([G.to_file(p) for p in P], *rest) for P, *rest in tris] def render(tris, pages, cam, size, path, texs): Wd, Hd = size eye, target, fov = cam eye = np.array(eye, float); target = np.array(target, float) fwd = target - eye; fwd /= np.linalg.norm(fwd) right = np.cross(fwd, [0, 1, 0]); right /= np.linalg.norm(right) up = np.cross(right, fwd) focal = (Wd / 2) / math.tan(math.radians(fov) / 2) img = np.zeros((Hd, Wd, 3), np.float32); img[:] = (0.12, 0.16, 0.24) zb = np.full((Hd, Wd), np.inf, np.float32) pg = [p.astype(np.float32) / 255 for p in pages] L = np.array([0.35, 0.85, 0.4]); L /= np.linalg.norm(L) for P, uv, C, page, tex, tile in tris: P = np.array(P) - eye cz = P @ fwd if (cz < 0.3).any(): continue sx = Wd / 2 + focal * (P @ right) / cz sy = Hd / 2 - focal * (P @ up) / cz x0, x1 = int(max(0, math.floor(sx.min()))), int(min(Wd - 1, math.ceil(sx.max()))) y0, y1 = int(max(0, math.floor(sy.min()))), int(min(Hd - 1, math.ceil(sy.max()))) if x0 > x1 or y0 > y1: continue den = (sy[1] - sy[2]) * (sx[0] - sx[2]) + (sx[2] - sx[1]) * (sy[0] - sy[2]) if abs(den) < 1e-9: continue xs, ys = np.meshgrid(np.arange(x0, x1 + 1) + 0.5, np.arange(y0, y1 + 1) + 0.5) w0 = ((sy[1] - sy[2]) * (xs - sx[2]) + (sx[2] - sx[1]) * (ys - sy[2])) / den w1 = ((sy[2] - sy[0]) * (xs - sx[2]) + (sx[0] - sx[2]) * (ys - sy[2])) / den w2 = 1 - w0 - w1 inside = (w0 >= 0) & (w1 >= 0) & (w2 >= 0) if not inside.any(): continue iz = w0 / cz[0] + w1 / cz[1] + w2 / cz[2] depth = 1 / np.where(inside, iz, 1e-9) sub = zb[y0:y1 + 1, x0:x1 + 1] # colour nrm = np.cross(np.array(P[1]) - P[0], np.array(P[2]) - P[0]) nl = np.linalg.norm(nrm) shade = 0.55 + 0.45 * abs(nrm @ L) / nl if nl > 0 else 1 Cc = np.array(C) if page >= 0: UV = np.array(uv) pu = (w0 / cz[0] * UV[0, 0] + w1 / cz[1] * UV[1, 0] + w2 / cz[2] * UV[2, 0]) * depth pv = (w0 / cz[0] * UV[0, 1] + w1 / cz[1] * UV[1, 1] + w2 / cz[2] * UV[2, 1]) * depth tp = pg[page] tx = np.clip((pu * tp.shape[1]).astype(int), 0, tp.shape[1] - 1) ty = np.clip((pv * tp.shape[0]).astype(int), 0, tp.shape[0] - 1) texel = tp[ty, tx] alpha = texel[..., 3] > 0.5 col = texel[..., :3] else: t = texs[tex] if tex < len(texs) else None col = np.broadcast_to(np.array(flat_color(t) if t else (1, 0, 1), np.float32), xs.shape + (3,)) alpha = np.ones(xs.shape, bool) vc = (w0[..., None] * Cc[0] + w1[..., None] * Cc[1] + w2[..., None] * Cc[2]) col = col * vc * shade mask = inside & alpha & (depth < sub) sub[mask] = depth[mask] img[y0:y1 + 1, x0:x1 + 1][mask] = col[mask] Image.fromarray((np.clip(img, 0, 1) * 255).astype(np.uint8)).save(path) def main(): ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument('wad'); ap.add_argument('outdir') ap.add_argument('--collision', action='store_true') ap.add_argument('--objects', action='store_true') ap.add_argument('--no-png', action='store_true', help='no page PNGs/previews') ap.add_argument('--size', default='1200x900') ap.add_argument('--export-space', action='store_true', help='raw export space (z < 0, mirrored in Blender) instead of game space') a = ap.parse_args() out = (lambda p: p) if a.export_space else G.to_file order = (lambda s: list(s)) if a.export_space else G.file_order os.makedirs(a.outdir, exist_ok=True) ch, wf, trak, mp = G.load_level(a.wad) pages, texs = G.parse_text(G.wad_get(ch, 'TEXT')) tris = world_tris(trak, mp, texs) write_obj(os.path.join(a.outdir, 'level.obj'), tris, texs, 'level.mtl', a.export_space) write_mtl(os.path.join(a.outdir, 'level.mtl'), tris, texs, len(pages)) print(f'{a.wad}: WFPC={wf:#x} TRAK models={len(trak)} tiles={mp["N"]} grid={mp["W"]}x{mp["H"]} ' f'triangles={len(tris)} textures={len(texs)} pages={len(pages)}') if a.collision: with open(os.path.join(a.outdir, 'collision.obj'), 'w') as fo: vi = 1 for i in range(mp['N']): x, y, z, rot, mi, fl = mp['A'][i] f, _ = G.tile_xform(x, y, z, rot) for g, grp in enumerate(trak[mi].coll): for r in grp: c = G.coll_decode(r) pts = G.coll_polygon(c) if len(pts) < 3: continue nrm = np.cross(np.subtract(pts[1], pts[0]), np.subtract(pts[2], pts[0])) if nrm @ np.array(c['plane'][:3]) < 0: pts = pts[::-1] fo.write(f'g mat{c["mat"]:02d}_grp{g}\n') for p in order(pts): fo.write('v %.5f %.5f %.5f\n' % out(f(*p))) fo.write('f ' + ' '.join(str(vi + k) for k in range(len(pts))) + '\n') vi += len(pts) if a.objects: models, _ = G.parse_stpc_models(G.wad_get(ch, 'STPC')) with open(os.path.join(a.outdir, 'objects.obj'), 'w') as fo: fo.write('mtllib level.mtl\n') vi = 1; vt = 1; ox = 0.0 for k, m in enumerate(models): if not m.verts: continue xs = [v[0] for v in m.verts] dx = ox - min(xs) fo.write(f'o stpc_model_{k:03d}\n') for v in m.verts: fo.write('v %.5f %.5f %.5f\n' % out((v[0] + dx, v[1], v[2]))) for fc in m.faces: t = texs[fc['tex']] if fc['tex'] < len(texs) else None if t and not (t['flags'] & 1) and t['page'] >= 0: uv = G.face_uvs(fc['flags'], G.tex_uv(t)); fo.write(f'usemtl page_{t["page"]:02d}\n') else: uv = [(0, 0)] * 3; fo.write(f'usemtl flat_{fc["tex"]}\n') for u, v in uv: fo.write('vt %.6f %.6f\n' % (u, 1 - v)) fo.write('f ' + ' '.join(f'{vi + fc["v"][j]}/{vt + j}' for j in order(range(3))) + '\n') vt += 3 vi += len(m.verts) ox += max(xs) - min(xs) + 0.5 if not a.no_png: imgs = [G.page_rgba(p) for p in pages] for k, im in enumerate(imgs): Image.fromarray(im).save(os.path.join(a.outdir, f'page_{k:02d}.png')) size = tuple(int(s) for s in a.size.split('x')) # the renderer's camera is right-handed: game space shows the level as the game does # (export space would come out mirrored); also with --export-space tris = game_space_tris(tris) P = np.array([p for t in tris for p in t[0]]) lo, hi = P.min(0), P.max(0); c = (lo + hi) / 2 ext = max(hi[0] - lo[0], hi[2] - lo[2]) # top view (almost vertical, game -z at the top) render(tris, imgs, ((c[0], hi[1] + ext * 1.2, c[2] + 0.01), (c[0], lo[1], c[2]), 50), size, os.path.join(a.outdir, 'preview_top.png'), texs) render(tris, imgs, ((c[0] + ext * 0.35, hi[1] + ext * 0.45, hi[2] + ext * 0.45), tuple(c), 60), size, os.path.join(a.outdir, 'preview_persp.png'), texs) if __name__ == '__main__': main()