This commit is contained in:
2026-07-05 01:46:27 +02:00
parent 0a939e3a85
commit 08f544b358
23 changed files with 970 additions and 217 deletions
+2
View File
@@ -0,0 +1,2 @@
__pycache__/
*.pyc
+166
View File
@@ -0,0 +1,166 @@
"""Build the Westgate_Urban_Foundation_MVP Blender scene (run in Blender 4.0.2).
blender-4.0.2 --background --python build.py -- out.blend
Realises tiles.py: one Collection per tile (VIS_/WOK_/BLOCK_ sub-objects + edge
custom props), a TILE_EDGE_METADATA text block, and the two preview assemblies.
Geometry is intentionally simple (KICKOFF.md: a geometry/grammar prototype).
"""
import json
import os
import sys
import bpy
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import tiles # noqa: E402
def get_mat(name):
m = bpy.data.materials.get(name)
if m:
return m
m = bpy.data.materials.new(name)
r, g, b = tiles.COLORS.get(name, (0.5, 0.5, 0.5))
m.diffuse_color = (r, g, b, 1.0) # viewport (solid) colour
m.use_nodes = True
bsdf = m.node_tree.nodes.get("Principled BSDF")
if bsdf:
bsdf.inputs["Base Color"].default_value = (r, g, b, 1.0)
return m
def build_vis(spec):
"""Subdivided 10x10 plane (100 quads): per-face material via paint(), per-vertex Z."""
n, step = 10, 1.0
paint = tiles.make_paint(spec)
verts, vidx = [], {}
def vi(i, j):
if (i, j) not in vidx:
x, y = -5 + i * step, -5 + j * step
vidx[(i, j)] = len(verts)
verts.append((x, y, tiles.height(spec, x, y)))
return vidx[(i, j)]
faces, face_mat, mats, midx = [], [], [], {}
for i in range(n):
for j in range(n):
faces.append((vi(i, j), vi(i + 1, j), vi(i + 1, j + 1), vi(i, j + 1)))
cx, cy = -5 + (i + 0.5) * step, -5 + (j + 0.5) * step
mn = paint(cx, cy)
if mn not in midx:
midx[mn] = len(mats)
mats.append(mn)
face_mat.append(midx[mn])
me = bpy.data.meshes.new("VIS_" + spec["code"])
me.from_pydata(verts, [], faces)
for mn in mats:
me.materials.append(get_mat(mn))
for poly, mi in zip(me.polygons, face_mat):
poly.material_index = mi
me.update()
ob = bpy.data.objects.new("VIS_" + spec["code"], me)
n_, e_, s_, w_ = spec["edges"]
ob["edge_N"], ob["edge_E"], ob["edge_S"], ob["edge_W"] = n_, e_, s_, w_
return ob
def build_quads(name, quads, matname):
if not quads:
return None
verts, faces = [], []
for q in quads:
b = len(verts)
verts += q
faces.append((b, b + 1, b + 2, b + 3))
me = bpy.data.meshes.new(name)
me.from_pydata(verts, [], faces)
me.materials.append(get_mat(matname))
me.update()
return bpy.data.objects.new(name, me)
def build_tile(spec):
col = bpy.data.collections.new(f"{spec['code']}_{spec['name']}")
bpy.context.scene.collection.children.link(col)
col.objects.link(build_vis(spec))
quads = tiles.wok_quads(spec)
wok = build_quads("WOK_" + spec["code"], [q for q, w in quads if w], "MAT_debug_walkmesh")
blk = build_quads("BLOCK_" + spec["code"], [q for q, w in quads if not w], "MAT_debug_blocker")
if wok:
col.objects.link(wok)
if blk:
col.objects.link(blk)
return col
def instance(col, loc, parent, name):
ob = bpy.data.objects.new(name, None)
ob.instance_type = "COLLECTION"
ob.instance_collection = col
ob.location = loc
parent.objects.link(ob)
def label(text, loc, parent):
cu = bpy.data.curves.new(text, "FONT")
cu.body = text
cu.size = 1.4
ob = bpy.data.objects.new("LBL_" + text, cu)
ob.location = loc
parent.objects.link(ob)
def main():
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
out = os.path.abspath(argv[0]) if argv else os.path.abspath("westgate_urban_foundation_mvp.blend")
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.context.scene.unit_settings.system = "METRIC"
tilecol = {s["code"]: build_tile(s) for s in tiles.TILES}
# edge metadata text block (KICKOFF.md)
txt = bpy.data.texts.new("TILE_EDGE_METADATA")
for s in tiles.TILES:
n_, e_, s_, w_ = s["edges"]
txt.write(f"{s['code']}_{s['name']}\nN: {n_}\nE: {e_}\nS: {s_}\nW: {w_}\n\n")
# library grid preview (8 wide, 12 m pitch, labelled)
grid = bpy.data.collections.new("PREVIEW_tile_library_grid")
bpy.context.scene.collection.children.link(grid)
cols = 8
for i, s in enumerate(tiles.TILES):
gx, gy = (i % cols) * 12, -(i // cols) * 12
instance(tilecol[s["code"]], (gx, gy, 0), grid, "GRID_" + s["code"])
label(s["code"], (gx - 4.5, gy + 5.5, 0), grid)
# 12x12 test district (10 m pitch), row south-ward, col east-ward
dist = bpy.data.collections.new("PREVIEW_12x12_test_district")
bpy.context.scene.collection.children.link(dist)
skipped = []
for r, row in enumerate(tiles.MAP):
for c, code in enumerate(row):
tc = tilecol.get(code)
if tc is None:
skipped.append([r, c, code])
continue
instance(tc, (c * 10, -r * 10, 0), dist, f"D_{r:02d}_{c:02d}_{code}")
bpy.ops.wm.save_as_mainfile(filepath=out)
report = {
"blend": out,
"tile_collections": len(tilecol),
"district_cells": sum(len(r) for r in tiles.MAP),
"district_skipped": skipped,
"materials": len(tiles.COLORS),
}
print("<<<WGT_REPORT_BEGIN>>>" + json.dumps(report, indent=2) + "<<<WGT_REPORT_END>>>")
if __name__ == "__main__":
main()
+103
View File
@@ -0,0 +1,103 @@
"""Export every tile to real NWN ASCII .mdl + .wok via Neverblender (Blender 4.0.2).
blender-4.0.2 --background <built.blend> --python export_mdl.py -- <outdir> [CODE ...]
Requires the Neverblender addon enabled in this Blender (Import-Export:
Neverblender). Confirmed working headless against ~/.config/blender/4.0. For each
tile it assembles a Neverblender-native model in its own scene — an AuroraBase
(Empty, classification TILE, name == resref), a trimesh child (the VIS geometry),
and one aabb walkmesh child (from tiles.wok_quads) — then calls
scene.nvb_mdlexport(export_walkmesh=True) to emit <resref>.mdl and <resref>.wok.
Walkmesh surface materials use Neverblender's name->surfacemat mapping
(nvb_def.wok_materials): walkable faces = wok_Stone (idx 4), blockers = wok_Nonwalk
(idx 7). Refine per tile later (wok_Water/wok_StoneBridge/etc.) if desired.
"""
import os
import sys
import bpy
import addon_utils
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import tiles # noqa: E402
# Neverblender writes each walkmesh face's material_index (slot) verbatim as the
# surfacemat id (nvb_node.py), and create_wok_materials() fills slots 0..22 in
# nvb_def.wok_materials order. So the slot index IS the surfacemat index:
WALK_SURF = 4 # wok_Stone
BLOCK_SURF = 7 # wok_Nonwalk
def build_wok(spec, name):
from neverblender import nvb_utils
quads = tiles.wok_quads(spec)
verts, faces, surf = [], [], []
for quad, walk in quads:
b = len(verts)
verts += quad
faces.append((b, b + 1, b + 2, b + 3))
surf.append(WALK_SURF if walk else BLOCK_SURF)
me = bpy.data.meshes.new(name)
me.from_pydata(verts, [], faces)
nvb_utils.create_wok_materials(me) # slots 0..22 == wok_materials order
me.update()
for poly, s in zip(me.polygons, surf):
poly.material_index = s # slot == surfacemat id on export
ob = bpy.data.objects.new(name, me)
ob.nvb.meshtype = "aabb"
return ob
def export_tile(spec, outdir):
ref = tiles.resref(spec)
vis = bpy.data.objects.get("VIS_" + spec["code"])
if vis is None:
return {"code": spec["code"], "ok": False, "error": "missing VIS object"}
scn = bpy.data.scenes.new("exp_" + spec["code"])
bpy.context.window.scene = scn
root = bpy.data.objects.new(ref, None) # AuroraBase; name == filename
root.nvb.emptytype = "dummy"
root.nvb.classification = "tile"
scn.collection.objects.link(root)
v = vis.copy()
v.data = vis.data.copy()
v.name = ref + "_01"
v.nvb.meshtype = "trimesh"
scn.collection.objects.link(v)
v.parent = root
w = build_wok(spec, ref + "_wg") # single aabb walkmesh (rule: 1 per tile)
scn.collection.objects.link(w)
w.parent = root
mdl = os.path.join(outdir, ref + ".mdl")
bpy.ops.scene.nvb_mdlexport(filepath=mdl, export_walkmesh=True,
check_existing=False, batch_mode="OFF")
wok = os.path.join(outdir, ref + ".wok")
return {"code": spec["code"], "ref": ref, "ok": os.path.isfile(mdl),
"mdl": os.path.isfile(mdl), "wok": os.path.isfile(wok)}
def main():
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
outdir = os.path.abspath(argv[0]) if argv else os.path.abspath("mdl")
only = set(argv[1:])
os.makedirs(outdir, exist_ok=True)
addon_utils.enable("neverblender", default_set=False)
results = [export_tile(s, outdir) for s in tiles.TILES if not only or s["code"] in only]
ok = sum(1 for r in results if r["ok"])
bad = [r for r in results if not r["ok"]]
print(f"EXPORTED {ok}/{len(results)} tiles -> {outdir}")
if bad:
print("FAILED:", bad)
if __name__ == "__main__":
main()
+87
View File
@@ -0,0 +1,87 @@
"""Generate the tileset palette `twu01palstd.itp` from tiles.py.
python make_itp.py [out.itp] # default: twu01palstd.itp
The toolset loads `<set>palstd.itp` when it registers a tileset; without it the
set fails to register (blank name in the New Area wizard, "could not create
area"). It's a GFF `ITP ` file: MAIN = a list of palette *category* folders, each
{ID byte, STRREF (dialog.tlk folder label), LIST of leaves}.
We emit TWO folders (the verified structure — AGENTS.md §2.3; wut01palstd.itp):
* Features (STRREF 63261): one leaf per NON-paint tile, {NAME, RESREF=tile
resref}. Each leaf is placeable ONLY because make_set.py backs it with a 1x1
[GROUP#] whose Name == this NAME and Tile0 == that tile; the toolset finds the
group by the RESREF (its first tile). Paint tiles (tiles.is_paint) are omitted
here on purpose — they're brush-laid terrain, not click-placed features.
* Terrain (STRREF 8282): the paintable vocabulary — `cobble` and `canal` (each
RESREF = terrain name) plus the magic `eraser` leaf (RESREF `eraser`, STRREF
63291, no NAME). `raised` is NOT here: it's a height on cobble (Raise/Lower),
deferred to the next pass with HasHeightTransition=1 + a cobble-height matrix.
Multi-tile groups (folder 63262) would go in a third folder; we have none yet.
Built as JSON and converted to GFF with neverwinter.nim's nwn_gff (on PATH).
"""
import json
import os
import subprocess
import sys
import tempfile
import tiles
STRREF_FEATURES = 63261 # dialog.tlk label for the "Features" palette folder
STRREF_TERRAIN = 8282 # dialog.tlk label for the "Terrain" palette folder
STRREF_ERASER = 63291 # the magic eraser leaf (no NAME)
def leaf(name, resref):
return {"__struct_id": 0,
"NAME": {"type": "cexostring", "value": name},
"RESREF": {"type": "resref", "value": resref}}
def eraser_leaf():
return {"__struct_id": 0,
"RESREF": {"type": "resref", "value": "eraser"},
"STRREF": {"type": "dword", "value": STRREF_ERASER}}
def category(cid, strref, leaves):
return {"__struct_id": 0,
"ID": {"type": "byte", "value": cid},
"STRREF": {"type": "dword", "value": strref},
"LIST": {"type": "list", "value": leaves}}
def build():
features = [leaf(tiles.group_name(s), tiles.resref(s))
for s in tiles.TILES if not tiles.is_paint(s)]
# Paintable terrains = every .set terrain EXCEPT 'raised' (which is a height on
# cobble, not a terrain — the Raise/Lower tool, deferred to the next pass).
paintable = [t for t in tiles.TERRAINS if t != "raised"] # -> cobble, canal
terrain = [leaf(t, t) for t in paintable] + [eraser_leaf()]
return {
"__data_type": "ITP ",
"MAIN": {"type": "list", "value": [
category(0, STRREF_FEATURES, features), # ID/STRREF mirror wut01palstd.itp
category(2, STRREF_TERRAIN, terrain),
]},
}
def main():
out = sys.argv[1] if len(sys.argv) > 1 else f"{tiles.PREFIX}palstd.itp"
with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as tf:
json.dump(build(), tf)
js = tf.name
try:
subprocess.run(["nwn_gff", "-i", js, "-l", "json", "-k", "gff", "-o", out],
check=True)
finally:
os.unlink(js)
nfeat = sum(1 for s in tiles.TILES if not tiles.is_paint(s))
print(f"wrote {out} ({nfeat} features + paint:cobble,canal + eraser)")
if __name__ == "__main__":
main()
+174
View File
@@ -0,0 +1,174 @@
"""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()
+145
View File
@@ -0,0 +1,145 @@
"""Generate the first-pass NWN tileset definition `twu01.set` from tiles.py.
python make_set.py [out.set] # default: twu01.set
Pure Python, no Blender. Emits a valid, DOS/CRLF `.set` (AGENTS.md §4/§9: Unix
line endings crash the toolset). Corners/heights/crossers derive from each tile's
edge grammar; this is a DRAFT to load and hand-tune in the toolset, not a final
set. See CAVEATS below.
Every NON-paint tile gets a 1x1 [GROUP#] (a "feature") so it is click-placeable
from the palette (AGENTS.md §2.3) — the .itp Features folder points its leaves at
these groups by first-tile resref, and Name must match tiles.group_name().
PAINT tiles (tiles.is_paint: cobble fill CP1/2/3, canal fill CN1, canal edges
CE_*) are deliberately NOT grouped: a grouped tile is excluded from the toolset's
terrain-paint pool, so leaving them ungrouped IS what makes cobble/canal
paintable and the eraser work. Grouping every tile empties the fill pool and the
New Area wizard crashes (verified vs stock tcn01, 2026-07-04). Transition tiles
expand into 4 orientations (tiles.placements) so the corner matrix is complete;
make_set.main() hard-fails if tiles.canal_paint_matrix() is missing any pattern.
CAVEATS (first pass — tune in the toolset):
* PathNode is a placeholder ('A' walkable / 'N' non-walkable). Real pathnode
letters govern creature routing and must be verified against the toolset.
* ImageMap2D references minimaps (twu01_<code>.tga) that are not rendered yet.
"""
import re
import sys
import tiles
PREFIX = tiles.PREFIX
def tile_block(i, spec, orient, corners, edges):
tl, tr, bl, br = corners
n, e, s, w = edges
ref = tiles.resref(spec)
walkable = spec["kind"] != "canal"
# Paint tiles connect by CORNER match only (like stock wut01 terrains) — blank
# their edge crossers so the brush isn't over-constrained; features keep theirs.
cr = (lambda edge: "") if tiles.is_paint(spec) else tiles.nwn_crosser
# Field order/values mirror the stock BioWare sets (the toolset's .set reader
# is picky); WalkMesh=msb01 is the universal legacy label, not a file.
L = [f"[TILE{i}]"]
L += [
f"Model={ref}",
"WalkMesh=msb01",
f"TopLeft={tl[0]}", f"TopLeftHeight={tl[1]}",
f"TopRight={tr[0]}", f"TopRightHeight={tr[1]}",
f"BottomLeft={bl[0]}", f"BottomLeftHeight={bl[1]}",
f"BottomRight={br[0]}", f"BottomRightHeight={br[1]}",
f"Top={cr(n)}",
f"Right={cr(e)}",
f"Bottom={cr(s)}",
f"Left={cr(w)}",
"MainLight1=1", "MainLight2=1", "SourceLight1=1", "SourceLight2=1",
"AnimLoop1=1", "AnimLoop2=1", "AnimLoop3=1",
"Doors=0",
"Sounds=0",
f"PathNode={'A' if walkable else 'N'}", # placeholder — verify in toolset
f"Orientation={orient}", # 0/90/180/270 rotations of one model
"VisibilityNode=A",
"VisibilityOrientation=0",
f"ImageMap2D={ref}", # minimap resref (no .tga yet; blank in toolset)
]
return L
def build():
L = []
L += [
"[GENERAL]",
f"Name={PREFIX}",
"Type=SET",
"Interior=0",
"HasHeightTransition=0", # MVP paints flat cobble only; matches stock
"Transition=1.5", # metres per height step = Z=1.5 raised tier (unused while HHT=0)
"Border=cobble",
"Default=cobble",
"Floor=cobble",
"DisplayName=-1",
"UnlocalizedName=Westgate Urban Foundation",
"Version=V1.0",
"EnvMap=",
"SelectorHeight=3",
"",
"[GRASS]",
"Grass=0",
"GrassTextureName=",
"Density=0.0",
"Height=0.0",
"AmbientRed=0.0", "AmbientGreen=0.0", "AmbientBlue=0.0",
"DiffuseRed=0.0", "DiffuseGreen=0.0", "DiffuseBlue=0.0",
"",
"[TERRAIN TYPES]",
f"Count={len(tiles.TERRAINS)}",
]
for idx, t in enumerate(tiles.TERRAINS):
L += ["", f"[TERRAIN{idx}]", f"Name={t}", "StrRef=-1"]
L += ["", "[CROSSER TYPES]", f"Count={len(tiles.CROSSERS)}"]
for idx, cr in enumerate(tiles.CROSSERS):
L += ["", f"[CROSSER{idx}]", f"Name={cr}", "StrRef=-1"]
L += ["", "[PRIMARY RULES]", "Count=0", # optional; wut01 paints 6 terrains w/ 0
"", "[SECONDARY RULES]", "Count=0"]
# Expand each spec into its placement(s): transition tiles emit 4 rotations, so
# one model backs several [TILE#] entries. Track each spec's FIRST tile index so
# its [GROUP#] (if any) points at the right tile.
placed, first_idx = [], {}
for spec in tiles.TILES:
for pl in tiles.placements(spec):
first_idx.setdefault(spec["code"], len(placed))
placed.append((spec, pl))
L += ["", "[TILES]", f"Count={len(placed)}"]
for i, (spec, (orient, corners, edges)) in enumerate(placed):
L.append("")
L += tile_block(i, spec, orient, corners, edges)
# One 1x1 group ("feature") per NON-paint tile so the palette can click-place it.
# Paint tiles (fills + terrain transitions) stay ungrouped -> the brush pool.
feats = [s for s in tiles.TILES if not tiles.is_paint(s)]
L += ["", "[GROUPS]", f"Count={len(feats)}"]
for gi, spec in enumerate(feats):
L += ["", f"[GROUP{gi}]", f"Name={tiles.group_name(spec)}",
"Rows=1", "Columns=1", f"Tile0={first_idx[spec['code']]}"]
L += [""]
return L
def main():
_, _cov, missing = tiles.canal_paint_matrix()
if missing: # incomplete matrix = paint crash; never stage
raise SystemExit(f"INCOMPLETE canal paint matrix — {len(missing)} pattern(s) "
f"have no tile: {sorted(missing)}")
out = sys.argv[1] if len(sys.argv) > 1 else f"{PREFIX}.set"
lines = build()
with open(out, "w", newline="") as fh: # newline='' + explicit \r\n = CRLF
fh.write("\r\n".join(lines))
ntiles = sum(1 for l in lines if re.fullmatch(r"\[TILE\d+\]", l))
print(f"wrote {out} ({ntiles} tile placements from {len(tiles.TILES)} models, "
f"{len(tiles.TERRAINS)} terrains, {len(tiles.CROSSERS)} crossers, CRLF)")
if __name__ == "__main__":
main()
+118
View File
@@ -0,0 +1,118 @@
"""Generate per-tile ASCII walkmeshes (`twu01_<code>.wok`) from tiles.py.
python make_wok.py [outdir] # default: ./wok
Pure Python, no Blender. Emits the Aurora ASCII walkmesh node (verts + faces with
per-face surface material + an AABB bounding-volume tree). This is the usable
intermediate the toolset/cleanmodels/nwnmdlcomp compile to a binary `.wok`
(AGENTS.md §6); NeverBlender would otherwise produce this. Geometry comes from
tiles.wok_quads so it matches the Blender WOK_/BLOCK_ proxies exactly.
Surface material ids (surfacemat.2da, AGENTS.md §3.3): walkable faces use WALK,
non-walkable proxy faces use BLOCK. Verify these two ids against your
surfacemat.2da before shipping.
"""
import os
import sys
import tiles
WALK = 4 # surfacemat.2da row 4 = Stone, walkable (Neverblender wok_Stone)
BLOCK = 7 # surfacemat.2da row 7 = Nonwalk, non-walkable (wok_Nonwalk)
def geometry(spec):
"""Return (verts, faces): dedup verts, faces = (a, b, c, surfacemat)."""
verts, vmap, faces = [], {}, []
def vid(v):
key = tuple(round(c, 4) for c in v)
if key not in vmap:
vmap[key] = len(verts)
verts.append(key)
return vmap[key]
for quad, walkable in tiles.wok_quads(spec):
s = WALK if walkable else BLOCK
a, b, c, d = (vid(p) for p in quad)
faces.append((a, b, c, s)) # triangulate quad -> 2 tris
faces.append((a, c, d, s))
return verts, faces
def _box(verts, face):
pts = [verts[face[0]], verts[face[1]], verts[face[2]]]
return ([min(p[i] for p in pts) for i in range(3)],
[max(p[i] for p in pts) for i in range(3)])
def aabb_tree(verts, faces):
"""Binary BVH, pre-order (node, left subtree, right subtree). Leaf carries its
face index; internal carries -1. Reader reconstructs by the same recursion."""
nodes = []
def rec(items): # items = [(face_index, (min, max)), ...]
mn = [min(b[0][i] for _, b in items) for i in range(3)]
mx = [max(b[1][i] for _, b in items) for i in range(3)]
if len(items) == 1:
nodes.append((mn, mx, items[0][0]))
return
ax = max(range(3), key=lambda i: mx[i] - mn[i]) # split longest axis
items.sort(key=lambda ib: ib[1][0][ax] + ib[1][1][ax]) # by box centre
mid = len(items) // 2
nodes.append((mn, mx, -1)) # internal placeholder
rec(items[:mid])
rec(items[mid:])
rec([(i, _box(verts, f)) for i, f in enumerate(faces)])
assert sum(1 for _, _, fi in nodes if fi >= 0) == len(faces) # every face is a leaf
assert len(nodes) == 2 * len(faces) - 1 # full binary tree
return nodes
def aabb_node(spec):
"""The `node aabb <ref>_wok ... endnode` block (reused by make_mdl embed)."""
verts, faces = geometry(spec)
nodes = aabb_tree(verts, faces)
name = tiles.resref(spec)
L = [f"node aabb {name}_wg",
f" parent {name}",
" position 0.0 0.0 0.0",
" orientation 0.0 0.0 0.0 0.0",
" bitmap NULL",
" ambient 0.0 0.0 0.0",
" diffuse 0.0 0.0 0.0",
" specular 0.0 0.0 0.0",
" render 0",
" shadow 0",
f" verts {len(verts)}"]
L += [f" {v[0]:.5f} {v[1]:.5f} {v[2]:.5f}" for v in verts]
L.append(f" faces {len(faces)}")
# face line: v1 v2 v3 smoothgroup tv1 tv2 tv3 surfacemat
L += [f" {a} {b} {c} 1 0 0 0 {s}" for a, b, c, s in faces]
L.append(" aabb")
L += [f" {mn[0]:.5f} {mn[1]:.5f} {mn[2]:.5f} {mx[0]:.5f} {mx[1]:.5f} {mx[2]:.5f} {fi}"
for mn, mx, fi in nodes]
L.append("endnode")
return name, L
def emit(spec):
name, L = aabb_node(spec)
head = f"# {name} walkmesh (ASCII) - compile via nwnmdlcomp/cleanmodels"
return name, head + "\n" + "\n".join(L) + "\n"
def main():
outdir = sys.argv[1] if len(sys.argv) > 1 else "wok"
os.makedirs(outdir, exist_ok=True)
for spec in tiles.TILES:
name, text = emit(spec)
with open(os.path.join(outdir, name + ".wok"), "w") as fh:
fh.write(text)
print(f"wrote {len(tiles.TILES)} .wok files to {outdir}/")
if __name__ == "__main__":
main()
+82
View File
@@ -0,0 +1,82 @@
"""Guard the invariants that silently break the toolset (AGENTS.md §5, §2.3):
python test_namesync.py # asserts; no nwn_gff / Blender needed
1. Every .itp Features leaf is backed by a matching .set [GROUP#] whose first tile
is that leaf's model (Name + Tile0).
2. Paint tiles (fills + terrain transitions) stay OUT of every group/feature, so the
brush pool is non-empty — an empty pool crashes the New Area wizard (the
2026-07-04 access violation). Regression guard for that crash.
3. The cobble/canal paint matrix covers every corner pattern the brush can request
(an incomplete matrix = paint crash).
4. The .itp Terrain folder exposes exactly the paintable terrains + eraser.
Runs on make_set.build() and make_itp.build() directly, so it fails fast if
group_name / resref / is_paint / placements drift apart.
"""
import re
import tiles
import make_set
import make_itp
def _groups(setf):
return {m.group(1): int(m.group(2))
for m in re.finditer(r"\[GROUP\d+\]\nName=(.+?)\n(?:.*\n)*?Tile0=(\d+)", setf)}
def _tile_models(setf):
return {int(m.group(1)): m.group(2)
for m in re.finditer(r"\[TILE(\d+)\]\nModel=(\S+)", setf)}
def _features():
return [l for c in make_itp.build()["MAIN"]["value"] if c["ID"]["value"] == 0
for l in c["LIST"]["value"]]
def test_features_back_every_group():
setf = "\n".join(make_set.build())
groups, models = _groups(setf), _tile_models(setf)
feat = _features()
nonpaint = [s for s in tiles.TILES if not tiles.is_paint(s)]
assert len(feat) == len(groups) == len(nonpaint), \
f"count mismatch: {len(feat)} features / {len(groups)} groups / {len(nonpaint)} non-paint"
for lf in feat:
name, resref = lf["NAME"]["value"], lf["RESREF"]["value"]
assert name in groups, f".itp feature {name!r} has no matching .set [GROUP#]"
tile0 = groups[name]
assert models[tile0] == resref, \
f"{name}: group Tile0={tile0} model {models[tile0]!r} != leaf RESREF {resref!r}"
def test_paint_tiles_stay_in_brush_pool():
paint = [s for s in tiles.TILES if tiles.is_paint(s)]
assert paint, "no paint tiles -> empty brush pool -> New Area crash"
grouped = set(_groups("\n".join(make_set.build())))
featured = {l["NAME"]["value"] for l in _features()}
for s in paint:
gn = tiles.group_name(s)
assert gn not in grouped, f"paint tile {gn!r} is grouped -> dropped from brush pool"
assert gn not in featured, f"paint tile {gn!r} is a feature -> dropped from brush pool"
def test_canal_matrix_complete():
want, _covered, missing = tiles.canal_paint_matrix()
assert not missing, f"paint matrix missing {len(missing)} pattern(s): {sorted(missing)}"
assert want, "empty matrix?"
def test_terrain_folder_is_paintables_plus_eraser():
terr = [l for c in make_itp.build()["MAIN"]["value"] if c["ID"]["value"] == 2
for l in c["LIST"]["value"]]
names = {l["RESREF"]["value"] for l in terr}
assert names == {"cobble", "canal", "eraser"}, names
if __name__ == "__main__":
test_features_back_every_group()
test_paint_tiles_stay_in_brush_pool()
test_canal_matrix_complete()
test_terrain_folder_is_paintables_plus_eraser()
print("namesync OK")
+453
View File
@@ -0,0 +1,453 @@
"""Single source of truth for the wgt01 (Westgate Urban Foundation) tileset.
Pure Python, NO bpy — imported by build.py (Blender), make_wok.py and make_set.py
alike. Defines the 40-tile table, materials, the 12x12 test map, and the pure
functions that derive visuals (paint), heights, walkmesh quads and the NWN .set
mapping from a tile spec. Blender realises these; the .wok/.set tools consume the
same data so nothing drifts.
Coordinate convention (matches KICKOFF.md): tile centred at origin, X/Y in
[-5,+5], Z=0 ground, Z=1.5 raised tier, Z=-0.3 canal water. Z up.
"""
PREFIX = "twu01" # Tileset Westgate Urban 01 (5-char resref prefix, AGENTS.md §1)
HALF = 5.0
Z_RAISED = 1.5
Z_WATER = -0.3
# --- prototype materials. Bright, DISTINCT per role so structure reads at a glance
# in the toolset (KICKOFF.md wants geometry not art; the old near-black palette made
# every tile invisible). Curb/edge is deliberately loud so canal/height edges pop.
COLORS = {
"MAT_wet_cobble_dark": (0.42, 0.42, 0.47), # cobble ground — cool mid grey
"MAT_cobble_patch": (0.50, 0.34, 0.22), # repair patch — brown
"MAT_plaza_slab": (0.74, 0.68, 0.52), # plaza — warm tan
"MAT_sidewalk_stone": (0.64, 0.65, 0.70), # sidewalk — light grey
"MAT_curb_dark_stone": (0.98, 0.55, 0.08), # EDGES/curbs — loud orange (pop)
"MAT_canal_black_water": (0.10, 0.38, 0.80), # canal — strong blue (real tga overrides)
"MAT_void_black": (0.03, 0.03, 0.05),
"MAT_ramp_stone": (0.80, 0.25, 0.68), # ramps/stairs — magenta (height reads)
"MAT_drain_iron": (0.22, 0.24, 0.30), # iron — dark steel
"MAT_foundation_pad": (0.58, 0.54, 0.34), # lots — olive
"MAT_debug_walkmesh": (0.00, 0.80, 0.20),
"MAT_debug_blocker": (0.80, 0.10, 0.10),
}
CURB = "MAT_curb_dark_stone"
# NWN needs a real texture (bitmap) per trimesh; material name -> texture resref
# (<=16 chars, prefix eats 6). make_mdl.py writes a solid-colour .tga per entry.
TEXTURES = {
"MAT_wet_cobble_dark": "twu01_wcob",
"MAT_cobble_patch": "twu01_cpat",
"MAT_plaza_slab": "twu01_plaz",
"MAT_sidewalk_stone": "twu01_swlk",
"MAT_curb_dark_stone": "twu01_curb",
"MAT_canal_black_water": "twu01_watr",
"MAT_void_black": "twu01_void",
"MAT_ramp_stone": "twu01_ramp",
"MAT_drain_iron": "twu01_iron",
"MAT_foundation_pad": "twu01_fnd",
"MAT_debug_walkmesh": "twu01_dwlk",
"MAT_debug_blocker": "twu01_dblk",
}
def texname(mat):
return TEXTURES.get(mat, "twu01_wcob")
F, R, C, RP, MX = "FLAT", "RAISED", "CANAL", "RAMP", "MIXED"
def T(code, name, kind, edges, style, **kw):
d = dict(code=code, name=name, kind=kind, edges=edges, style=style)
d.update(kw)
return d
# 40 tiles, KICKOFF.md order. edges = (N, E, S, W).
TILES = [
# --- flat walkable ground ---
T("CP1", "cobble_plain", "flat", (F, F, F, F), "solid", mat="MAT_wet_cobble_dark"),
T("CP2", "cobble_plain_wet_variant", "flat", (F, F, F, F), "patches",
mat="MAT_wet_cobble_dark", patch="MAT_cobble_patch"),
T("CP3", "cobble_plain_broken_variant", "flat", (F, F, F, F), "patches",
mat="MAT_wet_cobble_dark", patch="MAT_cobble_patch"),
T("AL1", "narrow_alley_paving", "flat", (F, F, F, F), "alley"),
T("PL1", "plaza_slab", "flat", (F, F, F, F), "solid", mat="MAT_plaza_slab"),
T("DR1", "drain_detail", "flat", (F, F, F, F), "drain"),
T("MU1", "muddy_repair_patch", "flat", (F, F, F, F), "mud"),
T("LOT", "foundation_pad", "flat", (F, F, F, F), "solid", mat="MAT_foundation_pad"),
# --- streets (fully walkable cobble with painted street grammar) ---
T("ST_S", "straight_street", "flat", (F, F, F, F), "street", shape="straight"),
T("ST_C", "street_corner", "flat", (F, F, F, F), "street", shape="corner"),
T("ST_T", "street_t_junction", "flat", (F, F, F, F), "street", shape="t"),
T("ST_X", "street_crossroads", "flat", (F, F, F, F), "street", shape="x"),
T("ST_E", "street_end", "flat", (F, F, F, F), "street", shape="end"),
T("ST_W", "wide_boulevard", "flat", (F, F, F, F), "street", shape="wide"),
T("ST_G", "gate_mouth", "flat", (F, F, F, F), "street", shape="gate"),
T("ST_D", "street_with_central_drain", "flat", (F, F, F, F), "street", shape="drain"),
# --- sidewalk / curb / gutter ---
T("SW_S", "sidewalk_straight", "flat", (F, F, F, F), "sidewalk"),
T("SW_C", "sidewalk_corner", "flat", (F, F, F, F), "sidewalk"),
T("SW_T", "sidewalk_t_split", "flat", (F, F, F, F), "sidewalk"),
T("SW_X", "sidewalk_crossing", "flat", (F, F, F, F), "sidewalk"),
T("SW_E", "sidewalk_end", "flat", (F, F, F, F), "sidewalk"),
T("GD_S", "gutter_straight", "flat", (F, F, F, F), "gutter", shape="straight"),
T("GD_C", "gutter_corner", "flat", (F, F, F, F), "gutter", shape="corner"),
T("GD_X", "gutter_crossing_grate", "flat", (F, F, F, F), "gutter", shape="x", grate=True),
# --- raised tiers / ramps / stairs ---
T("UP1", "raised_terrace", "raised", (R, R, R, R), "raised"),
T("UP2", "raised_plaza_variant", "raised", (R, R, R, R), "raised"),
T("TE_S", "retaining_edge_straight", "retain", (R, MX, F, MX), "retain"),
T("TE_C", "retaining_edge_outer_corner", "retain", (R, R, MX, MX), "retain"),
T("TE_I", "retaining_edge_inner_corner", "retain", (R, MX, MX, R), "retain"),
T("RA_S", "straight_ramp", "ramp", (R, RP, F, RP), "ramp"),
T("RA_C", "corner_or_dogleg_ramp", "ramp", (R, RP, F, RP), "ramp"), # simplified to straight ramp
T("STA", "broad_stair", "stair", (R, MX, F, MX), "stair"),
# --- canal / void / bridges ---
T("CN1", "canal_black_water", "canal", (C, C, C, C), "canal"),
T("CN2", "narrow_drainage_cut", "cut", (C, F, C, F), "cut"),
T("CE_S", "canal_edge_straight", "canal_edge", (F, C, F, F), "canal_edge", which="E"),
T("CE_C", "canal_edge_corner", "canal_edge", (F, C, C, F), "canal_edge", which="ES_outer"),
# Inside (concave) corner: geometry (ES_inner) is canal in the SE quadrant only,
# i.e. ONE canal corner at BR — the edge tuple would wrongly read as 3, so pin it.
T("CE_I", "canal_edge_inside_corner", "canal_edge", (F, C, C, F), "canal_edge",
which="ES_inner", corners=[("cobble", 0), ("cobble", 0), ("cobble", 0), ("canal", 0)]),
# Diagonal "pinch": canal on TL+BR, land on TR+BL. Edge-derived corners can't
# express a diagonal (see nwn_corners), so this one carries explicit corners.
# Completes the 16-pattern cobble/canal paint matrix (canal_paint_matrix()).
T("CE_D", "canal_edge_diagonal_pinch", "canal_edge", (C, C, C, C), "canal_edge",
which="diag", corners=[("canal", 0), ("cobble", 0), ("cobble", 0), ("canal", 0)]),
T("BR_1", "narrow_bridge", "bridge", (F, C, F, C), "bridge", hw=1.5),
T("BR_2", "broad_bridge", "bridge", (F, C, F, C), "bridge", hw=3.0),
T("SEW", "sewer_mouth_culvert", "canal_edge", (F, F, C, F), "sewer", which="S"),
]
# 12x12 test district (KICKOFF.md). Placed by row (south-ward) / col (east-ward).
MAP = [
["CN1", "CN1", "CE_S", "CE_S", "BR_2", "CE_S", "CE_S", "CN1", "CN1", "CN1", "CN1", "CN1"],
["CN1", "CE_C", "SW_S", "ST_S", "ST_S", "ST_S", "SW_S", "CE_C", "CN1", "CN1", "CN1", "CN1"],
["CE_S", "SW_S", "ST_C", "ST_S", "ST_T", "ST_S", "ST_C", "SW_S", "CE_S", "CE_S", "BR_1", "CE_S"],
["SW_S", "AL1", "AL1", "CP1", "PL1", "PL1", "CP1", "AL1", "AL1", "SW_S", "ST_S", "SW_S"],
["SW_S", "AL1", "LOT", "CP2", "PL1", "PL1", "CP2", "LOT", "AL1", "SW_S", "ST_S", "SW_S"],
["ST_S", "ST_S", "ST_T", "ST_S", "ST_X", "ST_S", "ST_T", "ST_S", "ST_S", "ST_C", "ST_S", "SW_S"],
["SW_S", "AL1", "LOT", "CP1", "ST_S", "ST_S", "CP1", "LOT", "AL1", "SW_S", "CP2", "SW_S"],
["SW_S", "AL1", "AL1", "CP1", "PL1", "PL1", "CP1", "AL1", "AL1", "SW_S", "CP2", "SW_S"],
["CE_S", "SW_S", "ST_C", "ST_S", "ST_T", "ST_S", "ST_C", "SW_S", "CE_S", "TE_S", "TE_C", "SW_S"],
["CN1", "CE_C", "SW_S", "CP1", "RA_S", "UP1", "UP1", "TE_S", "TE_S", "UP1", "RA_S", "SW_S"],
["CN1", "CN1", "CE_S", "CE_S", "BR_2", "CE_S", "CE_S", "CN1", "CE_S", "SW_S", "ST_S", "SW_S"],
["CN1", "CN1", "CN1", "CN1", "CN1", "CN1", "CN1", "CN1", "CN1", "SW_S", "ST_E", "SW_S"],
]
# --- geometry predicates -----------------------------------------------------
def _disk(x, y, cx, cy, r):
return (x - cx) ** 2 + (y - cy) ** 2 <= r * r
def _road(shape, x, y, hw):
ns = abs(x) <= hw
ew = abs(y) <= hw
if shape == "straight":
return ns
if shape in ("wide", "gate"):
return abs(x) <= hw * 1.7
if shape == "corner":
return (ns and y >= -hw) or (ew and x <= hw) # N arm + E arm
if shape == "t":
return ew or (ns and y <= hw) # E-W bar + stub to S
if shape == "x":
return ns or ew
if shape == "end":
return ns and y <= 0.0 # enters from S, stops mid-tile
if shape == "drain":
return ns
return False
def _land_fn(which):
"""Where a canal_edge tile is walkable land (True) vs canal (False)."""
return {
"E": lambda x, y: x <= 0.0, # canal on east
"S": lambda x, y: y >= 0.0, # canal on south
"ES_outer": lambda x, y: (x <= 0.0 and y >= 0.0), # land = NW quadrant only
"ES_inner": lambda x, y: not (x > 0.0 and y < 0.0), # land = all but SE quadrant
"diag": lambda x, y: (x >= 0.0 and y >= 0.0) or (x <= 0.0 and y <= 0.0), # land NE+SW
}[which]
# --- visuals: paint(x, y) -> material name -----------------------------------
def make_paint(spec):
st = spec["style"]
if st == "solid":
m = spec["mat"]
return lambda x, y: m
if st == "patches":
base, patch = spec["mat"], spec["patch"]
pts = [(-2, 2), (1.5, -1.5), (3, 2.5), (-2.5, -2)]
return lambda x, y: patch if any(_disk(x, y, cx, cy, 1.3) for cx, cy in pts) else base
if st == "alley":
return lambda x, y: "MAT_wet_cobble_dark" if abs(x) <= 2.2 else "MAT_foundation_pad"
if st == "drain":
return lambda x, y: "MAT_drain_iron" if (abs(x) <= 1 and abs(y) <= 1) else "MAT_wet_cobble_dark"
if st == "mud":
return lambda x, y: "MAT_cobble_patch" if _disk(x, y, 0.5, -0.5, 2.4) else "MAT_wet_cobble_dark"
if st == "street":
shape, hw = spec["shape"], spec.get("hw", 1.6)
def f(x, y):
if _road(shape, x, y, hw):
return "MAT_drain_iron" if (shape == "drain" and abs(x) <= 0.5) else "MAT_wet_cobble_dark"
if _road(shape, x, y, hw + 1.0):
return CURB
return "MAT_sidewalk_stone"
return f
if st == "sidewalk":
return lambda x, y: CURB if (abs(x) >= 4.2 or abs(y) >= 4.2) else "MAT_sidewalk_stone"
if st == "gutter":
shape, grate = spec["shape"], spec.get("grate", False)
def f(x, y):
if grate and abs(x) <= 0.9 and abs(y) <= 0.9:
return "MAT_drain_iron"
if _road(shape, x, y, 0.6):
return "MAT_drain_iron"
if _road(shape, x, y, 1.1):
return CURB
return "MAT_wet_cobble_dark"
return f
if st == "raised":
return lambda x, y: "MAT_plaza_slab"
if st == "retain":
return lambda x, y: "MAT_plaza_slab" if y >= 0 else "MAT_wet_cobble_dark"
if st in ("ramp", "stair"):
return lambda x, y: "MAT_ramp_stone"
if st == "canal":
return lambda x, y: "MAT_wet_cobble_dark" if (abs(x) >= 4.4 or abs(y) >= 4.4) else "MAT_canal_black_water"
if st == "cut":
return lambda x, y: "MAT_canal_black_water" if abs(x) <= 0.9 else "MAT_wet_cobble_dark"
if st == "canal_edge":
land = _land_fn(spec["which"])
return lambda x, y: "MAT_wet_cobble_dark" if land(x, y) else "MAT_canal_black_water"
if st == "bridge":
hw = spec.get("hw", 1.5)
return lambda x, y: ("MAT_ramp_stone" if abs(x) <= hw
else (CURB if abs(x) <= hw + 0.3 else "MAT_canal_black_water"))
if st == "sewer":
return lambda x, y: (("MAT_drain_iron" if _disk(x, y, 0, -3.2, 1.6) else "MAT_wet_cobble_dark")
if y >= 0 else "MAT_canal_black_water")
return lambda x, y: "MAT_wet_cobble_dark"
def height(spec, x, y):
"""Z of the visual top surface at (x, y). Drives the subdivided VIS plane."""
k = spec["kind"]
if k == "raised":
return Z_RAISED
if k in ("ramp", "stair"):
return (y + HALF) / (2 * HALF) * Z_RAISED # S=0 -> N=1.5
if k == "retain":
return Z_RAISED if y >= 0 else 0.0
if k == "canal":
return 0.0 if (abs(x) >= 4.4 or abs(y) >= 4.4) else Z_WATER
if k == "cut":
return Z_WATER if abs(x) <= 0.9 else 0.0
if k == "canal_edge":
return 0.0 if _land_fn(spec["which"])(x, y) else Z_WATER
if k == "bridge":
hw = spec.get("hw", 1.5)
return 0.2 if abs(x) <= hw + 0.3 else Z_WATER
if k == "sewer":
return 0.0 if y >= 0 else Z_WATER
if spec.get("style") == "sidewalk":
return 0.15 if (abs(x) >= 4.2 or abs(y) >= 4.2) else 0.0 # low curb (KICKOFF 0.12-0.18)
return 0.0
# --- walkmesh proxy: list of (verts4, walkable) ------------------------------
def _rect(x0, x1, y0, y1, z):
return [(x0, y0, z), (x1, y0, z), (x1, y1, z), (x0, y1, z)]
def _ramp(x0, x1, y0, y1):
z0 = (y0 + HALF) / (2 * HALF) * Z_RAISED
z1 = (y1 + HALF) / (2 * HALF) * Z_RAISED
return [(x0, y0, z0), (x1, y0, z0), (x1, y1, z1), (x0, y1, z1)]
def wok_quads(spec):
k = spec["kind"]
if k == "flat":
return [(_rect(-5, 5, -5, 5, 0.0), True)]
if k == "raised":
return [(_rect(-5, 5, -5, 5, Z_RAISED), True)]
if k in ("ramp", "stair"):
return [(_ramp(-5, 5, -5, 5), True)]
if k == "retain":
return [(_rect(-5, 5, 0, 5, Z_RAISED), True), (_rect(-5, 5, -5, 0, 0.0), True)]
if k == "canal":
return [(_rect(-5, 5, -5, 5, 0.0), False)]
if k == "cut":
return [(_rect(-5, -0.9, -5, 5, 0.0), True),
(_rect(0.9, 5, -5, 5, 0.0), True),
(_rect(-0.9, 0.9, -5, 5, 0.0), False)]
if k == "canal_edge":
w = spec["which"]
if w == "E":
return [(_rect(-5, 0, -5, 5, 0.0), True), (_rect(0, 5, -5, 5, 0.0), False)]
if w == "S":
return [(_rect(-5, 5, 0, 5, 0.0), True), (_rect(-5, 5, -5, 0, 0.0), False)]
if w == "ES_outer":
return [(_rect(-5, 0, 0, 5, 0.0), True),
(_rect(0, 5, -5, 5, 0.0), False), (_rect(-5, 0, -5, 0, 0.0), False)]
if w == "ES_inner":
return [(_rect(-5, 0, -5, 5, 0.0), True), (_rect(0, 5, 0, 5, 0.0), True),
(_rect(0, 5, -5, 0, 0.0), False)]
if w == "diag": # NE+SW land, NW+SE canal
return [(_rect(0, 5, 0, 5, 0.0), True), (_rect(-5, 0, -5, 0, 0.0), True),
(_rect(-5, 0, 0, 5, 0.0), False), (_rect(0, 5, -5, 0, 0.0), False)]
if k == "bridge":
hw = spec.get("hw", 1.5)
return [(_rect(-hw, hw, -5, 5, 0.2), True),
(_rect(-5, -hw, -5, 5, 0.0), False), (_rect(hw, 5, -5, 5, 0.0), False)]
return [(_rect(-5, 5, -5, 5, 0.0), True)]
# --- visible mesh: subdivided 10x10 plane, faces tagged by material ----------
# Same source as build.py's build_vis so the Blender preview and the exported
# .mdl never drift. Faces are quads here; make_mdl.py triangulates on emit.
def vis_geometry(spec):
"""Return (verts, faces): 100-quad plane. faces = (a, b, c, d, matname)."""
n, step = 10, 1.0
paint = make_paint(spec)
verts, vidx = [], {}
def vi(i, j):
if (i, j) not in vidx:
x, y = -HALF + i * step, -HALF + j * step
vidx[(i, j)] = len(verts)
verts.append((x, y, height(spec, x, y)))
return vidx[(i, j)]
faces = []
for i in range(n):
for j in range(n):
cx, cy = -HALF + (i + 0.5) * step, -HALF + (j + 0.5) * step
faces.append((vi(i, j), vi(i + 1, j), vi(i + 1, j + 1), vi(i, j + 1),
paint(cx, cy)))
return verts, faces
# --- NWN .set mapping (AGENTS.md §4, §7) -------------------------------------
# Minimal, deliberate vocabulary (AGENTS.md §2.1: every terrain pairing multiplies
# tile count). Ground tiles all share 'cobble' so they interconnect freely
# (KICKOFF.md: "entire tile can remain walkable for flexibility").
TERRAINS = ["cobble", "raised", "canal"]
CROSSERS = ["ramp", "canal_edge"]
def resref(spec):
return f"{PREFIX}_{spec['code'].lower()}" # <=16 chars, valid identifier
def group_name(spec):
"""Palette label for a tile — MUST be identical in the .itp leaf NAME and the
.set [GROUP#] Name (name-sync gotcha, AGENTS.md §5). e.g. '[flat] cobble_plain'."""
return f"[{spec['kind']}] {spec['name']}"
# --- paint pool vs palette features (AGENTS.md §2.3) --------------------------
# A tile in ANY [GROUP#] is excluded from the toolset's terrain-paint pool. So the
# tiles the brush auto-lays (fills + terrain transitions) must stay UNGROUPED; only
# click-place prefabs get grouped. Grouping *every* tile empties the fill pool ->
# the New Area wizard null-derefs ("Access violation ... Write of 00000040").
GROUND_CODES = {"CP1", "CP2", "CP3"} # cobble fill pool (Default/Floor)
CANAL_PAINT_CODES = {"CN1", "CE_S", "CE_C", "CE_I", "CE_D"} # canal fill + edge transitions
PAINT_CODES = GROUND_CODES | CANAL_PAINT_CODES # ungrouped, brush-laid terrain
TRANSITION_CODES = {"CE_S", "CE_C", "CE_I", "CE_D"} # emit all 4 rotations (matrix)
def is_ground(spec):
return spec["code"] in GROUND_CODES
def is_paint(spec):
"""True = ungrouped terrain tile the brush auto-picks (never a click-place feature)."""
return spec["code"] in PAINT_CODES
def is_transition(spec):
return spec["code"] in TRANSITION_CODES
def _rot_corners(c): # one CCW 90° step: (TL,TR,BL,BR) -> (TR,BR,TL,BL)
return (c[1], c[3], c[0], c[2])
def _rot_edges(e): # one CCW 90° step: (N,E,S,W) -> (E,S,W,N)
return (e[1], e[2], e[3], e[0])
def base_corners(spec):
"""(TL,TR,BL,BR) each (terrain, height) — explicit override or edge-derived."""
if "corners" in spec:
return tuple(tuple(c) for c in spec["corners"])
c = nwn_corners(spec["edges"])
return (c["TopLeft"], c["TopRight"], c["BottomLeft"], c["BottomRight"])
def placements(spec):
"""[(orient_deg, corners4, edges4)] backing this tile's [TILE#] entries. A
transition tile emits each DISTINCT rotation (one model covers the whole
rotation class via Orientation — the engine rotates model+walkmesh together),
so the paint matrix is complete without extra models. Others place upright."""
c, e = base_corners(spec), tuple(spec["edges"])
if not is_transition(spec):
return [(0, c, e)]
seen, out = set(), []
for k in range(4):
if c not in seen:
seen.add(c)
out.append((k * 90, c, e))
c, e = _rot_corners(c), _rot_edges(e)
return out
def canal_paint_matrix():
"""(want, covered, missing) sets of 4-corner cobble/canal patterns. `missing`
non-empty = a pattern the brush can request has no tile -> paint/New-Area crash.
The generator hard-fails on this, so an incomplete matrix can never stage."""
opts = [("cobble", 0), ("canal", 0)]
want = {(tl, tr, bl, br) for tl in opts for tr in opts for bl in opts for br in opts}
have = {c for s in TILES if is_paint(s) for _o, c, _e in placements(s)}
return want, have & want, want - have
def _terr(e):
return {"RAISED": "raised", "CANAL": "canal"}.get(e, "cobble")
def _corner(ea, eb):
ts = {_terr(ea), _terr(eb)}
terr = "raised" if "raised" in ts else ("canal" if "canal" in ts else "cobble")
h = 1 if (ea == "RAISED" or eb == "RAISED") else 0
return terr, h
def nwn_corners(edges):
"""Corner (terrain, height) for TopLeft/TopRight/BottomLeft/BottomRight.
Top=N Bottom=S Left=W Right=E (AGENTS.md §4)."""
n, e, s, w = edges
return {
"TopLeft": _corner(n, w), "TopRight": _corner(n, e),
"BottomLeft": _corner(s, w), "BottomRight": _corner(s, e),
}
def nwn_crosser(edge):
return {"CANAL": "canal_edge", "RAMP": "ramp"}.get(edge, "")