mesh files use game space (right-handed, y up): obj/gltf import and export no longer mirror models, per-mesh handedness keeps meshes of old projects as they were, python tools follow (--export-space for the old files)

This commit is contained in:
MineTec committed 2026-11-07 12:48:43 +01:00
1 parent c4fa500198
commit 4a58910fba
26 files changed
+525 -96

No files matched your search

+3 -3
View File
@@ -33,7 +33,7 @@ class ObjW:
self.f.append((tex, mat, [base + k for k in range(4)]))
def box(self, x0, z0, x1, z1, y0, y1, top, side):
# coordinates: x0<x1, z0>z1 (negative z = further into the grid)
# export space: x0<x1, z0>z1 (negative z = further into the grid); save() writes game space
self.quad([(x0, y1, z0), (x1, y1, z0), (x1, y1, z1), (x0, y1, z1)], top)
for (ax, az), (bx, bz) in [((x0, z0), (x0, z1)), ((x0, z1), (x1, z1)), ((x1, z1), (x1, z0)), ((x1, z0), (x0, z0))]:
# wall from a to b, outside = clockwise
@@ -45,12 +45,12 @@ class ObjW:
def save(self, path):
with open(path, 'w') as fo:
for p in self.v:
fo.write('v %.4f %.4f %.4f 1 1 1\n' % p)
fo.write('v %.4f %.4f %.4f 1 1 1\n' % G.to_file(p))
for u, v in self.vt:
fo.write('vt %.6f %.6f\n' % (u, 1 - v))
for tex, mat, ids in self.f:
fo.write(f'g demo_mat{mat:02d}\nusemtl tex_{tex}\n')
fo.write('f ' + ' '.join(f'{i + 1}/{i + 1}' for i in ids) + '\n')
fo.write('f ' + ' '.join(f'{i + 1}/{i + 1}' for i in G.file_order(ids)) + '\n')
def main():
+18 -11
View File
@@ -11,7 +11,9 @@ Writes to outdir:
objects.obj (--objects) static STPC object models side by side
preview_top.png, preview_persp.png textured software renderings for checking
Coordinates: export space = MAP floats (y up, right-handed, z < 0). Game space = (x, y, -z).
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
@@ -61,7 +63,7 @@ def flat_color(t):
return (t['u0'] / 255, t['u1'] / 255, t['v0'] / 255)
def write_obj(path, tris, texs, mtlname):
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')
@@ -77,10 +79,11 @@ def write_obj(path, tris, texs, mtlname):
if mat != cur_mat:
fo.write(f'usemtl {mat}\n')
cur_mat = mat
for p, c in zip(P, C):
fo.write('v %.5f %.5f %.5f %.3f %.3f %.3f\n' % (*p, *c))
for u, v in uv:
fo.write('vt %.6f %.6f\n' % (u, 1 - v))
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
@@ -168,12 +171,16 @@ def main():
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')
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)}')
@@ -194,8 +201,8 @@ def main():
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 pts:
fo.write('v %.5f %.5f %.5f\n' % f(*p))
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)
@@ -211,7 +218,7 @@ def main():
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' % (v[0] + dx, v[1], v[2]))
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:
@@ -220,7 +227,7 @@ def main():
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 range(3)) + '\n')
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
+16 -7
View File
@@ -4,8 +4,9 @@
geom_import.py base.wad mesh.obj new.wad [--default-tex N] [--no-coll] [--mat N]
[--keep-tiles] [--band 6]
Input OBJ in the export space of geom_export.py (y up, 1 unit = 1 tile, z < 0 lies in the
grid; grid cell (cx,cz) = (floor(x), floor(-z)), must be within 0..W-1 / 0..H-1).
Input OBJ in game space as geom_export.py writes it (right-handed, y up, 1 unit = 1 tile, z > 0
lies in the grid; grid cell (cx,cz) = (floor(x), floor(z)), must be within 0..W-1 / 0..H-1), i.e.
as Blender shows it. --export-space reads the old convention (raw export space, z < 0).
Textures: the engine has NO free UVs. Each triangle shows one half of a texture
rectangle (entry of the TEXT table); the corners are chosen via face flags.
@@ -43,7 +44,8 @@ import geomlib as G # noqa: E402
PATTERNS = [0x800, 0x810, 0x820, 0x830, 0x000, 0x020]
def read_obj(path):
def read_obj(path, export_space=False):
"""Faces and points in export space (converted from file space unless export_space)."""
V, C, VT = [], [], []
faces = [] # (idx list, vt list or None, material, group)
mat, grp = None, ''
@@ -52,7 +54,8 @@ def read_obj(path):
if not p:
continue
if p[0] == 'v':
V.append(tuple(float(x) for x in p[1:4]))
v = tuple(float(x) for x in p[1:4])
V.append(v if export_space else G.to_file(v))
C.append(tuple(float(x) for x in p[4:7]) if len(p) >= 7 else None)
elif p[0] == 'vt':
VT.append((float(p[1]), float(p[2])))
@@ -67,7 +70,11 @@ def read_obj(path):
a = int(s[0]); vi.append(a - 1 if a > 0 else len(V) + a)
if len(s) > 1 and s[1]:
b = int(s[1]); ti.append(b - 1 if b > 0 else len(VT) + b)
faces.append((vi, ti if len(ti) == len(vi) else None, mat, grp))
ti = ti if len(ti) == len(vi) else None
if not export_space:
vi = G.file_order(vi)
ti = ti and G.file_order(ti)
faces.append((vi, ti, mat, grp))
return V, C, VT, faces
@@ -116,12 +123,14 @@ def main():
ap.add_argument('--zones', choices=['flat', 'keep'], default='flat',
help='flat: disable zone visibility (PVS), draw everything; keep: original')
ap.add_argument('--band', type=float, default=6.0, help='height band per tile (units)')
ap.add_argument('--export-space', action='store_true',
help='OBJ in raw export space (z < 0; files of geom_export --export-space / before 2026-11-07)')
a = ap.parse_args()
ch, wf, trak, mp = G.load_level(a.base)
pages, texs = G.parse_text(G.wad_get(ch, 'TEXT'))
W, H = mp['W'], mp['H']
V, C, VT, faces = read_obj(a.obj)
V, C, VT, faces = read_obj(a.obj, a.export_space)
deftex = a.default_tex
if deftex is None:
deftex = next(i for i, t in enumerate(texs) if not (t['flags'] & 1) and t['page'] >= 0)
@@ -160,7 +169,7 @@ def main():
tris.append((P, Cc, tex, fl))
if a.coll_obj:
cV, _, _, cfaces = read_obj(a.coll_obj)
cV, _, _, cfaces = read_obj(a.coll_obj, a.export_space)
polys = []
for vi, ti, mat, grp in cfaces:
m = re.search(r'mat(\d+)', grp or '')
+18
View File
@@ -13,6 +13,24 @@ FUN_0042ac50 MAP, FUN_00406d30 texture table, FUN_00556510 tile renderer).
import math
import struct
# ---------------------------------------------------------------- OBJ file space
# OBJ files (geom_export/geom_import/geom_demo, the editor's Import mesh and OBJ/glTF export) are
# right-handed, y up, in game space: file (x, y, z) = export (x, y, -z). The game shows export space
# left-handed, so this is how Blender shows the level the same way round as the game. Converting
# mirrors, so the corner order reverses (first corner kept: export (0,1,2) <-> file (0,2,1)).
def to_file(p):
"""Export-space point -> OBJ file point."""
return (p[0], p[1], -p[2])
def file_order(seq):
"""Corner order export <-> file (an involution)."""
seq = list(seq)
return seq[:1] + seq[:0:-1]
# ---------------------------------------------------------------- WAD
+2 -2
View File
@@ -69,7 +69,7 @@ for ln in open(os.path.join(objdir, 'level.obj')):
if ln.startswith('v '):
p = ln.split()
x, y, z = float(p[1]), float(p[2]), float(p[3])
V.append((x, z, y)) # game (x, y, -z) -> Blender (x, -game_z, y) = (x, z_obj, y_obj)
V.append((x, -z, y)) # OBJ = game space (right-handed, y up) -> Blender z up
C.append((float(p[4]), float(p[5]), float(p[6])) if len(p) >= 7 else (1, 1, 1))
elif ln.startswith('vt '):
p = ln.split(); VT.append((float(p[1]), float(p[2])))
@@ -81,7 +81,7 @@ for ln in open(os.path.join(objdir, 'level.obj')):
q = t.split('/')
vi.append(int(q[0]) - 1)
ti.append(int(q[1]) - 1 if len(q) > 1 and q[1] else -1)
faces.append((vi[::-1], ti[::-1], mat)) # mirroring -> reverse winding
faces.append((vi, ti, mat))
matnames = sorted(set(f[2] for f in faces))
mesh = bpy.data.meshes.new('level')