175 lines
6.6 KiB
Python
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()
|