261 lines
12 KiB
Python
261 lines
12 KiB
Python
#!/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()
|