Files
2026-07-05 01:46:27 +02:00

175 lines
6.6 KiB
Python

"""Generate loadable NWN tile models (`twu01_<code>.mdl` + `.wok` + textures).
python make_mdl.py [outdir] # default: ./mdl
Pure Python, no Blender. Emits Aurora ASCII models directly from tiles.py so the
result is deterministic and testable without a headless Blender/Neverblender run.
(Neverblender is kept as an alternate path in export_mdl.py, but it flattens our
multi-material tiles to one unloadable material, so this is the shipping path.)
Each tile .mdl contains:
* an AuroraBase dummy node named == resref (classification TILE),
* one trimesh node PER texture used on the tile (NWN allows one bitmap/node) —
faces triangulated, real tverts, and a `bitmap <resref>` the engine can load,
* the aabb walkmesh node (shared with make_wok, embedded per NWN tile rules).
The standalone `.wok` (same walkmesh) is written alongside.
Textures: a <=16-char .tga per tiles.TEXTURES. Each is a procedural structural
read-out (distinct role colour + dark border + orientation pip) so geometry reads
in the toolset (KICKOFF.md: geometry not art); SRC_TEX swaps in a real stock tga
where a photographic surface helps (e.g. canal water). Real art can replace any of
these files later without touching geometry.
"""
import os
import shutil
import sys
import tiles
import make_wok
VIS_MATID = 65536 # constant last-field on visible faces (matches stock tiles)
# Real stock-NWN textures copied verbatim for surfaces where a photographic read
# beats a flat colour (user: "pull cobbles/water from refs"). Everything else is
# the procedural structural read-out below. Missing source -> procedural fallback.
REFS = "/home/vicky/Projects/nwnee-refs"
SRC_TEX = {
"MAT_canal_black_water": f"{REFS}/aurora_tin.bif/tin01_water01.tga", # animated-look water
}
TEX_N = 64 # texture size (power of 2)
_BORDER = 3 # dark inset ring — makes within-tile paint seams read
_PIP = 9 # bright corner pip — reveals tile ORIENTATION (debug rotations)
def write_tga(path, rgb):
"""64x64 24-bit TGA: base `rgb` (0..1), a dark inset border, and a white
corner pip. Not art — a structural read-out: the pip exposes each tile's
orientation (so the paint auto-picker's rotations are visible) and the border
outlines internal texture edges. TGA origin is lower-left (descriptor 0), so
the pip sits at the tile's south-west (x=-5, y=-5) corner."""
r, g, b = (max(0, min(255, round(c * 255))) for c in rgb)
dr, dg, db = (v * 35 // 100 for v in (r, g, b)) # 0.35x -> border
n = TEX_N
body = bytearray()
for j in range(n):
for i in range(n):
if i < _PIP and j < _PIP:
body += bytes((255, 255, 255)) # pip
elif i < _BORDER or i >= n - _BORDER or j < _BORDER or j >= n - _BORDER:
body += bytes((db, dg, dr)) # border (BGR)
else:
body += bytes((b, g, r)) # base (BGR)
assert len(body) == n * n * 3
header = bytes([0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0,
n & 0xFF, n >> 8, n & 0xFF, n >> 8, 24, 0])
with open(path, "wb") as fh:
fh.write(header + bytes(body))
def _trimesh(ref, idx, tex, verts, quads):
"""One trimesh node: local verts + planar tverts + triangulated faces."""
local, lmap, tvs = [], {}, []
def li(gi):
if gi not in lmap:
x, y, z = verts[gi]
lmap[gi] = len(local)
local.append((x, y, z))
tvs.append(((x + tiles.HALF) / (2 * tiles.HALF),
(y + tiles.HALF) / (2 * tiles.HALF)))
return lmap[gi]
tris = []
for a, b, c, d in quads:
la, lb, lc, ld = li(a), li(b), li(c), li(d)
tris.append((la, lb, lc))
tris.append((la, lc, ld))
L = [f"node trimesh {ref}_{idx:02d}",
f" parent {ref}",
" position 0.00000 0.00000 0.00000",
" orientation 0.00000 0.00000 0.00000 0.00000",
" wirecolor 1.0 1.0 1.0",
" ambient 1.0 1.0 1.0",
" diffuse 1.0 1.0 1.0",
" specular 0.0 0.0 0.0",
" shininess 1",
f" bitmap {tex}",
" render 1",
" shadow 0",
" tilefade 0",
" rotatetexture 0",
f" verts {len(local)}"]
L += [f" {v[0]:.5f} {v[1]:.5f} {v[2]:.5f}" for v in local]
L.append(f" tverts {len(tvs)}")
L += [f" {u:.6f} {v:.6f} 0" for u, v in tvs]
L.append(f" faces {len(tris)}")
# face: v1 v2 v3 smoothgroup tv1 tv2 tv3 matid (tvert idx == vert idx here)
L += [f" {a} {b} {c} 1 {a} {b} {c} {VIS_MATID}" for a, b, c in tris]
L.append("endnode")
return L
def emit_mdl(spec):
ref = tiles.resref(spec)
verts, faces = tiles.vis_geometry(spec)
# group quads by texture, preserving first-seen order for stable node numbers
groups, order = {}, []
for a, b, c, d, mat in faces:
tex = tiles.texname(mat)
if tex not in groups:
groups[tex] = []
order.append(tex)
groups[tex].append((a, b, c, d))
L = [f"# {ref} - Westgate Urban Foundation MVP tile (ASCII, generated)",
f"newmodel {ref}",
f"setsupermodel {ref} null",
"classification TILE",
"setanimationscale 1.0",
f"beginmodelgeom {ref}",
f"node dummy {ref}",
" parent null",
"endnode"]
for idx, tex in enumerate(order, start=1):
L += _trimesh(ref, idx, tex, verts, groups[tex])
_, aabb = make_wok.aabb_node(spec)
L += aabb
L += [f"endmodelgeom {ref}", f"donemodel {ref}"]
return ref, "\n".join(L) + "\n", set(order)
def main():
outdir = sys.argv[1] if len(sys.argv) > 1 else "mdl"
os.makedirs(outdir, exist_ok=True)
used = set()
for spec in tiles.TILES:
ref, mdl, texs = emit_mdl(spec)
used |= texs
with open(os.path.join(outdir, ref + ".mdl"), "w") as fh:
fh.write(mdl)
_, wok = make_wok.emit(spec)
with open(os.path.join(outdir, ref + ".wok"), "w") as fh:
fh.write(wok)
# write every texture (not just `used`) so nothing dangles if a tile changes;
# copy a real stock tga where SRC_TEX has one, else the procedural read-out.
for mat, tex in tiles.TEXTURES.items():
dst = os.path.join(outdir, tex + ".tga")
src = SRC_TEX.get(mat)
if src and os.path.exists(src):
shutil.copyfile(src, dst)
else:
write_tga(dst, tiles.COLORS[mat])
print(f"wrote {len(tiles.TILES)} .mdl + .wok and "
f"{len(tiles.TEXTURES)} .tga to {outdir}/")
if __name__ == "__main__":
main()