"""Generate loadable NWN tile models (`twu01_.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 ` 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()