Files
llamamod/tools/debugviz.py
T

730 lines
29 KiB
Python

#!/usr/bin/env python3
"""Debug-Visualisierung fuer Level-WADs von "Ein Koenigreich fuer ein Lama".
Macht Kollisionsgeometrie (auch unsichtbare Waende/Boeden) und unsichtbare Logik-Objekte
(Kameras, Trigger, Ausgaenge, Checkpoints, Kraftzonen ...) im Spiel sichtbar.
debugviz.py IN.wad OUT.wad [Optionen] ein Level
debugviz.py --all OUTDIR [--src CD-wads-Ordner] alle Level-WADs (t1l*, t6l*)
Optionen: --no-coll --no-markers --no-fill (nur Kanten) --opaque (Fuellung deckend)
--width 0.03 (Kantenbreite) --offset 0.012 (Abstand entlang der Normale)
--report datei.json
Prinzip (Details: notes/debugviz.md):
* Kollision: an jedes TRAK-Modell werden Render-Dreiecke angehaengt (Rahmen entlang der
Kanten jedes Kollisionspolygons, leicht entlang der Normale versetzt; bei unsichtbarer
Kollision zusaetzlich halbtransparente Fuellung). Damit stimmen Kachel-Transformation,
Zonen/Sichtbarkeit und Kachel-Wechsel automatisch. Kollisionspolygone werden NICHT
veraendert/ergaenzt. Vertexfarben-Block in MAP waechst je Kachel und Farbsatz mit.
* Objekte: Marker (Wuerfel, Linien, Boxen) als neue Kacheln ohne Kollision, je Rasterzelle
eine Kachel (also in der richtigen Zone). Strats bleiben unveraendert.
* Farben: angehaengte Flachfarb-Texturen (Flags 0x105 deckend, 0x1105 = Blendmodus 1
Alpha-Blending, Alpha im 4. Byte).
"""
import argparse
import collections
import glob
import json
import math
import os
import struct
import sys
import numpy as np
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import geomlib as G # noqa: E402
import mapchunk # noqa: E402
import strats as S # noqa: E402
CD_WADS = os.path.expanduser('~/.wine-lama/iso-dk/Setup/Eng/Cd/wads')
# ------------------------------------------------------------------ Farben
KIND_COL = {
'boden': (40, 255, 40), # begehbar (Normale steil nach oben)
'schraege': (200, 255, 0), # schraeg
'wand': (0, 150, 255), # Wand
'decke': (255, 130, 0), # Decke / Ueberhang
}
INVIS_FILL = (255, 0, 0) # unsichtbare Kollision (keine Render-Flaeche)
FILL_ALPHA = 96
# Material -> (Name, Farbe). Unbekannte Materialien bekommen Farben aus PALETTE.
MAT_INFO = {
16: ('Ausgang (LevelDoor-Boden)', (190, 60, 255)),
17: ('selektiv 0x11 (nur Objekte mit Flag 0x8000000)', (255, 255, 0)),
18: ('selektiv 0x12 (nicht fuer Objekte mit Flag 0x8000000)', (255, 200, 120)),
30: ('kein Boden / Fall-/Todesebene (0x1e)', (255, 0, 255)),
}
# feste Farben fuer die uebrigen Materialien (Bedeutung im Code nicht geklaert, siehe notes/debugviz.md)
MAT_FIXED = {
1: (0, 200, 200), 2: (255, 255, 255), 3: (255, 120, 170), 5: (120, 255, 200), 6: (160, 120, 255),
7: (255, 170, 80), 8: (0, 130, 60), 9: (150, 200, 255), 10: (150, 150, 0), 11: (150, 90, 40),
13: (255, 130, 110), 15: (255, 255, 150), 20: (0, 120, 140), 26: (110, 40, 160), 27: (190, 190, 190),
28: (255, 0, 140), 33: (150, 0, 0), 255: (20, 20, 20),
}
MAT_DEFAULT = (230, 230, 230)
MARK_COL = {
'start': (255, 255, 255),
'ausgang': (190, 60, 255),
'checkpoint': (0, 255, 140),
'kamera': (0, 255, 255),
'trigger': (255, 255, 0),
'kraft': (255, 90, 0),
'geheimnis': (255, 200, 0),
'sonst': (170, 170, 170),
'umgebung': (255, 100, 180),
'objekt': (90, 90, 90),
}
MARK_H = 0.35 # Markerwuerfel ueber dem Strat-Ursprung
MARK_SIZE = 0.05 # halbe Kantenlaenge
START_T = {'Llama', 'KuscoAndPachaTogetherOnATree', 'RollerCoaster', 'LlamaChase'}
EXIT_T = {'LevelDoor', 'VillageEndDoor', 'EndGameDoor', 'TeleportDoor'}
FORCE_T = {'FallOffNoHurt', 'ObjectDontDropInBox', 'RiverCurrentBox', 'RiverRebound',
'SuckingWPs', 'ObjectSuckPoints', 'RiverTrackChange', 'TrackSwitch',
'BackgroundRotationChanger', 'RiverWhirlpool'}
MISC_T = {'CheatTeleport', 'Wompy', 'WompySimple', 'WompyRollerCoaster', 'Rain', 'DrippingWater',
'WaterfallMist', 'Smoker', 'CoinLine', 'CoinCircle', 'CoinRollerCoaster', 'CoinBat',
'CoinSpawnOracle', 'MusicBox', 'SpitPlatformTarget', 'NewBlockheadKidHelper',
'FlockOfBirds', 'Bats', 'RiverBats', 'Scorpions1', 'Scorpions3', 'Scorpions5',
'GhostCatacombs', 'LabDumbGuards', 'FlyStuck', 'AppearingPlatforms', 'AppearingPlatformsCity',
'AppearingPlatformsJungle', 'AppearingPlatformsLab', 'AppearingPlatformsVillage', 'fat_bird',
'Column2', 'TipoHelp'}
TRIG_T = {'ChangeToBunny', 'ChangeToTurtle', 'RiverStartSign'}
def mark_cat(name):
n = name.replace('(spawn)', '')
if n in START_T:
return 'start'
if n in EXIT_T:
return 'ausgang'
if n.startswith('CheckPoint'):
return 'checkpoint'
if 'Camera' in n:
return 'kamera'
if n == 'Secret':
return 'geheimnis'
if n in FORCE_T or n.startswith('Force') or n.startswith('WaterRebound'):
return 'kraft'
if 'Trigger' in n or n.startswith('Say') or n in TRIG_T:
return 'trigger'
if n in MISC_T or n.endswith('Spawner') or 'Helper' in n:
return 'sonst'
return None
# ------------------------------------------------------------------ TEXT: Flachfarben anhaengen
def text_append_flats(d, entries):
"""entries: Liste (r,g,b,alpha|None). Liefert (neues TEXT, erster neuer Index)."""
o = 0
npal = struct.unpack_from('<I', d, o)[0]; o += 4
for _ in range(npal):
n = struct.unpack_from('<I', d, o + 4)[0]; o += 8 + 3 * n
npg = struct.unpack_from('<I', d, o)[0]; o += 4
for _ in range(npg):
fl, w, h = struct.unpack_from('<3I', d, o); o += 12
if fl == 0:
o += w * h
elif fl & 0x80:
o += 4 + struct.unpack_from('<I', d, o)[0]
else:
o += w * h * 2
nt = struct.unpack_from('<I', d, o)[0]
add = b''
for r, g, b, a in entries:
if a is None:
add += struct.pack('<HbbBBBB', 0x105, -1, -1, r, g, b, 0)
else: # Blendmodus 1 (Bits 12-14) = SRCALPHA/INVSRCALPHA, Alpha = 4. Byte
add += struct.pack('<HbbBBBB', 0x1105, -1, -1, r, g, b, a)
e = o + 4 + 8 * nt
return d[:o] + struct.pack('<I', nt + len(entries)) + d[o + 4:e] + add + d[e:], nt
class Palette:
def __init__(self):
self.entries = []
self.idx = {}
self.base = 0
def __call__(self, rgb, alpha=None):
k = (tuple(rgb), alpha)
if k not in self.idx:
self.idx[k] = len(self.entries)
self.entries.append((*rgb, alpha))
return self.idx[k] # relativ; + base beim Schreiben
# ------------------------------------------------------------------ Geometrie-Helfer
def norm(v):
n = np.linalg.norm(v)
return v / n if n > 1e-12 else v
class Mesh:
"""Dreiecksliste mit Paletten-Index; Verts (pos, normal)."""
def __init__(self):
self.verts = []
self.tris = [] # (a, b, c, palidx)
def v(self, p, n):
self.verts.append((float(p[0]), float(p[1]), float(p[2]), float(n[0]), float(n[1]), float(n[2])))
return len(self.verts) - 1
def quad(self, p, n, col):
"""4 Punkte (Ring), eine Normale."""
ids = [self.v(q, n) for q in p]
self.tris.append((ids[0], ids[1], ids[2], col))
self.tris.append((ids[0], ids[2], ids[3], col))
def beam(self, a, b, w, col):
"""Linie a->b als Kreuz aus zwei doppelseitigen Streifen."""
a = np.asarray(a, float); b = np.asarray(b, float)
d = b - a
L = np.linalg.norm(d)
if L < 1e-6:
return
d /= L
up = np.array([0, 1.0, 0]) if abs(d[1]) < 0.9 else np.array([1.0, 0, 0])
s1 = norm(np.cross(d, up)) * (w / 2)
s2 = norm(np.cross(d, s1)) * (w / 2)
for s in (s1, s2):
n = norm(np.cross(d, s))
self.quad([a - s, b - s, b + s, a + s], n, col)
def cube(self, c, h, col):
c = np.asarray(c, float)
for ax in range(3):
for sg in (-1, 1):
n = np.zeros(3); n[ax] = sg
u = np.zeros(3); u[(ax + 1) % 3] = h
v = np.zeros(3); v[(ax + 2) % 3] = h
f = c + n * h
self.quad([f - u - v, f + u - v, f + u + v, f - u + v], n, col)
def seg_split(a, b, maxlen=1.0):
a = np.asarray(a, float); b = np.asarray(b, float)
n = max(1, int(math.ceil(np.linalg.norm(b - a) / maxlen)))
return [(a + (b - a) * k / n, a + (b - a) * (k + 1) / n) for k in range(n)]
# ------------------------------------------------------------------ Kollision klassifizieren
def kind_of(ny):
if ny >= 0.7:
return 'boden'
if ny >= 0.2:
return 'schraege'
if ny > -0.2:
return 'wand'
return 'decke'
def decode_model_coll(m):
out = []
for g, grp in enumerate(m.coll):
for r in grp:
c = G.coll_decode(r)
pts = G.coll_polygon(c)
ok = len(pts) >= 3 and all(abs(x) < 12 for p in pts for x in p)
P = np.array(pts, float) if ok else None
if ok:
# Flaeche pruefen
ar = 0.0
for k in range(1, len(P) - 1):
ar += np.linalg.norm(np.cross(P[k] - P[0], P[k + 1] - P[0])) / 2
if ar < 1e-5:
ok = False
n = norm(np.array(c['plane'][:3]))
out.append(dict(c=c, pts=P if ok else None, n=n, mat=c['mat'], typ=c['typ'], grp=g,
kind=kind_of(n[1])))
return out
class RenderIndex:
"""Render-Dreiecke der Welt (Exportraum), nach Rasterzellen gebuckelt."""
def __init__(self, trak, mp):
cells = collections.defaultdict(list)
A, B, C = [], [], []
for i in range(mp['N']):
x, y, z, rot, mi, fl = mp['A'][i]
m = trak[mi]
if not m.faces:
continue
f, _ = G.tile_xform(x, y, z, rot)
W = np.array([f(*v[:3]) for v in m.verts])
for fc in m.faces:
a, b, c = (W[k] for k in fc['v'])
A.append(a); B.append(b); C.append(c)
self.A = np.array(A); self.B = np.array(B); self.C = np.array(C)
N = np.cross(self.B - self.A, self.C - self.A)
ln = np.linalg.norm(N, axis=1)
good = ln > 1e-10
N[good] /= ln[good, None]
self.N = N
self.D = np.einsum('ij,ij->i', N, self.A)
lo = np.minimum(np.minimum(self.A, self.B), self.C)
hi = np.maximum(np.maximum(self.A, self.B), self.C)
for t in np.nonzero(good)[0]:
for cx in range(int(math.floor(lo[t, 0])), int(math.floor(hi[t, 0])) + 1):
for cz in range(int(math.floor(lo[t, 2])), int(math.floor(hi[t, 2])) + 1):
cells[(cx, cz)].append(t)
self.cells = {k: np.array(v) for k, v in cells.items()}
def covered(self, p, n, dist=0.5, cosmin=0.4, tol=0.02):
"""Trifft der Strahl p + t*n (|t| <= dist) ein Render-Dreieck (Neigung zur Normale < ~66 Grad)?"""
ids = set()
for q in (p, p + n * dist, p - n * dist):
k = (int(math.floor(q[0])), int(math.floor(q[2])))
if k in self.cells:
ids.update(self.cells[k].tolist())
if not ids:
return False
ids = np.fromiter(ids, int)
N = self.N[ids]
cs = N @ n
sel = np.abs(cs) > cosmin
if not sel.any():
return False
ids, N, cs = ids[sel], N[sel], cs[sel]
t = (self.D[ids] - N @ p) / cs
sel = np.abs(t) <= dist
if not sel.any():
return False
ids, t = ids[sel], t[sel]
q = p[None, :] + t[:, None] * n[None, :]
a, b, c = self.A[ids], self.B[ids], self.C[ids]
v0, v1 = b - a, c - a
v2 = q - a
d00 = np.einsum('ij,ij->i', v0, v0); d01 = np.einsum('ij,ij->i', v0, v1)
d11 = np.einsum('ij,ij->i', v1, v1)
d20 = np.einsum('ij,ij->i', v2, v0); d21 = np.einsum('ij,ij->i', v2, v1)
den = d00 * d11 - d01 * d01
den[den == 0] = 1e-12
v = (d11 * d20 - d01 * d21) / den
w = (d00 * d21 - d01 * d20) / den
u = 1 - v - w
return bool(((u > -tol) & (v > -tol) & (w > -tol)).any())
def classify(trak, mp, decoded):
"""Setzt pro Polygon 'invis' (True = keine Render-Flaeche deckt es)."""
ri = RenderIndex(trak, mp)
votes = collections.defaultdict(lambda: [0, 0]) # (model, k) -> [sichtbar, unsichtbar]
inst = collections.defaultdict(list)
altmap = dict(zip([i for i in range(mp['N']) if mp['A'][i][5]], mp['anim']))
for i in range(mp['N']):
inst[mp['midx'][i]].append(i)
if i in altmap:
inst[altmap[i][4]].append(i)
for mi, polys in enumerate(decoded):
tiles = inst.get(mi, [])[:6] # wenige Instanzen reichen
for k, pl in enumerate(polys):
if pl['pts'] is None:
continue
P = pl['pts']
cen = P.mean(axis=0)
samples = [cen] + [cen + (q - cen) * 0.7 for q in P]
if not tiles:
votes[(mi, k)][1] += 1 # nie platziert: ohne Weltbezug -> als unsichtbar werten
continue
for ti in tiles:
x, y, z, rot, _, _ = mp['A'][ti]
f, fn = G.tile_xform(x, y, z, rot)
nw = np.array(fn(*pl['n']))
hit = sum(ri.covered(np.array(f(*s)), nw) for s in samples)
votes[(mi, k)][0 if hit >= 0.6 * len(samples) else 1] += 1
for mi, polys in enumerate(decoded):
for k, pl in enumerate(polys):
vs = votes.get((mi, k), [1, 0])
pl['invis'] = vs[1] > vs[0]
# ------------------------------------------------------------------ Overlay je Modell
def mat_color(mat, matcols):
if mat in MAT_INFO:
return MAT_INFO[mat][1]
matcols[mat] = MAT_FIXED.get(mat, MAT_DEFAULT)
return matcols[mat]
def poly_overlay(mesh, pl, pal, opt, matcols):
P = pl['pts']
if P is None:
return
n = pl['n']
c = pl['c']
mat = pl['mat']
ecol = KIND_COL[pl['kind']] if mat == 0 else mat_color(mat, matcols)
off = n * opt.offset
cen = P.mean(axis=0)
# Inkreis-Abschaetzung: kleinster Abstand Mittelpunkt -> Kante
rmin = 1e9
for k in range(len(P)):
a, b = P[k], P[(k + 1) % len(P)]
e = b - a
L = np.linalg.norm(e)
if L < 1e-9:
continue
rmin = min(rmin, np.linalg.norm(np.cross(e, cen - a)) / L)
w = min(opt.width, 0.4 * rmin)
# innere Ecken: Kantengeraden um w nach innen verschoben, mit Flaechenebene schneiden
E = c['edges']
m = len(E)
ok = True
pts2 = []
for k in range(m):
e1, e2 = E[k], E[(k + 1) % m]
Am = np.array([c['plane'][:3], e1[:3], e2[:3]])
Bm = np.array([c['plane'][3], e1[3] + w * np.linalg.norm(e1[:3]), e2[3] + w * np.linalg.norm(e2[:3])])
try:
pts2.append(np.linalg.solve(Am, Bm))
except np.linalg.LinAlgError:
ok = False
break
if ok and len(pts2) == len(P) and all(np.linalg.norm(pts2[k] - P[k]) < 4 * w + 0.05 for k in range(len(P))):
inner = pts2
else:
inner = [cen + (q - cen) * 0.85 for q in P]
O = [mesh.v(q + off, n) for q in P] # Ecken einmal anlegen (Rahmen + Fuellung teilen sie)
I = [mesh.v(q + off, n) for q in inner]
ec = pal(ecol)
for k in range(len(P)):
k2 = (k + 1) % len(P)
mesh.tris.append((O[k], O[k2], I[k2], ec))
mesh.tris.append((O[k], I[k2], I[k], ec))
fill = None
if not opt.no_fill:
if mat == 16:
fill = mat_color(mat, matcols)
elif pl['invis'] and mat != 30:
fill = INVIS_FILL if mat == 0 else mat_color(mat, matcols)
if fill is not None:
fc = pal(fill, None if opt.opaque else FILL_ALPHA)
ids = I
for k in range(1, len(ids) - 1):
mesh.tris.append((ids[0], ids[k], ids[k + 1], fc))
def append_mesh(model, mesh, pal_base, limit=7900):
"""Haengt Mesh an Modell an (Batch-Flag 0x8 bei Texturwechsel / alle 256 Faces)."""
nv0 = len(model.verts)
if nv0 + len(mesh.verts) > limit:
return 0
model.verts += mesh.verts
tris = sorted(mesh.tris, key=lambda t: t[3])
last, run = None, 0
for a, b, c, col in tris:
tex = pal_base + col
fl = 0x0800 | 0x0400 | 0x4000
if tex != last or run >= 250:
fl |= 0x8
last, run = tex, 0
run += 1
p0, p1, p2 = (model.verts[nv0 + k][:3] for k in (a, b, c))
model.faces.append(dict(flags=fl, v=(nv0 + a, nv0 + b, nv0 + c), tex=tex, pad=0,
plane=G.face_plane(p0, p1, p2)))
model.dirty = True
return len(mesh.verts)
# ------------------------------------------------------------------ Marker fuer Objekte
def fx(v):
v = v - (1 << 32) if v >= 1 << 31 else v
return v / 4096
def strat_markers(ch, wf, pal, stats):
"""Liefert Liste (welt-Mesh-Teile) als dict cell -> Mesh in Weltkoordinaten (Exportraum)."""
mm = mapchunk.parse(G.wad_get(ch, 'MAP'), wf, mapchunk.trak_vertcounts(G.wad_get(ch, 'TRAK')))
st = G.wad_get(ch, 'STPC')
import stpcwalk
lo = stpcwalk.walk(st, wf)['code']
nodes = mm['nodes']
segs = [] # (a, b, breite, farbe) Weltraum Spielkoordinaten (z positiv)
cubes = [] # (c, h, farbe)
quads = [] # (4 Punkte, farbe, alpha)
per_cat = collections.Counter()
per_type = collections.Counter()
def chain(w):
out = []
while 0 <= w < len(nodes) and len(out) < 200:
nd = nodes[w]
out.append(np.array([fx(nd[5]), fx(nd[6]), fx(nd[7])]))
if nd[0] == 0:
break
w = nd[0]
return out
def box(lo_, hi_, col, w=0.04):
x0, y0, z0 = lo_; x1, y1, z1 = hi_
P = [np.array(p) for p in [(x0, y0, z0), (x1, y0, z0), (x1, y0, z1), (x0, y0, z1),
(x0, y1, z0), (x1, y1, z0), (x1, y1, z1), (x0, y1, z1)]]
for a, b in [(0, 1), (1, 2), (2, 3), (3, 0), (4, 5), (5, 6), (6, 7), (7, 4), (0, 4), (1, 5), (2, 6), (3, 7)]:
segs.append((P[a], P[b], w, col))
for s in mm['strats']:
name = S.strat_name(st, s[6], lo) or '?'
cat = mark_cat(name)
if cat is None:
# alle uebrigen Objekte: kleiner dunkler Punkt am Ursprung (nichts geht verloren)
per_cat['objekt'] += 1
cubes.append((np.array([fx(s[3]), fx(s[4]), fx(s[5])]) + [0, 0.06, 0], 0.03, MARK_COL['objekt']))
continue
per_cat[cat] += 1
per_type[name] += 1
col = MARK_COL[cat]
p = np.array([fx(s[3]), fx(s[4]), fx(s[5])])
rot = s[1] / 4096 * 2 * math.pi
# Marker: Wuerfel + Pfahl
top = p + [0, MARK_H, 0]
if cat == 'start':
# Pfeil flach auf dem Boden (blockiert die Startkamera nicht)
d = np.array([math.sin(rot), 0, math.cos(rot)]) # 0 = +z, 90 Grad = +x
q = np.array([d[2], 0, -d[0]])
g = p + [0, 0.03, 0]
segs.append((g - d * 0.3, g + d * 0.4, 0.025, col))
segs.append((g + d * 0.4, g + d * 0.2 + q * 0.15, 0.025, col))
segs.append((g + d * 0.4, g + d * 0.2 - q * 0.15, 0.025, col))
else:
cubes.append((top, MARK_SIZE, col))
segs.append((p, top, 0.01, col))
if cat in ('kamera', 'checkpoint'):
d = np.array([math.sin(rot), 0, math.cos(rot)])
segs.append((top, top + d * 0.3, 0.012, col))
if cat == 'ausgang':
cx, cz = math.floor(p[0]), math.floor(p[2])
box((cx, p[1] - 0.05, cz), (cx + 1, p[1] + 1.0, cz + 1), col, 0.02)
ch_ = chain(s[13]) if s[13] >= 0 else []
if len(ch_) == 2:
a, b = ch_
lo_ = np.minimum(a, b); hi_ = np.maximum(a, b)
inside = (lo_[0] - 0.4 <= p[0] <= hi_[0] + 0.4) and (lo_[2] - 0.4 <= p[2] <= hi_[2] + 0.4)
if inside and hi_[0] - lo_[0] > 0.2 and hi_[2] - lo_[2] > 0.2:
y0 = min(lo_[1], p[1]) - 0.02
y1 = max(hi_[1], p[1]) + 1.2
box((lo_[0], y0, lo_[2]), (hi_[0], y1, hi_[2]), col, 0.02)
stats['boxen'] += 1
if (hi_[0] - lo_[0]) * (hi_[2] - lo_[2]) <= 64:
quads.append(([(lo_[0], y0 + 0.03, lo_[2]), (hi_[0], y0 + 0.03, lo_[2]),
(hi_[0], y0 + 0.03, hi_[2]), (lo_[0], y0 + 0.03, hi_[2])], col))
continue
if ch_ and cat in ('kamera', 'trigger', 'kraft', 'sonst', 'geheimnis', 'checkpoint', 'start'):
dim = tuple(int(c * 0.6) for c in col)
prev = p + [0, 0.45, 0]
for k, q in enumerate(ch_):
cubes.append((q, 0.025, dim))
if cat == 'kraft':
segs.append((prev, q, 0.008, dim))
prev = q
stats['pfadpunkte'] += len(ch_)
# Umgebungszonen (MAP-Block 4, 90 Byte: Zelle x/y/z, Nebel, Musik ...) -> Drahtwuerfel je Zelle
col = MARK_COL['umgebung']
for L in mm['lights']:
cx, cy, cz = L[0], L[1], L[2]
if L[3:6] == (1, 1, 1):
continue # Vorgabe-Eintrag (ganzes Level)
box((cx + 0.04, cy, cz + 0.04), (cx + 0.96, cy + 0.92, cz + 0.96), col, 0.02)
per_cat['umgebung'] += 1
# alles in Zellen buckeln (Exportraum: z negieren)
cells = collections.defaultdict(Mesh)
def cell_of(q):
return (int(math.floor(q[0])), int(math.floor(q[2])))
def ex(q):
return np.array([q[0], q[1], -q[2]])
for a, b, w, col in segs:
for s0, s1 in seg_split(a, b, 1.0):
m = (s0 + s1) / 2
cells[cell_of(m)].beam(ex(s0), ex(s1), w, pal(col))
for c, h, col in cubes:
cells[cell_of(c)].cube(ex(c), h, pal(col))
for pts, col in quads:
# in <=1x1-Stuecke zerlegen
(x0, y, z0), _, (x1, _, z1), _ = pts
xs = np.arange(math.floor(x0), math.ceil(x1))
zs = np.arange(math.floor(z0), math.ceil(z1))
for cx in xs:
for cz in zs:
a0, a1 = max(x0, cx), min(x1, cx + 1)
b0, b1 = max(z0, cz), min(z1, cz + 1)
if a1 - a0 < 1e-3 or b1 - b0 < 1e-3:
continue
ms = cells[(int(cx), int(cz))]
q = [ex((a0, y, b0)), ex((a1, y, b0)), ex((a1, y, b1)), ex((a0, y, b1))]
ids = [ms.v(t, (0, 1, 0)) for t in q]
fc = pal(col, 70)
ms.tris.append((ids[0], ids[1], ids[2], fc))
ms.tris.append((ids[0], ids[2], ids[3], fc))
stats['marker_kat'] = dict(per_cat)
stats['marker_typen'] = dict(per_type)
return cells
# ------------------------------------------------------------------ Hauptablauf
def process(src, dst, opt):
ch = G.wad_read(src)
wf = G.wfpc_flags(ch)
trak = G.parse_trak(G.wad_get(ch, 'TRAK'))
old_vc = [len(m.verts) for m in trak]
mp = G.parse_map_tiles(G.wad_get(ch, 'MAP'), wf, old_vc)
pal = Palette()
matcols = {}
stats = collections.Counter()
info = dict(level=os.path.basename(src))
mats = collections.Counter()
add_v = [0] * len(trak)
if not opt.no_coll:
decoded = [decode_model_coll(m) for m in trak]
classify(trak, mp, decoded)
# Statistik je Kachelinstanz
inst = collections.Counter(mp['midx'])
for mi, polys in enumerate(decoded):
for pl in polys:
mats[pl['mat']] += 1
stats['koll_polys'] += 1
if pl['pts'] is None:
stats['koll_kaputt'] += 1
elif pl['invis']:
stats['koll_unsichtbar'] += 1
stats['koll_unsichtbar_inst'] += inst.get(mi, 0)
for mi, m in enumerate(trak):
mesh = Mesh()
for pl in decoded[mi]:
poly_overlay(mesh, pl, pal, opt, matcols)
add_v[mi] = len(mesh.verts)
mesh_store = mesh
trak[mi]._ov = mesh_store
# Marker
cells = {}
if not opt.no_markers:
cells = strat_markers(ch, wf, pal, stats)
# TEXT erweitern
text, base = text_append_flats(G.wad_get(ch, 'TEXT'), pal.entries)
G.wad_set(ch, 'TEXT', text)
for mi, m in enumerate(trak):
ov = getattr(m, '_ov', None)
if ov is not None and ov.verts:
got = append_mesh(m, ov, base)
if got == 0:
stats['modelle_zu_gross'] += 1
add_v[mi] = 0
# neue Marker-Kacheln
W, H = mp['W'], mp['H']
new_tiles = []
for (cx, cz), mesh in sorted(cells.items()):
if not mesh.verts:
continue
if not (0 <= cx < W and 0 <= cz < H):
stats['marker_ausserhalb'] += 1
continue
ys = [v[1] for v in mesh.verts]
oy = round((min(ys) + max(ys)) / 2 * 4) / 4
ox, oz = cx + 0.5, -(cz + 0.5)
mdl = G.Model()
loc = Mesh()
loc.verts = [(v[0] - ox, v[1] - oy, v[2] - oz, v[3], v[4], v[5]) for v in mesh.verts]
loc.tris = mesh.tris
# zu grosse Zellen aufteilen
if len(loc.verts) > 7900:
stats['marker_gekuerzt'] += 1
keep = [t for t in loc.tris if max(t[:3]) < 7900]
loc.tris = keep
loc.verts = loc.verts[:7900]
append_mesh(mdl, loc, base)
trak.append(mdl)
new_tiles.append(dict(x=ox, y=oy, z=oz, rot=0, model=len(trak) - 1,
cols=[[0x00808080] * len(mdl.verts)]))
stats['marker_kacheln'] = len(new_tiles)
# MAP neu
altmap = dict(zip([i for i in range(mp['N']) if mp['A'][i][5]], mp['anim']))
tiles = []
for i in range(mp['N']):
x, y, z, rot, mi, fl = mp['A'][i]
ext = [0x00808080] * add_v[mi]
t = dict(x=x, y=y, z=z, rot=rot, model=mi, cols=[fr + ext for fr in mp['cols'][i]], flag=fl)
if i in altmap:
am = altmap[i][4]
t['altcols'] = [fr + [0x00808080] * add_v[am] for fr in mp['altcols'][i]]
tiles.append(t)
tiles += new_tiles
newmap = G.build_map_tiles(G.wad_get(ch, 'MAP'), wf, old_vc, tiles, zones='keep', anim=mp['anim'])
G.wad_set(ch, 'TRAK', G.build_trak(trak))
G.wad_set(ch, 'MAP', newmap)
# Gegenprobe: MAP vollstaendig parsen, Strats unveraendert
vc = [len(m.verts) for m in trak]
a = mapchunk.parse(newmap, wf, vc)
b = mapchunk.parse(G.wad_get(G.wad_read(src), 'MAP'), wf, old_vc)
assert a['rest'] == b['rest'] and a['strats'] == b['strats'] and a['nodes'] == b['nodes']
assert a['tiles'][:mp['N']] == b['tiles']
nv_max = max(vc)
G.wad_write(dst, ch)
info.update(stats)
info['materialien'] = {str(k): v for k, v in sorted(mats.items())}
info['materialfarben'] = {str(k): v for k, v in matcols.items()}
info['max_verts_modell'] = nv_max
info['max_faces_modell'] = max(len(m.faces) for m in trak)
info['texturen_neu'] = len(pal.entries)
info['groesse_alt'] = os.path.getsize(src)
info['groesse_neu'] = os.path.getsize(dst)
info['kacheln'] = mp['N'] + len(new_tiles)
info['modelle'] = len(trak)
return info
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument('inp', nargs='?')
ap.add_argument('out', nargs='?')
ap.add_argument('--all', metavar='OUTDIR')
ap.add_argument('--src', default=CD_WADS)
ap.add_argument('--only', help='Kommagetrennte Levelnamen (mit --all)')
ap.add_argument('--no-coll', action='store_true')
ap.add_argument('--no-markers', action='store_true')
ap.add_argument('--no-fill', action='store_true')
ap.add_argument('--opaque', action='store_true')
ap.add_argument('--width', type=float, default=0.007)
ap.add_argument('--offset', type=float, default=0.012)
ap.add_argument('--report')
opt = ap.parse_args()
infos = []
if opt.all:
os.makedirs(opt.all, exist_ok=True)
files = sorted(glob.glob(os.path.join(opt.src, 't[1-6]l*.wad')))
if opt.only:
want = {w.lower().replace('.wad', '') for w in opt.only.split(',')}
files = [f for f in files if os.path.basename(f).lower()[:-4] in want]
for f in files:
info = process(f, os.path.join(opt.all, os.path.basename(f)), opt)
infos.append(info)
print('%-13s %5d KB -> %5d KB Koll %5d (unsichtbar %4d) Marker-Kacheln %3d maxV %4d' % (
info['level'], info['groesse_alt'] // 1024, info['groesse_neu'] // 1024, info.get('koll_polys', 0),
info.get('koll_unsichtbar', 0), info.get('marker_kacheln', 0), info['max_verts_modell']), flush=True)
else:
if not (opt.inp and opt.out):
ap.error('IN.wad OUT.wad oder --all OUTDIR')
info = process(opt.inp, opt.out, opt)
infos.append(info)
print(json.dumps(info, indent=1, ensure_ascii=False))
if opt.report:
json.dump(infos, open(opt.report, 'w'), indent=1, ensure_ascii=False)
if __name__ == '__main__':
main()