saumwacht-godot/tools/verify_funkenlanze.py
Martin Stickelbroeck c2b447610c Baseline: Saumwacht 0.15.0 (Phase 20A) vor dem Umbau zu Champions of Luneraria
Unveränderter Ausgangsstand inklusive Masterprompt und Konzeptbildern
unter assets/. .godot/ und docs/screenshots/ bleiben laut .gitignore
unversioniert; letztere sind unter ../backups gesichert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 20:00:36 +02:00

63 lines
2.7 KiB
Python

"""Validate the portable Funkenlanze skin/animation asset, independent of Godot."""
from pathlib import Path
import json, math, sys
sys.path.insert(0, str(Path(__file__).resolve().parent))
from verify_phase4_glb import GLB, topology
p = Path(__file__).resolve().parents[1]
g = GLB(p / 'assets/characters/funkenlanze.glb')
d = g.doc
checks = []
def check(v, msg):
assert v, msg
checks.append(msg)
check(len(d['skins']) == 1, 'one portable skin')
check(len(d['skins'][0]['joints']) == 26, '26 distinct joint bindings')
expected = {'idle', 'start', 'walk', 'stop', 'attack_a', 'attack_b', 'hit', 'ability', 'death'}
check({a['name'] for a in d['animations']} == expected, 'nine complete clips')
weighted = 0
total = 0
max_error = 0
for m in d['meshes']:
for q in m['primitives']:
attr = q['attributes']
vertices = g.accessor(attr['POSITION'])
indices = [x[0] for x in g.accessor(q['indices'])]
t = topology(vertices, [indices[i:i + 3] for i in range(0, len(indices), 3)])
name = d['materials'][q['material']]['name']
check(t['closed'] and t['signed_volume'] > 0 and t['degenerate_triangles'] == 0,
'closed outward geometry ' + name)
check(all(x in attr for x in ('JOINTS_0', 'WEIGHTS_0', 'POSITION', 'NORMAL', 'TEXCOORD_0')),
'skin, normals and UVs')
joints = g.accessor(attr['JOINTS_0'])
weights = g.accessor(attr['WEIGHTS_0'])
normals = g.accessor(attr['NORMAL'])
for js, ws, n in zip(joints, weights, normals):
assert all(0 <= j < 26 for j in js)
assert all(math.isfinite(w) and 0 <= w <= 1 for w in ws)
max_error = max(max_error, abs(sum(ws) - 1))
weighted += sum(w > .001 for w in ws) > 1
total += 1
assert all(math.isfinite(v) for v in n)
check(max_error < .0001, 'all vertex weights normalized')
# Mineral plates stay single-weighted; continuous limb/torso cores carry the blend zones.
check(weighted / total > .08, 'joint zones have blended vertices')
for a in d['animations']:
check(len(a['channels']) > 5, a['name'] + ' animates multiple joints')
for samp in a['samplers']:
times = [v[0] for v in g.accessor(samp['input'])]
assert times == sorted(times) and times[0] >= 0
assert all(math.isfinite(x) for row in g.accessor(samp['output']) for x in row)
report = {
'checks': len(checks),
'failures': [],
'vertices': total,
'blended_vertices': weighted,
'blended_percent': round(weighted / total * 100, 2),
'max_weight_sum_error': max_error,
'clips': sorted(expected),
'bones': 26,
}
(p / 'docs/phase18-skin-audit.json').write_text(json.dumps(report, indent=2) + '\n')
print(report)