Merge custom character model library and native roster

This commit is contained in:
chazmm committed 2026-09-08 20:00:04 +01:00
commit ba929487c1
26 files changed
+2420 -18

No files matched your search

+125
View File
@@ -0,0 +1,125 @@
"""Publish owned retail Marquee models without autorigging or retargeting."""
import argparse, hashlib, json, os, shutil, struct, sys, tempfile
import xml.etree.ElementTree as ET
from pathlib import Path
import numpy as np
from PIL import Image, ImageChops
from tools.owned_game.big import BigArchive
from tools.extract_default_skater import import_rx2_parser, decode_texture
from tools.asset_pipeline.character_glb import Glb, convert
from tools.asset_pipeline.retail_character import RX2, decode_dense_morphs
# GetCACSettings 82590BE0..82590DBC, TU3: six named styles, Aggressive otherwise.
STYLES={'danny_way':'DannyWay','mike_carroll':'MikeCarroll','pj_ladd':'PJLadd',
'jason_dill':'JasonDill','jerry_hsu':'JerryHsu','rob_dyrdek':'RobDyrdek'}
LABELS={'isaak':'Isaac Clarke (Dead Space)','steak':'Meat Man','dem_bones':'Dem Bones',
'dr_pepper':'Dr Pepper Mascot','deerman':'Deerman of Dark Woods','pj_ladd':'PJ Ladd',
'reda':'Giovanni Reda','attiba_jefferson':'Atiba Jefferson','cuz':'Cuz Parry'}
def finalize_glb(path):
"""Remove numerical perspective residue; retain native affine bind transforms."""
data=path.read_bytes();length=struct.unpack_from('<I',data,12)[0]
writer=Glb();writer.doc=json.loads(data[20:20+length]);writer.data=bytearray(data[28+length:])
for node in writer.doc['nodes']:
if 'matrix' in node:
m=node['matrix']
if max(abs(m[i]) for i in [3,7,11])+abs(m[15]-1)>1e-6:raise ValueError('Non-affine retail joint')
for i in [3,7,11]:m[i]=0.
m[15]=1.
for skin in writer.doc['skins']:
a=writer.doc['accessors'][skin['inverseBindMatrices']];v=writer.doc['bufferViews'][a['bufferView']]
for j in range(a['count']):
offset=v.get('byteOffset',0)+a.get('byteOffset',0)+j*64
for i in [3,7,11]:struct.pack_into('<f',writer.data,offset+i*4,0.)
struct.pack_into('<f',writer.data,offset+60,1.)
for mesh in writer.doc['meshes']:
for p in mesh['primitives']:
for a in p['attributes'].values():writer.doc['bufferViews'][writer.doc['accessors'][a]['bufferView']]['target']=34962
writer.doc['bufferViews'][writer.doc['accessors'][p['indices']]['bufferView']]['target']=34963
temporary=path.with_suffix('.tmp');writer.save(temporary);os.replace(temporary,path)
def roster(rows):
rows={r['key']:r for r in rows if r['class']=='characters_marquee'}
result=[]
for key,row in rows.items():
chain=[]; node=row
while node:
if node['key'] in chain:raise ValueError('Cyclic character inheritance')
chain.append(node['key']);node=rows.get(node['parent'])
if not {'pro_skaters','ip'}.intersection(chain):continue
recipe=row['fields'].get('Hash_6C9F05D8DBE7A492',{}).get('data','')
if not recipe:continue
result.append({'key':key,'recipe':recipe,'name':LABELS.get(key,key.replace('_',' ').title()),
'category':'Special' if 'ip' in chain else 'Pro',
'animation_style':STYLES.get(key,'Aggressive')})
return sorted(result,key=lambda r:r['name'])
def prepare(game,assets,library,collections,work,only=None):
archive=BigArchive(game/'data/content/marquee.big');entries={e.path.lower():e for e in archive.entries}
work.mkdir(parents=True,exist_ok=True)
parser=import_rx2_parser(Path(__file__).resolve().parents[1]/'vendor/utt')
def extract(path):
dest=work/'source'/path
if not dest.exists():dest.parent.mkdir(parents=True,exist_ok=True);dest.write_bytes(archive.read(entries[path.lower()]))
return dest
def texture(tid):
dest=work/'decoded'/(tid+'.png');dest.parent.mkdir(exist_ok=True)
if not dest.exists():decode_texture(parser,extract('data/content/marquee/texture/0x'+tid+'.rx2'),dest)
return dest
records=roster(json.loads(collections.read_text())['collections']);report=[]
for item in records:
if only and item['key'] not in only:continue
key=item['key'];recipe_name=item['recipe'];folder=work/key;models=folder/'models';mats=folder/'materials'
models.mkdir(parents=True,exist_ok=True);mats.mkdir(exist_ok=True)
identity=hashlib.sha256(('native-marquee-v1:'+key).encode()).hexdigest();target=library/'entries'/identity
try:
xml=extract('data/content/recipe/marquee/'+recipe_name+'.xml');root=ET.fromstring(xml.read_bytes())
material_defs={m.attrib['id']:m for m in root.findall('mat')}
recipe={'components':[],'morph_assembly':{'expected_targets':{},'face_targets':[]},'preset':{'body_mods':{}}}
for component in root.findall('comp'):
slot=component.attrib['n'];mods=component.findall('mod')
if len(mods)!=1:raise ValueError('Ambiguous native component '+slot)
lod=next(l for l in mods[0].findall('lod') if l.get('idx')=='0')
raw=extract(f"data/content/marquee/model/{recipe_name}/{slot}/{lod.attrib['arenaid']}.rx2")
dest=models/slot/raw.name;dest.parent.mkdir(exist_ok=True);shutil.copyfile(raw,dest)
mid=lod.find('matinst/matvar').attrib['id'];mat=material_defs[mid]
tex={s.attrib['chn']:s.attrib['id'].removeprefix('0x') for s in mat.findall('sp')}
diffuse=Image.open(texture(tex['diffuse'])).convert('RGBA')
if 'alpha' in tex:
alpha=Image.open(texture(tex['alpha'])).convert('RGB').getchannel('R');diffuse.putalpha(alpha)
diffuse.save(mats/(slot+'_base_color.png'))
if 'normal' in tex:
a=np.asarray(Image.open(texture(tex['normal'])).convert('RGBA'),dtype=float)
x=a[:,:,3]/127.5-1.;y=a[:,:,1]/127.5-1.;s=np.maximum(np.sqrt(x*x+y*y),1.);x/=s;y/=s
z=np.sqrt(np.maximum(0.,1.-x*x-y*y));Image.fromarray(np.rint((np.stack((x,y,z),2)*.5+.5)*255).astype('uint8')).save(mats/(slot+'_normal.png'))
if 'specular' in tex:ImageChops.invert(Image.open(texture(tex['specular'])).convert('L')).save(mats/(slot+'_roughness.png'))
parsed=RX2.parse_rx2(str(dest));mesh=next(m for m in parsed['meshes'] if m.get('positions') and m.get('indices'))
morphs=decode_dense_morphs(dest,parsed,len(mesh['positions']),RX2)
recipe['morph_assembly']['expected_targets'][slot]=[m['name'] for m in morphs]
recipe['components'].append({'slot':slot,'tint':[1,1,1],'textures':tex,'alpha_mode':'MASK' if 'alpha' in tex else 'OPAQUE'})
target.parent.mkdir(parents=True,exist_ok=True)
if not target.exists():
with tempfile.TemporaryDirectory(prefix='.native-',dir=library) as tmp:
stage=Path(tmp)/'entry';stage.mkdir()
convert(models,assets/'private',recipe,output=stage/'character.glb',materials=mats)
finalize_glb(stage/'character.glb')
sys.path.insert(0,str(Path(__file__).resolve().parents[1]/'mixamo_to_skate'))
from library_import import thumbnail
thumbnail(stage/'character.glb',stage/'preview.png')
metadata={'version':1,'id':identity,'name':item['name'],'native':item,
'source_recipe_sha256':hashlib.sha256(xml.read_bytes()).hexdigest()}
(stage/'manifest.json').write_text(json.dumps(metadata,indent=2))
(stage/'recipe.json').write_text(json.dumps(recipe,indent=2))
stage.rename(target)
report.append({**item,'id':identity,'status':'ready'})
print('READY',key,flush=True)
except Exception as e:
report.append({**item,'status':'error','error':str(e)});print('ERROR',key,str(e),flush=True)
(work/'report.json').write_text(json.dumps(report,indent=2))
return report
if __name__=='__main__':
p=argparse.ArgumentParser();p.add_argument('--game',type=Path,required=True);p.add_argument('--assets',type=Path,required=True)
p.add_argument('--library',type=Path,required=True);p.add_argument('--collections',type=Path,required=True);p.add_argument('--work',type=Path,required=True);p.add_argument('--only',nargs='*')
a=p.parse_args();r=prepare(a.game,a.assets,a.library,a.collections,a.work,a.only)
raise SystemExit(1 if any(x['status']=='error' for x in r) else 0)
+2
View File
@@ -0,0 +1,2 @@
__pycache__/
*.pyc
+424
View File
@@ -0,0 +1,424 @@
"""Fit a Mixamo bind mesh to the installed Skate render rig. No runtime edits."""
import copy
import hashlib
import json
import os
import subprocess
import tempfile
from pathlib import Path
import numpy as np
from glb import ConversionError, Document, Writer, require, clean_node_transform
VERSION = 1
MAP = {'hips':'HIPS', 'spine':'SPINE', 'spine1':'SPINE1', 'spine2':'SPINE3',
'neck':'NECK', 'head':'HEAD'}
for side in ('left', 'right'):
for part in ('shoulder', 'arm', 'forearm', 'hand', 'upleg', 'leg', 'foot', 'toebase'):
MAP[side+part] = (side+part).upper()
REQUIRED = set(MAP) - {'lefttoebase', 'righttoebase'}
BOARD = {'SKATEBOARD_ROOT', 'TRUCK_FRONT', 'TRUCK_BACK', 'LEFT_WHEELFRONT',
'RIGHT_WHEELFRONT', 'LEFT_WHEELBACK', 'RIGHT_WHEELBACK'}
def sha(path):
return hashlib.sha256(Path(path).read_bytes()).hexdigest()
def key(name):
# Mixamo prefixes may include a namespace number. Do not guess arbitrary rigs.
name = name.rsplit(':', 1)[-1].lower()
return name
def named_nodes(doc):
result = {}
for i, n in enumerate(doc.doc['nodes']):
k = key(n.get('name', ''))
if k in MAP or k.startswith(('lefthand', 'righthand')) or k.endswith('_end'):
require(k not in result, 'Ambiguous duplicated bone: '+k)
result[k] = i
require(REQUIRED <= result.keys(), 'Not a supported Mixamo humanoid; missing: '+
', '.join(sorted(REQUIRED-result.keys())))
for k in REQUIRED - {'hips'}:
parent = doc.parents.get(result[k])
ancestors = set()
while parent is not None:
ancestors.add(parent)
parent = doc.parents.get(parent)
require(result['hips'] in ancestors, k+' is outside the hip hierarchy')
return result
def bind_worlds(doc):
"""Derive bind globals from skin matrices, not frame-zero animation."""
result = {}
for ni, node in enumerate(doc.doc['nodes']):
if 'mesh' not in node:
continue
require('skin' in node, 'Unskinned mesh: '+node.get('name', str(ni)))
skin = doc.doc['skins'][node['skin']]
require('inverseBindMatrices' in skin, 'Missing inverse bind matrices')
matrices = doc.accessor(skin['inverseBindMatrices'])
require(len(matrices) == len(skin['joints']), 'Inverse bind count mismatch')
for ji, joint in enumerate(skin['joints']):
inverse = matrices[ji].reshape(4, 4).T
require(abs(np.linalg.det(inverse)) > 1e-12, 'Singular skin bind')
world = doc.worlds[ni] @ np.linalg.inv(inverse)
if joint in result:
require(np.allclose(result[joint], world, atol=1e-5, rtol=1e-4),
'Meshes disagree about a bone bind pose')
result[joint] = world
# Unweighted endpoints inherit the nearest known bind transform.
def solve(i):
if i in result:
return result[i]
parent = doc.parents.get(i)
result[i] = (solve(parent) @ np.linalg.inv(doc.worlds[parent]) @ doc.worlds[i]
if parent is not None else doc.worlds[i])
return result[i]
for i in range(len(doc.doc['nodes'])):
solve(i)
return result
def mesh_bounds(doc, exclude_board=False):
points = []
for ni, node in enumerate(doc.doc['nodes']):
if 'mesh' not in node:
continue
for p in doc.doc['meshes'][node['mesh']]['primitives']:
name = doc.doc.get('materials', [])[p['material']].get('name', '') if 'material' in p else ''
if exclude_board and name in ('Retail_SkateBoard', 'Retail_SkateTruck', 'Retail_SkateWheel'):
continue
pos = doc.accessor(p['attributes']['POSITION'])
points.append(pos @ doc.worlds[ni][:3, :3].T + doc.worlds[ni][:3, 3])
require(points, 'No character geometry')
return np.concatenate(points)
def normalization(doc, ref, binds, names):
"""Resolve body axes from landmarks, then height/ground, avoiding unit guesses."""
p = lambda k: binds[names[k]][:3, 3]
up = p('head')-p('hips')
require(np.linalg.norm(up)>1e-8, 'Degenerate head/hip landmarks')
up /= np.linalg.norm(up)
right = p('leftarm')-p('rightarm') # stock anatomical left is positive X
right -= up*np.dot(right, up)
require(np.linalg.norm(right) > 1e-8, 'Degenerate shoulder landmarks')
right /= np.linalg.norm(right)
forward = np.cross(right, up)
rotation = np.stack([right, up, forward])
points = mesh_bounds(doc) @ rotation.T
target = mesh_bounds(ref, True)
height = np.ptp(points[:, 1])
require(height > 1e-6, 'Degenerate character height')
scale = np.ptp(target[:, 1])/height
matrix = np.eye(4)
matrix[:3, :3] = rotation*scale
hip = rotation @ p('hips')*scale
matrix[:3, 3] = [-hip[0], target[:, 1].min()-points[:, 1].min()*scale, -hip[2]]
return matrix, scale
def source_skin(doc, primitive, skin):
attrs = primitive['attributes']
require('JOINTS_0' in attrs and 'WEIGHTS_0' in attrs, 'Mesh has no skin weights')
require(not any(k.startswith(('JOINTS_', 'WEIGHTS_')) and k[-1] not in '01' for k in attrs),
'More than eight source influences are unsupported')
js, ws = [], []
for channel in (0, 1):
j, w = 'JOINTS_'+str(channel), 'WEIGHTS_'+str(channel)
require((j in attrs) == (w in attrs), 'Unpaired skin attributes')
if j in attrs:
js.append(doc.accessor(attrs[j]).astype(np.int64))
ws.append(doc.accessor(attrs[w]).astype(float))
joints, weights = np.concatenate(js, axis=1), np.concatenate(ws, axis=1)
require((joints >= 0).all() and (joints < len(skin['joints'])).all(), 'Invalid joint index')
require((weights >= 0).all() and (weights.sum(axis=1) > 1e-8).all(), 'Invalid skin weights')
return joints, weights/weights.sum(axis=1, keepdims=True)
def reference_rig(ref):
require(len(ref.doc.get('skins', [])) == 1, 'Stock reference must have one shared skin')
skin = ref.doc['skins'][0]
names = [ref.doc['nodes'][i].get('name', '').upper() for i in skin['joints']]
require(len(names) == len(set(names)) and set(MAP.values()) <= set(names),
'Reference is not a complete supported stock render rig')
require(len(names) <= 256, 'Stock rig exceeds renderer joint limit')
inverse = ref.accessor(skin['inverseBindMatrices']).reshape(-1, 4, 4).transpose(0, 2, 1)
require(len(inverse) == len(names), 'Stock inverse bind count mismatch')
for j, inv in zip(skin['joints'], inverse):
require(np.allclose(ref.worlds[j] @ inv, np.eye(4), atol=2e-4),
'Stock reference is not in its mesh bind pose')
return skin, names, inverse
def create_profile(source, reference):
doc, ref = Document(source), Document(reference)
reference_rig(ref)
names, binds = named_nodes(doc), bind_worlds(doc)
norm, scale = normalization(doc, ref, binds, names)
# Round-trip pairing is meaningful only when the same exported mesh returned.
# Bounding-box calibration avoids hip fitting shifts in the paired source.
src_points, ref_points = mesh_bounds(doc), mesh_bounds(ref, True)
src_box = np.stack([src_points.min(axis=0), src_points.max(axis=0)])
ref_box = np.stack([ref_points.min(axis=0), ref_points.max(axis=0)])
scale = float(np.ptp(ref_points[:,1])/np.ptp(src_points[:,1]))
fit = np.eye(4)
fit[:3, :3] *= scale
fit[:3, 3] = ref_box.mean(axis=0)-src_box.mean(axis=0)*scale
require(np.max(np.abs((src_box*scale+fit[:3, 3])-ref_box)) < 0.015,
'Calibration is not the returned stock mesh (bounds differ by over 1.5 cm)')
return {'version':VERSION, 'reference_sha256':sha(reference), 'source_sha256':sha(source),
'normalization_scale':scale,
'bones':{k:(fit@binds[i]).tolist() for k, i in names.items()},
'note':'Private paired-stock calibration; contains no mesh or textures.'}
def fallback_targets(ref, names, binds, norm):
"""Anatomical aim frames for non-calibrated use. Never apply stock bone roll to Mixamo axes."""
_, target_names, inverse = reference_rig(ref)
stock = {n:np.linalg.inv(m) for n,m in zip(target_names, inverse)}
child = {'hips':'spine', 'spine':'spine1', 'spine1':'spine2', 'spine2':'neck',
'neck':'head'}
for s in ('left', 'right'):
child.update({s+'shoulder':s+'arm', s+'arm':s+'forearm', s+'forearm':s+'hand',
s+'upleg':s+'leg', s+'leg':s+'foot', s+'foot':s+'toebase'})
targets = {}
def rotation_between(a, b):
a, b = a/np.linalg.norm(a), b/np.linalg.norm(b)
v, c = np.cross(a, b), np.dot(a, b)
require(c > -0.99, 'Reversed limb in reference pose; export Mixamo T-pose')
k = np.array([[0,-v[2],v[1]], [v[2],0,-v[0]], [-v[1],v[0],0]])
return np.eye(3)+k+k@k/(1+c)
for k, target in MAP.items():
if k not in names:
continue
src = norm@binds[names[k]]
src[:3,:3] /= np.linalg.norm(src[:3,:3],axis=0)
result = src.copy()
result[:3, 3] = stock[target][:3, 3]
if k in child and child[k] in names:
src_direction = (norm@binds[names[child[k]]])[:3,3]-src[:3,3]
dst_direction = stock[MAP[child[k]]][:3,3]-result[:3,3]
if min(np.linalg.norm(src_direction), np.linalg.norm(dst_direction)) > 1e-6:
result[:3,:3] = rotation_between(src_direction, dst_direction)@src[:3,:3]
targets[k] = result
return targets
def convert_glb(source, reference, output, profile=None, include_board=True):
doc, ref = Document(source), Document(reference)
ref_skin, target_names, inverse = reference_rig(ref)
# The stock export contains ~1e-16 numerical residue in the affine row.
# glTF requires this row to be exactly (0,0,0,1).
require(np.allclose(inverse[:,3,:],[0,0,0,1],atol=1e-7), 'Non-affine stock inverse binds')
inverse[:,3,:] = [0,0,0,1]
names, binds = named_nodes(doc), bind_worlds(doc)
norm, scale = normalization(doc, ref, binds, names)
report = {'version':VERSION, 'source_sha256':sha(source), 'reference_sha256':sha(reference),
'mode':'fit_to_stock_proportions', 'scale':float(scale), 'warnings':[],
'vertices':0, 'triangles':0, 'collapsed_bones':{}, 'max_discarded_weight':0.0}
if profile:
require(profile.get('version') == VERSION and profile['reference_sha256'] == sha(reference),
'Calibration belongs to another stock reference or converter version')
targets = {k:np.asarray(v,dtype=float) for k,v in profile['bones'].items()}
require(REQUIRED <= targets.keys(), 'Incomplete calibration')
# Calibration is world-space in metres; strip source export scale from its bone axes.
for m in targets.values():
require(m.shape == (4,4) and np.isfinite(m).all(), 'Invalid calibration matrix')
m[:3,:3] /= np.linalg.norm(m[:3,:3], axis=0)
target_length = np.linalg.norm(targets['head'][:3,3]-targets['hips'][:3,3])
source_length = np.linalg.norm(binds[names['head']][:3,3]-binds[names['hips']][:3,3])
fitted_scale = target_length/source_length
norm[:3,:] *= fitted_scale/scale
scale = fitted_scale
report['scale'] = float(scale)
report['calibrated'] = True
else:
targets = fallback_targets(ref, names, binds, norm)
report['calibrated'] = False
report['warnings'].append('No paired calibration: generic anatomical fitting needs visual review.')
normalized = {}
for i, m in binds.items():
m = norm@m
lengths = np.linalg.norm(m[:3,:3],axis=0)
require((lengths>1e-10).all(), 'Degenerate bind axes')
m[:3,:3] /= lengths
require(np.allclose(m[:3,:3].T@m[:3,:3],np.eye(3),atol=1e-3) and
np.linalg.det(m[:3,:3])>0, 'Sheared or mirrored bind rig is unsupported')
normalized[i] = m
target_index = {name:i for i,name in enumerate(target_names)}
def mapped(i):
original = i
while key(doc.doc['nodes'][i].get('name','')) not in MAP:
require(i in doc.parents, 'Weighted bone cannot map to stock: '+doc.doc['nodes'][original].get('name',''))
i = doc.parents[i]
k = key(doc.doc['nodes'][i]['name'])
if original != i:
report['collapsed_bones'][doc.doc['nodes'][original]['name']] = MAP[k]
return i, k, target_index[MAP[k]]
writer = Writer()
writer.materials_from(doc)
primitives = []
for ni,node in enumerate(doc.doc['nodes']):
if 'mesh' not in node:
continue
skin = doc.doc['skins'][node['skin']]
matrices, mapped_joints = [], []
for joint in skin['joints']:
ancestor,k,ti = mapped(joint)
# Unavailable destination fingers/endpoints follow the mapped ancestor.
use = joint if key(doc.doc['nodes'][joint].get('name','')) in targets else ancestor
tk = key(doc.doc['nodes'][use].get('name',''))
matrices.append(targets[tk] @ np.linalg.inv(normalized[use]) @ norm @ doc.worlds[ni])
mapped_joints.append(ti)
matrices = np.asarray(matrices)
require(np.isfinite(matrices).all(), 'Invalid fit transforms')
for p in doc.doc['meshes'][node['mesh']]['primitives']:
require(p.get('mode',4) == 4, 'Only triangle meshes are supported')
require(not p.get('targets'), 'Blendshapes are not supported by version 1; export the neutral mesh')
attrs = p['attributes']
require('NORMAL' in attrs, 'Normals are missing')
positions, normals = doc.accessor(attrs['POSITION']), doc.accessor(attrs['NORMAL'])
joints, weights = source_skin(doc,p,skin)
require(len(positions)==len(normals)==len(joints), 'Attribute lengths differ')
require(report['vertices']+len(positions) <= 1_000_000, 'Character exceeds 1 million vertices')
blended = np.einsum('vi,vijk->vjk', weights, matrices[joints])
require((np.linalg.det(blended[:,:3,:3]) > 1e-10).all(),
'Fit folds or collapses the mesh; export a neutral T-pose')
fitted = np.einsum('vij,vj->vi',blended[:,:3,:3],positions)+blended[:,:3,3]
normal_fit = np.einsum('vij,vj->vi',np.linalg.inv(blended[:,:3,:3]).transpose(0,2,1),normals)
normal_fit /= np.maximum(np.linalg.norm(normal_fit,axis=1,keepdims=True),1e-12)
merged = np.zeros((len(positions),len(target_names)))
for column in range(joints.shape[1]):
np.add.at(merged,(np.arange(len(positions)),np.asarray(mapped_joints)[joints[:,column]]),weights[:,column])
output_joints = np.argsort(-merged,axis=1,kind='stable')[:,:4]
output_weights = np.take_along_axis(merged,output_joints,axis=1)
discarded = 1-output_weights.sum(axis=1)
report['max_discarded_weight'] = max(report['max_discarded_weight'],float(discarded.max()))
require(discarded.max() <= 0.1, 'Reducing to four weights loses over 10%; simplify skin weights first')
output_weights /= output_weights.sum(axis=1,keepdims=True)
output_joints[output_weights==0] = 0
out_attrs = {'POSITION':writer.accessor(fitted,'VEC3'), 'NORMAL':writer.accessor(normal_fit,'VEC3'),
'JOINTS_0':writer.accessor(output_joints,'VEC4',5123),
'WEIGHTS_0':writer.accessor(output_weights,'VEC4')}
for name in ('TEXCOORD_0','TEXCOORD_1','COLOR_0'):
if name in attrs:
values = doc.accessor(attrs[name])
require(len(values)==len(positions), 'Attribute length differs: '+name)
out_attrs[name] = writer.accessor(values,'VEC'+str(values.shape[1]))
indices = doc.accessor(p['indices']).ravel() if 'indices' in p else np.arange(len(positions))
require(len(indices)%3==0 and indices.min()>=0 and indices.max()<len(positions),'Invalid triangle indices')
out = {'attributes':out_attrs,'indices':writer.accessor(indices,'SCALAR',5125)}
if 'material' in p:
out['material'] = p['material']
primitives.append(out)
report['vertices'] += len(positions)
report['triangles'] += len(indices)//3
if include_board:
offset = writer.materials_from(ref)
for mesh in ref.doc['meshes']:
for p in mesh['primitives']:
attrs = p['attributes']
js = ref.accessor(attrs['JOINTS_0']).astype(int)
ws = ref.accessor(attrs['WEIGHTS_0'])
used = set(js[ws>1e-6].ravel())
if used and all(target_names[j] in BOARD for j in used):
out_attrs = {}
for name in ('POSITION','NORMAL','TEXCOORD_0','TEXCOORD_1','JOINTS_0','WEIGHTS_0'):
if name in attrs:
a = ref.doc['accessors'][attrs[name]]
values = ref.accessor(attrs[name])
if name=='JOINTS_0':
values[ws==0] = 0
out_attrs[name] = writer.accessor(values,a['type'],5123 if name=='JOINTS_0' else 5126)
primitives.append({'attributes':out_attrs,'indices':writer.accessor(ref.accessor(p['indices']).ravel(),'SCALAR',5125),
'material':p['material']+offset})
# A single shared rig/skin also works with the older binder which binds one skin.
nodes = []
node_map = {}
for i,n in enumerate(ref.doc['nodes']):
if 'mesh' not in n:
node_map[i] = len(nodes)
nodes.append({'name':n.get('name',''),**clean_node_transform(n)})
for i,oi in node_map.items():
children = [node_map[c] for c in ref.doc['nodes'][i].get('children',[]) if c in node_map]
if children:
nodes[oi]['children'] = children
roots = [node_map[i] for i in node_map if i not in ref.parents]
mesh_node = len(nodes)
nodes.append({'name':'Imported_Skater','mesh':0,'skin':0})
roots.append(mesh_node)
writer.doc.update(nodes=nodes,meshes=[{'name':'Converted_Character','primitives':primitives}],
skins=[{'joints':[node_map[j] for j in ref_skin['joints']],
'inverseBindMatrices':writer.accessor(inverse.transpose(0,2,1).reshape(-1,16),'MAT4')}],
scenes=[{'nodes':roots}],scene=0)
report['board_included'] = include_board
report['warnings'].append('Fits stock proportions; fingers collapse to hands. Contact/deformation quality needs in-game review.')
if include_board:
report['warnings'].append('Stock board normal maps use engine-generated tangent space, as in the original asset.')
if doc.doc.get('animations'):
report['warnings'].append('Source animations omitted; the engine supplies stock skating poses.')
writer.save(output)
validate_output(output,reference)
return report
def validate_output(path, reference):
doc, ref = Document(path), Document(reference)
_, names, inverse = reference_rig(doc)
_, expected, ref_inverse = reference_rig(ref)
require(names==expected and np.allclose(inverse,ref_inverse,atol=1e-7), 'Output rig differs from stock')
require('animations' not in doc.doc, 'Unexpected imported animation')
for mesh in doc.doc['meshes']:
for p in mesh['primitives']:
positions = doc.accessor(p['attributes']['POSITION'])
joints, weights = source_skin(doc,p,doc.doc['skins'][0])
require(len(joints)==len(positions) and joints.shape[1]==4,'Invalid output skin')
require(np.allclose(doc.accessor(p['attributes']['WEIGHTS_0']).sum(axis=1),1,atol=1e-5),'Weights not normalized')
indices = doc.accessor(p['indices'])
require(indices.max()<len(positions),'Invalid output index')
for i in range(len(doc.doc.get('images',[]))):
doc.image(i)
def convert_file(source, reference, destination, fbx_tool, profile=None, include_board=True, keep_source=False):
source,reference,destination = Path(source).resolve(),Path(reference).resolve(),Path(destination).resolve()
require(source.suffix.lower() in ('.fbx','.glb'), 'Choose a Mixamo FBX or GLB')
require(source.stat().st_size <= 512*1024**2,'Source exceeds 512 MiB')
require(not destination.exists(), 'Output folder already exists; choose another name')
require(source != reference, 'Source and stock reference must be different files')
destination.parent.mkdir(parents=True,exist_ok=True)
# Publish the complete directory only after every conversion/validation succeeds.
with tempfile.TemporaryDirectory(prefix='.mixamo-',dir=destination.parent) as temporary:
temp = Path(temporary)
normalized = source
log = ''
if source.suffix.lower()=='.fbx':
require(Path(fbx_tool).is_file(),'FBX2glTF.exe is missing; run the converter setup')
normalized = temp/'source.glb'
proc = subprocess.run([str(fbx_tool),'--binary','--input',str(source),'--output',str(temp/'source'),
'--fbx-temp-dir',str(temp),'--compute-normals','missing','--pbr-metallic-roughness'],
capture_output=True,text=True,timeout=180,
creationflags=getattr(subprocess,'CREATE_NO_WINDOW',0))
log = proc.stdout+'\n'+proc.stderr
require(proc.returncode==0 and normalized.is_file(),'FBX conversion failed: '+log[-2000:])
publish = temp/'publish'
publish.mkdir()
report = convert_glb(normalized,reference,publish/'character.glb',profile,include_board)
if keep_source:
import shutil
shutil.copy2(normalized,publish/'source.glb')
report['original_source_sha256'] = sha(source)
report['fbx_conversion_log'] = log
report['output_sha256'] = sha(publish/'character.glb')
(publish/'report.json').write_text(json.dumps(report,indent=2),encoding='utf-8')
(publish/'README.txt').write_text('character.glb is a stock-rig appearance asset.\n'
'Original source and installed assets were not modified.\n'
'This converter does not install the character or add a menu importer.\n'
'Inspect report.json. Gameplay/visual validation is still required.\n',encoding='utf-8')
publish.rename(destination)
return destination/'character.glb'
+215
View File
@@ -0,0 +1,215 @@
"""Small, strict GLB reader/writer for the offline character converter."""
import base64
import copy
import json
import struct
from pathlib import Path
import numpy as np
class ConversionError(ValueError):
pass
def require(condition, message):
if not condition:
raise ConversionError(message)
def transform(node):
if 'matrix' in node:
result = np.asarray(node['matrix'], dtype=float).reshape(4, 4).T
else:
x, y, z, w = node.get('rotation', [0, 0, 0, 1])
q = np.asarray([x, y, z, w], dtype=float)
require(abs(np.linalg.norm(q) - 1) < 1e-3, 'Invalid node quaternion')
result = np.eye(4)
result[:3, :3] = [
[1-2*y*y-2*z*z, 2*x*y-2*z*w, 2*x*z+2*y*w],
[2*x*y+2*z*w, 1-2*x*x-2*z*z, 2*y*z-2*x*w],
[2*x*z-2*y*w, 2*y*z+2*x*w, 1-2*x*x-2*y*y]]
result[:3, :3] *= node.get('scale', [1, 1, 1])
result[:3, 3] = node.get('translation', [0, 0, 0])
require(np.isfinite(result).all() and abs(np.linalg.det(result)) > 1e-12,
'Non-finite or singular node transform')
return result
def clean_node_transform(node):
"""Round tiny authored matrix shear to a valid glTF TRS representation."""
matrix = transform(node)
u, _, vh = np.linalg.svd(matrix[:3,:3])
rotation = u@vh
require(np.linalg.det(rotation)>0, 'Mirrored stock node unsupported')
scale = np.diag(rotation.T@matrix[:3,:3])
require(np.allclose(rotation*scale,matrix[:3,:3],atol=2e-5), 'Stock node has significant shear')
# Stable matrix-to-quaternion, including 180-degree rotations.
trace = np.trace(rotation)
if trace > 0:
s = np.sqrt(trace+1)*2
q = [(rotation[2,1]-rotation[1,2])/s,(rotation[0,2]-rotation[2,0])/s,
(rotation[1,0]-rotation[0,1])/s,s/4]
else:
i = int(np.argmax(np.diag(rotation)));j=(i+1)%3;k=(i+2)%3
s = np.sqrt(1+rotation[i,i]-rotation[j,j]-rotation[k,k])*2
q = [0.,0.,0.,0.]
q[i]=s/4;q[j]=(rotation[j,i]+rotation[i,j])/s;q[k]=(rotation[k,i]+rotation[i,k])/s
q[3]=(rotation[k,j]-rotation[j,k])/s
q = np.asarray(q)/np.linalg.norm(q)
return {'translation':matrix[:3,3].tolist(),'rotation':q.tolist(),'scale':scale.tolist()}
class Document:
def __init__(self, path):
self.path = Path(path)
require(self.path.stat().st_size <= 512 * 1024**2, 'GLB exceeds 512 MiB limit')
self.raw = self.path.read_bytes()
require(len(self.raw) >= 28, 'Truncated GLB')
magic, version, total = struct.unpack_from('<III', self.raw)
require(magic == 0x46546c67 and version == 2 and total == len(self.raw),
'Invalid GLB header')
chunks = {}
offset = 12
while offset < total:
require(offset + 8 <= total, 'Truncated chunk header')
length, kind = struct.unpack_from('<I4s', self.raw, offset)
require(offset + 8 + length <= total and kind not in chunks, 'Invalid GLB chunk')
chunks[kind] = self.raw[offset+8:offset+8+length]
offset += 8 + length
self.doc = json.loads(chunks[b'JSON'])
self.data = chunks.get(b'BIN\0', b'')
require(len(self.doc.get('buffers', [])) == 1 and
'uri' not in self.doc['buffers'][0], 'Only embedded GLB buffers are supported')
require(not self.doc.get('extensionsRequired'), 'Required glTF extensions are unsupported')
self.parents = {}
nodes = self.doc['nodes']
require(len(nodes) <= 4096, 'Too many scene nodes')
for i, node in enumerate(nodes):
for child in node.get('children', []):
require(0 <= child < len(nodes) and child not in self.parents,
'Invalid or multiply parented node')
self.parents[child] = i
self.worlds = {}
def visit(i, active):
require(i not in active, 'Skeleton/scene contains a cycle')
if i not in self.worlds:
parent = self.parents.get(i)
self.worlds[i] = ((visit(parent, active | {i}) if parent is not None else np.eye(4))
@ transform(nodes[i]))
return self.worlds[i]
for i in range(len(nodes)):
visit(i, set())
def view(self, i):
v = self.doc['bufferViews'][i]
require(v.get('buffer', 0) == 0, 'External buffer unsupported')
start, length = v.get('byteOffset', 0), v['byteLength']
require(start >= 0 and length >= 0 and start+length <= len(self.data), 'Buffer view out of bounds')
return self.data[start:start+length]
def accessor(self, i):
a = self.doc['accessors'][i]
require('sparse' not in a, 'Sparse accessors need an explicit conversion first')
types = {5120:'i1', 5121:'u1', 5122:'<i2', 5123:'<u2', 5125:'<u4', 5126:'<f4'}
sizes = {'SCALAR':1, 'VEC2':2, 'VEC3':3, 'VEC4':4, 'MAT4':16}
dtype, width = np.dtype(types[a['componentType']]), sizes[a['type']]
require(0 < a['count'] <= 6_000_000, 'Accessor count out of supported range')
v = self.doc['bufferViews'][a['bufferView']]
data = self.view(a['bufferView'])
start, stride = a.get('byteOffset', 0), v.get('byteStride', dtype.itemsize*width)
require(start >= 0 and stride >= dtype.itemsize*width and
start+(a['count']-1)*stride+dtype.itemsize*width <= len(data), 'Accessor out of bounds')
values = np.ndarray((a['count'], width), dtype=dtype, buffer=data,
offset=start, strides=(stride, dtype.itemsize)).copy()
if a.get('normalized'):
require(dtype.kind in 'iu', 'Invalid normalized accessor')
values = np.maximum(values.astype(float)/np.iinfo(dtype).max, -1)
require(np.isfinite(values).all(), 'Non-finite accessor')
return values
def image(self, i):
image = self.doc['images'][i]
if 'bufferView' in image:
return self.view(image['bufferView']), image['mimeType']
uri = image.get('uri', '')
require(uri.startswith('data:') and ';base64,' in uri, 'Missing embedded texture; export FBX with media')
header, payload = uri.split(',', 1)
return base64.b64decode(payload, validate=True), header[5:].split(';')[0]
class Writer:
def __init__(self):
self.data = bytearray()
self.doc = {'asset': {'version':'2.0', 'generator':'Mixamo to Skate 1'},
'bufferViews':[], 'accessors':[], 'materials':[], 'textures':[], 'images':[]}
def view(self, data):
self.data.extend(b'\0' * (-len(self.data) % 4))
offset = len(self.data)
self.data.extend(data)
self.doc['bufferViews'].append({'buffer':0, 'byteOffset':offset, 'byteLength':len(data)})
return len(self.doc['bufferViews'])-1
def accessor(self, values, kind, component=5126):
values = np.asarray(values, dtype={5126:'<f4', 5123:'<u2', 5125:'<u4'}[component])
require(np.isfinite(values).all(), 'Non-finite output')
a = {'bufferView':self.view(values.tobytes()), 'componentType':component,
'count':len(values), 'type':kind}
if kind == 'VEC3':
a.update(min=values.min(axis=0).tolist(), max=values.max(axis=0).tolist())
self.doc['accessors'].append(a)
return len(self.doc['accessors'])-1
def materials_from(self, source):
offset = len(self.doc['materials'])
texture_offset = len(self.doc['textures'])
for image_index in range(len(source.doc.get('images', []))):
data, mime = source.image(image_index)
require(mime in ('image/png', 'image/jpeg'), 'Only PNG/JPEG textures supported')
self.doc['images'].append({'bufferView':self.view(data), 'mimeType':mime})
image_offset = len(self.doc['images']) - len(source.doc.get('images', []))
sampler_offset = len(self.doc.get('samplers', []))
self.doc.setdefault('samplers', []).extend(copy.deepcopy(source.doc.get('samplers', [])))
for t in source.doc.get('textures', []):
require('source' in t and not t.get('extensions'), 'Unsupported texture extension')
t = copy.deepcopy(t)
t['source'] += image_offset
if 'sampler' in t:
t['sampler'] += sampler_offset
self.doc['textures'].append(t)
def fix(obj):
if isinstance(obj, dict):
for key, value in obj.items():
if key.endswith('Texture') and isinstance(value, dict):
value['index'] += texture_offset
else:
fix(value)
elif isinstance(obj, list):
for value in obj:
fix(value)
for m in source.doc.get('materials', []):
m = copy.deepcopy(m)
require(not m.get('extensions'), 'Material extensions require explicit conversion')
m.pop('extras', None)
fix(m)
self.doc['materials'].append(m)
return offset
def save(self, path):
for mesh in self.doc.get('meshes',[]):
for p in mesh['primitives']:
for index in p['attributes'].values():
self.doc['bufferViews'][self.doc['accessors'][index]['bufferView']]['target']=34962
if 'indices' in p:
self.doc['bufferViews'][self.doc['accessors'][p['indices']]['bufferView']]['target']=34963
for key in ('materials', 'textures', 'images', 'samplers'):
if not self.doc.get(key):
self.doc.pop(key, None)
self.doc['buffers'] = [{'byteLength':len(self.data)}]
data = bytes(self.data) + b'\0'*(-len(self.data)%4)
encoded = json.dumps(self.doc, separators=(',', ':'), allow_nan=False).encode()
encoded += b' '*(-len(encoded)%4)
Path(path).write_bytes(struct.pack('<III', 0x46546c67, 2, 28+len(encoded)+len(data)) +
struct.pack('<I4s', len(encoded), b'JSON') + encoded +
struct.pack('<I4s', len(data), b'BIN\0') + data)
+137
View File
@@ -0,0 +1,137 @@
"""Game-facing import job. File chooser and CPU thumbnail run outside the game."""
import hashlib
import json
import os
import shutil
import tempfile
from datetime import datetime, timezone
from pathlib import Path
import numpy as np
from PIL import Image
from converter import convert_file, sha, validate_output
from glb import Document, require
def thumbnail(path, output, width=256, height=320):
doc=Document(path)
items=[]
for node in doc.doc['nodes']:
if 'mesh' not in node:
continue
for p in doc.doc['meshes'][node['mesh']]['primitives']:
pos=doc.accessor(p['attributes']['POSITION'])
idx=doc.accessor(p['indices']).ravel().reshape(-1,3).astype(int)
material=doc.doc.get('materials',[])[p['material']] if 'material' in p else {}
pbr=material.get('pbrMetallicRoughness',{})
color=np.asarray(pbr.get('baseColorFactor',[.65,.72,.8,1]))
texture=None
if 'baseColorTexture' in pbr:
import io
data,_=doc.image(doc.doc['textures'][pbr['baseColorTexture']['index']]['source'])
with Image.open(io.BytesIO(data)) as image:
image.thumbnail((1024,1024))
texture=np.asarray(image.convert('RGBA'))/255.
uv=doc.accessor(p['attributes']['TEXCOORD_0']) if 'TEXCOORD_0' in p['attributes'] else None
items.append((pos,idx,color,texture,uv,material))
require(items,'Cannot preview an empty model')
angle=.22
basis=np.array([[np.cos(angle),0,-np.sin(angle)], [0,1,0], [np.sin(angle),0,np.cos(angle)]])
all_points=np.concatenate([p for p,*_ in items])@basis.T
low,high=all_points.min(0),all_points.max(0)
scale=min((width-24)/max(high[0]-low[0],.01),(height-24)/max(high[1]-low[1],.01))
centre=(high+low)/2
rgb=np.zeros((height,width,3),dtype=float)+np.array([.055,.075,.11])
depth=np.full((height,width),-np.inf)
light=np.array([.3,.6,1]);light/=np.linalg.norm(light)
for pos,idx,color,texture,uv,material in items:
world=pos@basis.T
points=(world-centre)*scale
points[:,0]+=width/2;points[:,1]=height/2-points[:,1]
for face in idx:
tri=points[face];a,b,c=tri[:,:2]
xmin=max(0,int(np.floor(tri[:,0].min())));xmax=min(width-1,int(np.ceil(tri[:,0].max())))
ymin=max(0,int(np.floor(tri[:,1].min())));ymax=min(height-1,int(np.ceil(tri[:,1].max())))
if xmin>xmax or ymin>ymax:continue
denom=(b[1]-c[1])*(a[0]-c[0])+(c[0]-b[0])*(a[1]-c[1])
if abs(denom)<1e-8:continue
yy,xx=np.mgrid[ymin:ymax+1,xmin:xmax+1];xx=xx+.5;yy=yy+.5
w0=((b[1]-c[1])*(xx-c[0])+(c[0]-b[0])*(yy-c[1]))/denom
w1=((c[1]-a[1])*(xx-c[0])+(a[0]-c[0])*(yy-c[1]))/denom
w2=1-w0-w1
z=w0*tri[0,2]+w1*tri[1,2]+w2*tri[2,2]
visible=(w0>=0)&(w1>=0)&(w2>=0)&(z>depth[ymin:ymax+1,xmin:xmax+1])
if not visible.any():continue
pixels=np.broadcast_to(color,(*xx.shape,4)).copy()
if texture is not None and uv is not None:
tex=w0[...,None]*uv[face[0]]+w1[...,None]*uv[face[1]]+w2[...,None]*uv[face[2]]
tx=np.clip((tex[...,0]%1*texture.shape[1]).astype(int),0,texture.shape[1]-1)
ty=np.clip((tex[...,1]%1*texture.shape[0]).astype(int),0,texture.shape[0]-1)
pixels*=texture[ty,tx]
if material.get('alphaMode')=='MASK':visible&=pixels[...,3]>=material.get('alphaCutoff',.5)
normal=np.cross(world[face[1]]-world[face[0]],world[face[2]]-world[face[0]])
normal/=max(np.linalg.norm(normal),1e-10)
shade=.45+.55*abs(np.dot(normal,light))
rgb[ymin:ymax+1,xmin:xmax+1][visible]=(pixels[...,:3]*shade)[visible]
depth[ymin:ymax+1,xmin:xmax+1][visible]=z[visible]
Image.fromarray(np.uint8(np.clip(rgb,0,1)*255)).save(output)
def import_model(source,reference,library,tool,profile=None):
source,reference,library=Path(source),Path(reference),Path(library)
digest=hashlib.sha256((sha(source)+sha(reference)+json.dumps(profile,sort_keys=True)+'library-v1').encode()).hexdigest()
entries=library/'entries';entries.mkdir(parents=True,exist_ok=True)
target=entries/digest
if target.exists():
require((target/'manifest.json').is_file() and (target/'preview.png').is_file(), 'Existing library entry is incomplete')
validate_output(target/'character.glb',reference)
return digest
with tempfile.TemporaryDirectory(prefix='.import-',dir=library) as temp:
staging=Path(temp)/'entry'
# Already converted stock-rig GLBs are accepted as well as Mixamo exports.
ready=False
if source.suffix.lower()=='.glb':
try:validate_output(source,reference);ready=True
except (ValueError,KeyError):pass
if ready:
staging.mkdir();shutil.copy2(source,staging/'character.glb');shutil.copy2(source,staging/'source.glb')
else:
convert_file(source,reference,staging,tool,profile,keep_source=True)
thumbnail(staging/'character.glb',staging/'preview.png')
metadata={'version':1,'id':digest,'name':source.stem[:64],
'source_sha256':sha(source),'reference_sha256':sha(reference),
'created_utc':datetime.now(timezone.utc).isoformat()}
(staging/'manifest.json').write_text(json.dumps(metadata,indent=2),encoding='utf-8')
staging.rename(target)
return digest
def run(args,app):
result={'status':'cancelled'}
try:
require(args.result is not None and args.reference is not None,'Import needs result and reference paths')
source=args.files[0] if args.files else None
if source is None:
import tkinter as tk
from tkinter import filedialog
root=tk.Tk();root.withdraw();root.attributes('-topmost',True)
try:
name=filedialog.askopenfilename(title='Import a Mixamo character',
filetypes=[('Character models','*.fbx *.glb')],parent=root)
source=Path(name) if name else None
finally:root.destroy()
if source:
profile_path=args.profile or app/'calibration.json'
profile=json.loads(profile_path.read_text(encoding='utf-8')) if profile_path.is_file() else None
if profile and profile['reference_sha256']!=sha(args.reference):profile=None
identity=import_model(source,args.reference,args.library_import,
args.fbx_tool or app/'tools/FBX2glTF.exe',profile)
result={'status':'ready','id':identity}
except Exception as error:
result={'status':'error','message':str(error)}
if args.result:
args.result.parent.mkdir(parents=True,exist_ok=True)
temporary=args.result.with_suffix('.tmp')
temporary.write_text(json.dumps(result),encoding='utf-8');os.replace(temporary,args.result)
return 1 if result['status']=='error' else 0
+104
View File
@@ -0,0 +1,104 @@
"""Windows drag-and-drop / file-dialog entry point, also usable from the CLI."""
import argparse
import json
import os
import sys
from pathlib import Path
from converter import convert_file, create_profile, sha
from glb import ConversionError, require
def app_root():
return Path(sys.executable).parent if getattr(sys,'frozen',False) else Path(__file__).resolve().parent
def find_reference(expected_hash=None):
base = Path(os.environ.get('LOCALAPPDATA',Path.home()/'AppData/Local'))/'Skate3RustEngine/installations'
candidates = sorted(base.glob('*/assets/private/skater.glb'))
if expected_hash:
candidates = [p for p in candidates if sha(p)==expected_hash]
if candidates:
return candidates[0]
if len(candidates)==1:
return candidates[0]
if candidates and len({sha(p) for p in candidates})==1:
return candidates[0]
return None
def main(argv=None):
parser = argparse.ArgumentParser(description='Convert a Mixamo humanoid to the installed Skate render rig.')
parser.add_argument('files',nargs='*',type=Path)
parser.add_argument('--reference',type=Path,help='Owned stock assets/private/skater.glb')
parser.add_argument('--profile',type=Path,help='Optional local paired-stock calibration JSON')
parser.add_argument('--output',type=Path,help='New output folder (single input only)')
parser.add_argument('--fbx-tool',type=Path)
parser.add_argument('--no-board',action='store_true',help='Output character only, for future modular import')
parser.add_argument('--calibrate',action='store_true',help='Create profile from a paired stock Mixamo GLB')
parser.add_argument('--noninteractive',action='store_true')
parser.add_argument('--library-import',type=Path,help='Import into the game character library')
parser.add_argument('--result',type=Path,help='Game import result file')
args = parser.parse_args(argv)
if args.library_import:
from library_import import run
return run(args,app_root())
gui = not args.noninteractive and (not args.files or getattr(sys,'frozen',False))
root = None
try:
if gui:
import tkinter as tk
from tkinter import filedialog, messagebox
root = tk.Tk()
root.withdraw()
if not args.files:
args.files = [Path(f) for f in filedialog.askopenfilenames(title='Choose Mixamo FBX characters',
filetypes=[('Mixamo characters','*.fbx *.glb')])]
if not args.files:
return 0
profile_path = args.profile or app_root()/'calibration.json'
profile = json.loads(profile_path.read_text(encoding='utf-8')) if profile_path.is_file() else None
reference = args.reference or find_reference(profile.get('reference_sha256') if profile else None)
if reference is None and gui:
selected = filedialog.askopenfilename(title='Choose your installed stock skater.glb',
filetypes=[('Stock GLB','*.glb')])
reference = Path(selected) if selected else None
require(reference is not None,'Stock reference not found. Use --reference path/to/skater.glb')
require(args.files,'No input selected')
require(not args.output or len(args.files)==1,'--output supports one input only')
if args.calibrate:
require(len(args.files)==1 and args.files[0].suffix.lower()=='.glb','Calibration requires one normalized Mixamo GLB')
require(not profile_path.exists(),'Calibration exists; choose another --profile path')
profile_path.write_text(json.dumps(create_profile(args.files[0],reference),indent=2),encoding='utf-8')
print('Calibration saved:',profile_path)
return 0
tool = args.fbx_tool or app_root()/'tools/FBX2glTF.exe'
outputs = []
for source in args.files:
destination = args.output
if destination is None:
destination = source.with_name(source.stem+'-skate')
counter = 2
while destination.exists():
destination = source.with_name(source.stem+f'-skate-{counter}')
counter += 1
print('Converting:',source,flush=True)
result = convert_file(source,reference,destination,tool,profile,not args.no_board)
outputs.append(str(result))
print('Converted and validated:',result,flush=True)
if gui:
messagebox.showinfo('Conversion complete','Saved:\n\n'+'\n'.join(outputs)+
'\n\nThe game was not modified. These files still need visual/gameplay review.')
return 0
except Exception as error:
print('Conversion failed:',str(error),file=sys.stderr)
if gui and root is not None:
messagebox.showerror('Conversion failed',str(error)+'\n\nExisting outputs and game files were not replaced.')
return 1
finally:
if root is not None:
root.destroy()
if __name__=='__main__':
raise SystemExit(main())
+2
View File
@@ -0,0 +1,2 @@
numpy>=1.26,<3
Pillow>=10,<13
+167
View File
@@ -0,0 +1,167 @@
"""Procedural fixtures only: no stock or other copyrighted meshes."""
import json
import tempfile
import unittest
from pathlib import Path
import numpy as np
from converter import MAP, convert_file, convert_glb, create_profile, reference_rig
from glb import ConversionError, Document, Writer
def fixture(path, mixamo, scale=1, bad_weights=False, morph=False):
positions = {'hips':(0,1,0),'spine':(0,1.15,0),'spine1':(0,1.3,0),
'spine2':(0,1.45,0),'neck':(0,1.6,0),'head':(0,1.8,0)}
for s,sign in [('left',1),('right',-1)]:
for part,p in {'shoulder':(.1,1.5,0),'arm':(.2,1.5,0),'forearm':(.45,1.5,0),
'hand':(.7,1.5,0),'upleg':(.1,.95,0),'leg':(.1,.5,0),
'foot':(.1,.08,0),'toebase':(.1,.03,.15)}.items():
positions[s+part]=(p[0]*sign,p[1],p[2])
keys = list(MAP)
w=Writer()
nodes=[]
inverses=[]
verts=[]
joint_weights=[]
joint_indices=[]
for i,k in enumerate(keys):
p=np.asarray(positions[k])*scale
nodes.append({'name':('mixamorig:'+k if mixamo else MAP[k]),'translation':p.tolist()})
if i:
nodes[-1]['translation']=(p-np.asarray(positions['hips'])*scale).tolist()
m=np.eye(4);m[:3,3]=-p
inverses.append(m.T.ravel())
for v in [(0,0,0),(.015,0,0),(0,.015,0)]:
verts.append(p+np.asarray(v)*scale)
joint_indices.append([i,0,0,0])
joint_weights.append([1,0,0,0])
nodes[0]['children']=list(range(1,len(nodes)))
if mixamo:
# Extra weighted finger is not present in the destination rig.
hand=keys.index('lefthand');finger=len(nodes)
nodes.append({'name':'mixamorig:LeftHandIndex1','translation':[.02*scale,0,0]})
nodes[hand]['children']=[finger]
m=np.eye(4);m[:3,3]=-(np.asarray(positions['lefthand'])+np.array([.02,0,0]))*scale
inverses.append(m.T.ravel())
for i in range(hand*3,hand*3+3):
joint_indices[i]=[finger,hand,0,0];joint_weights[i]=[.7,.3,0,0]
if bad_weights:
joint_indices[0][0]=999
attrs={'POSITION':w.accessor(verts,'VEC3'),
'NORMAL':w.accessor([[0,0,1]]*len(verts),'VEC3'),
'JOINTS_0':w.accessor(joint_indices,'VEC4',5123),
'WEIGHTS_0':w.accessor(joint_weights,'VEC4')}
primitive={'attributes':attrs,'indices':w.accessor(np.arange(len(verts)),'SCALAR',5125)}
if morph:
primitive['targets']=[{'POSITION':attrs['POSITION']}]
skin={'joints':list(range(len(nodes))),'inverseBindMatrices':w.accessor(inverses,'MAT4')}
nodes.append({'name':'body','mesh':0,'skin':0})
w.doc.update(nodes=nodes,skins=[skin],meshes=[{'primitives':[primitive]}],
scenes=[{'nodes':[0,len(nodes)-1]}],scene=0)
w.save(path)
class ConversionTests(unittest.TestCase):
def setUp(self):
self.tmp=tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.root=Path(self.tmp.name)
self.ref=self.root/'stock.glb';self.src=self.root/'mixamo.glb';self.out=self.root/'out.glb'
fixture(self.ref,False)
fixture(self.src,True,.01)
def test_calibrated_roundtrip_scale_weights_and_runtime_pose(self):
profile=create_profile(self.src,self.ref)
report=convert_glb(self.src,self.ref,self.out,profile,False)
src,ref=Document(self.out),Document(self.ref)
p=src.doc['meshes'][0]['primitives'][0]
r=ref.doc['meshes'][0]['primitives'][0]
got=src.accessor(p['attributes']['POSITION'])
expected=ref.accessor(r['attributes']['POSITION'])
np.testing.assert_allclose(got,expected,atol=2e-6)
_,names,inverse=reference_rig(src)
hand=names.index('LEFTHAND')
js=src.accessor(p['attributes']['JOINTS_0']).astype(int)
ws=src.accessor(p['attributes']['WEIGHTS_0'])
np.testing.assert_allclose(ws.sum(1),1,atol=1e-6)
for i in range(hand*3,hand*3+3):
self.assertEqual(js[i,0],hand)
self.assertAlmostEqual(ws[i,0],1)
# Simulate a translated stock hand: its vertices move exactly 20 cm,
# while an unrelated head vertex stays put. No engine/game is run.
globals=np.linalg.inv(inverse)
globals[hand,0,3]+=.2
skin_mats=globals@inverse
hv=np.column_stack([got,np.ones(len(got))])
deformed=np.einsum('vi,vijk,vk->vj',ws,skin_mats[js],hv)[:,:3]
expected_pose=got.copy();expected_pose[hand*3:hand*3+3,0]+=.2
np.testing.assert_allclose(deformed,expected_pose,atol=2e-6)
self.assertIn('mixamorig:LeftHandIndex1',report['collapsed_bones'])
def test_generic_fit_and_no_imported_bones(self):
report=convert_glb(self.src,self.ref,self.out,None,False)
self.assertFalse(report['calibrated'])
names=[n.get('name','') for n in Document(self.out).doc['nodes']]
self.assertFalse(any('mixamorig' in n for n in names))
got,ref=Document(self.out),Document(self.ref)
p=got.doc['meshes'][0]['primitives'][0]['attributes']['POSITION']
r=ref.doc['meshes'][0]['primitives'][0]['attributes']['POSITION']
np.testing.assert_allclose(got.accessor(p),ref.accessor(r),atol=2e-6)
def test_rotated_translated_rescaled_scene_uses_same_calibration(self):
profile=create_profile(self.src,self.ref)
d=Document(self.src);w=Writer();w.doc=d.doc;w.data=bytearray(d.data)
root=len(w.doc['nodes'])
w.doc['nodes'].append({'name':'export_axes','children':[0,root-1],
'rotation':[0,2**-.5,0,2**-.5],
'scale':[3,3,3],'translation':[8,2,-3]})
w.doc['scenes'][0]['nodes']=[root];w.save(self.src)
convert_glb(self.src,self.ref,self.out,profile,False)
got,ref=Document(self.out),Document(self.ref)
p=got.doc['meshes'][0]['primitives'][0]['attributes']['POSITION']
r=ref.doc['meshes'][0]['primitives'][0]['attributes']['POSITION']
np.testing.assert_allclose(got.accessor(p),ref.accessor(r),atol=2e-6)
def test_bad_joint_fails_transaction_without_mutating_source(self):
fixture(self.src,True,.01,bad_weights=True)
before=self.src.read_bytes();ref_before=self.ref.read_bytes()
with self.assertRaisesRegex(ConversionError,'joint index'):
convert_file(self.src,self.ref,self.root/'result','unused')
self.assertFalse((self.root/'result').exists())
self.assertEqual(self.src.read_bytes(),before)
self.assertEqual(self.ref.read_bytes(),ref_before)
self.assertFalse(list(self.root.glob('.mixamo-*')))
def test_existing_output_never_overwritten(self):
destination=self.root/'result';destination.mkdir()
(destination/'keep').write_text('keep')
with self.assertRaisesRegex(ConversionError,'already exists'):
convert_file(self.src,self.ref,destination,'unused')
self.assertEqual((destination/'keep').read_text(),'keep')
def test_profile_identity_mismatch(self):
profile=create_profile(self.src,self.ref)
profile['reference_sha256']='wrong'
with self.assertRaisesRegex(ConversionError,'another stock reference'):
convert_glb(self.src,self.ref,self.out,profile,False)
def test_morph_rejected_explicitly(self):
fixture(self.src,True,.01,morph=True)
with self.assertRaisesRegex(ConversionError,'Blendshapes'):
convert_glb(self.src,self.ref,self.out,None,False)
def test_truncated_glb_rejected(self):
self.src.write_bytes(self.src.read_bytes()[:-20])
with self.assertRaisesRegex(ConversionError,'header'):
Document(self.src)
def test_missing_bone_rejected(self):
d=Document(self.src);w=Writer();w.doc=d.doc;w.data=bytearray(d.data)
w.doc['nodes'][1]['name']='wrong';w.save(self.src)
with self.assertRaisesRegex(ConversionError,'missing'):
convert_glb(self.src,self.ref,self.out,None,False)
if __name__=='__main__':
unittest.main()
+31
View File
@@ -0,0 +1,31 @@
import json
import tempfile
import unittest
from pathlib import Path
import numpy as np
from PIL import Image
from test_converter import fixture
from converter import create_profile
from library_import import import_model
class LibraryTests(unittest.TestCase):
def test_publish_preview_deduplicate_and_failed_import(self):
with tempfile.TemporaryDirectory() as temp:
root=Path(temp); ref=root/'ref.glb'; src=root/'model.glb'; library=root/'library'
fixture(ref,False); fixture(src,True,.01)
profile=create_profile(src,ref)
identity=import_model(src,ref,library,None,profile)
entry=library/'entries'/identity
self.assertTrue((entry/'source.glb').is_file())
self.assertEqual(json.loads((entry/'manifest.json').read_text())['name'],'model')
with Image.open(entry/'preview.png') as preview:
self.assertEqual(preview.size,(256,320))
self.assertGreater(len(np.unique(np.asarray(preview).reshape(-1,3),axis=0)),1)
self.assertEqual(import_model(src,ref,library,None,profile),identity)
bad=root/'bad.glb';bad.write_bytes(b'broken')
with self.assertRaises(Exception):import_model(bad,ref,library,None,profile)
self.assertEqual(len(list((library/'entries').iterdir())),1)
converted=import_model(entry/'character.glb',ref,library,None,profile)
self.assertTrue((library/'entries'/converted/'preview.png').is_file())
if __name__=='__main__':unittest.main()