mirror of
https://github.com/SK8-ENGINE/skate-3-rust-engine.git
synced 2026-10-26 19:47:44 +00:00
Add scrolling custom character library and native skater roster
This commit is contained in:
1 parent
657eec2d93
commit
ab538ea732
26 files changed
+2420
-18
No files matched your search
@@ -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)
|
||||
@@ -0,0 +1,2 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
@@ -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'
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -0,0 +1,2 @@
|
||||
numpy>=1.26,<3
|
||||
Pillow>=10,<13
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user