Files
Wackelpeter/j3o_parser.py
T

823 lines
29 KiB
Python

#!/usr/bin/env python3
"""Parse JMonkeyEngine .j3o binary files and extract mesh/skeleton/animation data.
Outputs glTF 2.0 .glb for meshes with skeletons, or OBJ-like JSON for static meshes.
"""
import struct
import math
import os
import json
from pathlib import Path
# ── Constants ───────────────────────────────────────────────────────────────
NULL_OBJECT = -1
DEFAULT_OBJECT = -2
SIGNATURE = 0x4A4D4533
FIELD_TYPE = {
0: 'BYTE', 1: 'BYTE_1D', 2: 'BYTE_2D',
10: 'INT', 11: 'INT_1D', 12: 'INT_2D',
20: 'FLOAT', 21: 'FLOAT_1D', 22: 'FLOAT_2D',
30: 'DOUBLE', 31: 'DOUBLE_1D', 32: 'DOUBLE_2D',
40: 'LONG', 41: 'LONG_1D', 42: 'LONG_2D',
50: 'SHORT', 51: 'SHORT_1D', 52: 'SHORT_2D',
60: 'BOOLEAN', 61: 'BOOLEAN_1D', 62: 'BOOLEAN_2D',
70: 'STRING', 71: 'STRING_1D', 72: 'STRING_2D',
80: 'BITSET',
90: 'SAVABLE', 91: 'SAVABLE_1D', 92: 'SAVABLE_2D',
100: 'SAVABLE_ARRAYLIST', 101: 'SAVABLE_ARRAYLIST_1D', 102: 'SAVABLE_ARRAYLIST_2D',
105: 'SAVABLE_MAP', 106: 'STRING_SAVABLE_MAP', 107: 'INT_SAVABLE_MAP',
110: 'FLOATBUFFER_ARRAYLIST', 111: 'BYTEBUFFER_ARRAYLIST',
120: 'FLOATBUFFER', 121: 'INTBUFFER', 122: 'BYTEBUFFER', 123: 'SHORTBUFFER',
}
# VertexBuffer usage types (key for IntMap in Mesh.buffers)
VB_USAGE = {
'Position': 0, 'Normal': 1, 'TexCoord': 2, 'Color': 3,
'Tangent': 4, 'Binormal': 5,
'Size': 6, 'InterleavedData': 7, 'Misc': 8,
'BoneIndex': 9, 'BoneWeight': 10, 'HWBoneIndex': 11, 'HWBoneWeight': 12,
'BindPosePosition': 13, 'BindPoseNormal': 14, 'BindPoseTangent': 15,
}
VB_FORMAT = {'Float': 0, 'Short': 2, 'UnsignedShort': 4, 'UnsignedByte': 5, 'Byte': 6, 'Half': 7, 'Int': 8, 'UnsignedInt': 9}
# ── Binary reader ───────────────────────────────────────────────────────────
class J3OReader:
def __init__(self, data):
self.data = data
self.offset = 0
def tell(self):
return self.offset
def read_bytes(self, n):
r = self.data[self.offset:self.offset + n]
self.offset += n
return r
def read_be_int32(self):
return struct.unpack('>i', self.read_bytes(4))[0]
def read_be_uint32(self):
return struct.unpack('>I', self.read_bytes(4))[0]
def read_be_int16(self):
return struct.unpack('>h', self.read_bytes(2))[0]
def read_byte(self):
b = self.data[self.offset]
self.offset += 1
return b
def read_float(self):
i = struct.unpack('>i', self.read_bytes(4))[0]
return struct.unpack('>f', struct.pack('>i', i))[0]
def read_double(self):
i = struct.unpack('>q', self.read_bytes(8))[0]
return struct.unpack('>d', struct.pack('>q', i))[0]
def read_short(self):
return struct.unpack('>h', self.read_bytes(2))[0]
def read_bool(self):
return self.read_byte() != 0
def read_compressed_int(self):
first = self.read_byte()
if first == 0xFF:
return NULL_OBJECT
if first == 0xFE:
return DEFAULT_OBJECT
if first == 0x00:
return 0
length = first & 0xFF
raw = self.read_bytes(length)
# Right-align to 4 bytes
if length <= 4:
pad_len = 4 - length
value_bytes = b'\x00' * pad_len + raw
else:
# If longer than 4 bytes, take only the last 4
value_bytes = raw[length-4:]
try:
val = struct.unpack('>i', value_bytes)[0]
except struct.error:
return NULL_OBJECT
if val in (NULL_OBJECT, DEFAULT_OBJECT):
if length == 4:
self.offset -= 4
return val
def read_compressed_long(self):
first = self.read_byte()
if first == 0xFF:
return NULL_OBJECT
if first == 0xFE:
return DEFAULT_OBJECT
if first == 0x00:
return 0
length = first & 0xFF
raw = self.read_bytes(length)
pad = b'\x00' * (8 - length)
return struct.unpack('>q', pad + raw)[0]
def read_string(self):
length = self.read_compressed_int()
if length == NULL_OBJECT:
return None
return self.read_bytes(length).decode('utf-8', errors='replace')
# ── Field reading ───────────────────────────────────────────────────────────
class CapsuleReader:
"""Reads field data for a single capsule."""
def __init__(self, rdr: J3OReader, length):
self.rdr = rdr
self.end_offset = rdr.tell() + length
self.class_fields = None # set by BinaryClassObject
def read_field_value(self, field_type, expected_alias=None):
"""Read a value of the given type. Returns (value, field_alias)."""
if expected_alias is not None:
alias = self.rdr.read_byte()
else:
alias = 0
val = None
if field_type in (0,): # BYTE
val = self.rdr.read_byte()
elif field_type in (1,): # BYTE_1D
ln = self.rdr.read_compressed_int()
val = list(self.rdr.read_bytes(ln)) if ln > 0 else []
elif field_type in (2,): # BYTE_2D
outer = self.rdr.read_compressed_int()
val = []
for _ in range(outer):
ln = self.rdr.read_compressed_int()
val.append(list(self.rdr.read_bytes(ln)) if ln > 0 else [])
elif field_type in (10,): # INT
val = self.rdr.read_compressed_int()
elif field_type in (11,): # INT_1D
ln = self.rdr.read_compressed_int()
val = [self.rdr.read_compressed_int() for _ in range(ln)]
elif field_type in (12,): # INT_2D
outer = self.rdr.read_compressed_int()
val = [[self.rdr.read_compressed_int() for _ in range(self.rdr.read_compressed_int())] for _ in range(outer)]
elif field_type in (20,): # FLOAT
val = self.rdr.read_float()
elif field_type in (21,): # FLOAT_1D
ln = self.rdr.read_compressed_int()
val = [self.rdr.read_float() for _ in range(ln)]
elif field_type in (22,): # FLOAT_2D
outer = self.rdr.read_compressed_int()
val = [[self.rdr.read_float() for _ in range(self.rdr.read_compressed_int())] for _ in range(outer)]
elif field_type in (30,): # DOUBLE
val = self.rdr.read_double()
elif field_type in (40,): # LONG
val = self.rdr.read_compressed_long()
elif field_type in (50,): # SHORT
val = self.rdr.read_short()
elif field_type in (51,): # SHORT_1D
ln = self.rdr.read_compressed_int()
val = [self.rdr.read_short() for _ in range(ln)]
elif field_type in (60,): # BOOLEAN
val = self.rdr.read_bool()
elif field_type in (61,): # BOOLEAN_1D
ln = self.rdr.read_compressed_int()
val = [self.rdr.read_bool() for _ in range(ln)]
elif field_type in (70,): # STRING
val = self.rdr.read_string()
elif field_type in (71,): # STRING_1D
ln = self.rdr.read_compressed_int()
val = [self.rdr.read_string() for _ in range(ln)]
elif field_type in (80,): # BITSET
ln = self.rdr.read_compressed_int()
val = [self.rdr.read_bool() for _ in range(ln)]
elif field_type in (90,): # SAVABLE
val = self.rdr.read_compressed_int()
elif field_type in (91, 100): # SAVABLE_1D / SAVABLE_ARRAYLIST
ln = self.rdr.read_compressed_int()
val = [self.rdr.read_compressed_int() for _ in range(ln)]
elif field_type in (92, 101): # SAVABLE_2D
outer = self.rdr.read_compressed_int()
val = [[self.rdr.read_compressed_int() for _ in range(self.rdr.read_compressed_int())] for _ in range(outer)]
elif field_type in (105,): # SAVABLE_MAP
ln = self.rdr.read_compressed_int()
val = [[self.rdr.read_compressed_int(), self.rdr.read_compressed_int()] for _ in range(ln)]
elif field_type in (106,): # STRING_SAVABLE_MAP
ln = self.rdr.read_compressed_int()
keys = [self.rdr.read_string() for _ in range(ln)]
_ = self.rdr.read_compressed_int() # skip length
vals = [self.rdr.read_compressed_int() for _ in range(ln)]
val = dict(zip(keys, vals))
elif field_type in (107,): # INT_SAVABLE_MAP
ln = self.rdr.read_compressed_int()
keys = [self.rdr.read_compressed_int() for _ in range(ln)]
ln2 = self.rdr.read_compressed_int()
vals = [self.rdr.read_compressed_int() for _ in range(ln2)]
val = dict(zip(keys, vals))
elif field_type in (120, 121, 122, 123): # Buffer types
ln = self.rdr.read_compressed_int()
if ln > 0:
elem_size = {120: 4, 121: 4, 122: 1, 123: 2}[field_type]
raw = self.rdr.read_bytes(ln * elem_size)
val = raw
else:
val = b''
elif field_type in (110,): # FLOATBUFFER_ARRAYLIST
ln = self.rdr.read_compressed_int()
val = []
for _ in range(ln):
nb = self.rdr.read_compressed_int()
if nb > 0:
val.append(self.rdr.read_bytes(nb * 4))
else:
val.append(b'')
else:
# Unknown type - skip ahead
val = None
return val, alias
def read_all_fields(self, bco):
"""Read all fields of a capsule using its class definition."""
fields_read = {}
while self.rdr.tell() < self.end_offset - 1:
try:
alias = self.rdr.read_byte()
except (IndexError, struct.error):
break
field = bco.alias_fields.get(alias)
if field is None:
# Unknown field alias - skip the rest
break
try:
val, _ = self.read_field_value(field['type'], expected_alias=None)
fields_read[field['name']] = val
except (IndexError, struct.error):
break
return fields_read
# ── Class table ─────────────────────────────────────────────────────────────
class BinaryClassObject:
def __init__(self, alias, name, fields):
self.alias = alias
self.name = name
self.fields = fields
self.alias_fields = {f['alias']: f for f in fields}
def parse_j3o_header(filepath):
"""Parse J3O header, class table, and location table. Return loader function."""
with open(filepath, 'rb') as f:
data = f.read()
rdr = J3OReader(data)
# Header
sig = rdr.read_be_uint32()
if sig != SIGNATURE:
# Old format (version 0): no signature, first int is num_classes
rdr.offset = 0
version = 0
num_classes = rdr.read_be_int32()
else:
version = rdr.read_be_int32()
num_classes = rdr.read_be_int32()
alias_width = int(math.log(max(num_classes, 2), 256)) + 1
# Class table
classes = {}
for i in range(num_classes):
alias = rdr.read_bytes(alias_width)
if version >= 1:
hier_size = rdr.read_byte()
_ = [rdr.read_be_int32() for _ in range(hier_size)]
else:
_ = [0]
name_len = rdr.read_be_int32()
name = rdr.read_bytes(name_len).decode('ascii', errors='replace')
num_fields = rdr.read_be_int32()
fields = []
for _ in range(num_fields):
fa = rdr.read_byte()
ft = rdr.read_byte()
fn_len = rdr.read_be_int32()
fn = rdr.read_bytes(fn_len).decode('ascii', errors='replace')
fields.append({'alias': fa, 'type': ft, 'name': fn})
classes[alias] = BinaryClassObject(alias, name, fields)
# Location table
num_locs = rdr.read_be_int32()
location_table = {}
for _ in range(num_locs):
obj_id = rdr.read_be_int32()
loc = rdr.read_be_int32()
location_table[obj_id] = loc
# Root
_ = rdr.read_be_int32()
root_id = rdr.read_be_int32()
payload_start = rdr.tell()
payload = data[payload_start:]
return alias_width, classes, location_table, root_id, payload
def read_object(obj_id, alias_width, classes, location_table, payload, cache=None):
"""Read a single capsule and return its class name and field data."""
if cache is None:
cache = {}
if obj_id in cache:
return cache[obj_id]
if obj_id not in location_table:
cache[obj_id] = ('unknown', {})
return cache[obj_id]
loc = location_table[obj_id]
rdr = J3OReader(payload)
rdr.offset = loc
alias = rdr.read_bytes(alias_width)
bco = classes.get(alias)
data_len = rdr.read_be_int32()
if bco is None:
cache[obj_id] = ('unknown', {})
return cache[obj_id]
cap_rdr = CapsuleReader(rdr, data_len)
fields = cap_rdr.read_all_fields(bco)
result = (bco.name, fields)
cache[obj_id] = result
return result
def resolve_savable(obj_id, alias_width, classes, location_table, payload, cache):
"""Resolve a Savable reference, returning (class_name, fields)."""
if obj_id <= 0 or obj_id == NULL_OBJECT or obj_id == DEFAULT_OBJECT:
return None
return read_object(obj_id, alias_width, classes, location_table, payload, cache)
# ── Vertex buffer decoding ──────────────────────────────────────────────────
def decode_floatbuffer(raw, count):
"""Decode LE float buffer. raw is bytes."""
if len(raw) != count * 4:
return [0.0] * count
return list(struct.unpack(f'<{count}f', raw))
def decode_shortbuffer(raw, count):
return list(struct.unpack(f'<{count}h', raw))
def decode_bytebuffer(raw, count):
return list(raw[:count])
def decode_intbuffer(raw, count):
return list(struct.unpack(f'<{count}i', raw))
# ── Mesh extraction ─────────────────────────────────────────────────────────
def extract_mesh_geometry(obj_id, alias_width, classes, location_table, payload, cache):
obj = resolve_savable(obj_id, alias_width, classes, location_table, payload, cache)
if obj is None:
return None
cls_name, fields = obj
if 'Mesh' not in cls_name:
return None
vert_count = fields.get('vertCount', 0)
if vert_count == 0:
return None
buffers_map = fields.get('buffers', {})
element_count = fields.get('elementCount', 0)
positions = []
normals = []
texcoords = []
indices = []
bone_weights_raw = b''
bone_indices_raw = b''
bone_idx_bytes_per_elem = 2
weight_comps = 4
idx_comps = 4
for usage_code, vb_id in buffers_map.items():
vb_obj = resolve_savable(vb_id, alias_width, classes, location_table, payload, cache)
if vb_obj is None:
continue
_, vb_fields = vb_obj
comp = vb_fields.get('components', 0)
format_code = vb_fields.get('format', -1)
raw_data = None
is_float = False
is_short = False
is_byte = False
for dk, fl, sh, bt in [
('dataFloat', True, False, False),
('dataUnsignedShort', False, True, False),
('dataUnsignedByte', False, False, True),
('dataShort', False, True, False),
('dataByte', False, False, True),
('dataInt', False, False, False),
('data', False, False, False),
]:
if dk in vb_fields:
raw_data = vb_fields[dk]
is_float = fl
is_short = sh
is_byte = bt
break
if raw_data is None:
continue
if usage_code == VB_USAGE['Position']:
elem_count = len(raw_data) // 4
positions = decode_floatbuffer(raw_data, elem_count)
actual_vert_count = elem_count // comp
elif usage_code == VB_USAGE['Normal']:
elem_count = len(raw_data) // 4
normals = decode_floatbuffer(raw_data, elem_count)
elif usage_code == VB_USAGE['TexCoord']:
elem_count = len(raw_data) // 4
texcoords = decode_floatbuffer(raw_data, elem_count)
elif usage_code == VB_USAGE['BoneWeight']: # 10
bone_weights_raw = raw_data
weight_comps = comp
elif usage_code == VB_USAGE['BoneIndex']: # 9
bone_indices_raw = raw_data
bone_idx_bytes_per_elem = 2 if is_short else 1
idx_comps = comp
elif usage_code == VB_USAGE['Misc']: # 8
if is_short:
indices = list(struct.unpack(f'<{len(raw_data)//2}h', raw_data))
elif len(raw_data) >= 2:
indices = list(struct.unpack(f'<{len(raw_data)//2}H', raw_data))
elif raw_data:
indices = list(raw_data)
if not positions:
return None
# Decode bone data now that we know actual_vert_count
actual_vert_count = len(positions) // 3 if positions else vert_count
bone_indices = []
if bone_indices_raw:
if bone_idx_bytes_per_elem == 2:
count = len(bone_indices_raw) // 2
total = min(count, actual_vert_count * idx_comps)
bone_indices = list(struct.unpack(f'<{total}H', bone_indices_raw[:total*2]))
else:
total = min(len(bone_indices_raw), actual_vert_count * idx_comps)
bone_indices = list(bone_indices_raw[:total])
bone_weights = []
if bone_weights_raw:
total = min(len(bone_weights_raw) // 4, actual_vert_count * weight_comps)
bone_weights = decode_floatbuffer(bone_weights_raw, total)
if not indices and element_count > 0:
indices = list(range(element_count))
return {
'vertexCount': actual_vert_count,
'positions': positions,
'normals': normals,
'texcoords': texcoords,
'indices': indices,
'boneWeights': bone_weights,
'boneIndices': bone_indices,
}
# ── Skeleton extraction ─────────────────────────────────────────────────────
def extract_skeleton(skeleton_id, alias_width, classes, location_table, payload, cache):
"""Extract skeleton data."""
obj = resolve_savable(skeleton_id, alias_width, classes, location_table, payload, cache)
if obj is None:
return None
cls_name, fields = obj
if 'Skeleton' not in cls_name:
return None
root_bones_ids = fields.get('rootBones', [])
bone_list_ids = fields.get('boneList', [])
bones = []
bid_to_idx = {}
for i, bid in enumerate(bone_list_ids):
bone_obj = resolve_savable(bid, alias_width, classes, location_table, payload, cache)
if bone_obj is None:
continue
_, bf = bone_obj
bones.append({
'id': i,
'name': bf.get('name', f'bone_{i}'),
'position': bf.get('bindPos', [0,0,0]) if 'bindPos' in bf else [0,0,0],
'rotation': bf.get('bindRot', [0,0,0,1]) if 'bindRot' in bf else [0,0,0,1],
'scale': bf.get('bindScale', [1,1,1]) if 'bindScale' in bf else [1,1,1],
})
bid_to_idx[bid] = i
# Hierarchy
hierarchy = {}
for bid in bone_list_ids:
bone_obj = resolve_savable(bid, alias_width, classes, location_table, payload, cache)
if bone_obj is None:
continue
_, bf = bone_obj
children_ids = bf.get('children', [])
parent_idx = bid_to_idx.get(bid)
for cid in children_ids:
child_idx = bid_to_idx.get(cid)
if child_idx is not None and parent_idx is not None:
hierarchy[bones[child_idx]['name']] = bones[parent_idx]['name']
roots = [bid_to_idx[rid] for rid in root_bones_ids if rid in bid_to_idx]
return {
'bones': bones,
'hierarchy': hierarchy,
'roots': roots,
}
# ── Animation extraction ────────────────────────────────────────────────────
def extract_animations(anim_map, alias_width, classes, location_table, payload, cache):
"""Extract animation data from StringSavableMap of anim_name -> Animation."""
if anim_map is None:
return {}
animations = {}
for anim_name, anim_id in anim_map.items():
anim_obj = resolve_savable(anim_id, alias_width, classes, location_table, payload, cache)
if anim_obj is None:
continue
_, af = anim_obj
tracks_ids = af.get('tracks', [])
anim_len = af.get('length', 1.0)
anim_name_str = af.get('name', anim_name) or anim_name
tracks = []
for tid in tracks_ids:
track_obj = resolve_savable(tid, alias_width, classes, location_table, payload, cache)
if track_obj is None:
continue
ct, tf = track_obj
bone_idx = tf.get('boneIndex', 0)
# times: float array
times = tf.get('times', [])
# translations: savable CompactVector3Array
trans_id = tf.get('translations', None)
translations = []
if trans_id is not None:
trans_obj = resolve_savable(trans_id, alias_width, classes, location_table, payload, cache)
if trans_obj:
_, trf = trans_obj
trans_data = trf.get('array', None)
if trans_data:
translations = list(struct.unpack(f'<{len(trans_data)//4}f', trans_data)) if trans_data else []
# rotations: savable CompactQuaternionArray
rot_id = tf.get('rotations', None)
rotations = []
if rot_id is not None:
rot_obj = resolve_savable(rot_id, alias_width, classes, location_table, payload, cache)
if rot_obj:
_, rf = rot_obj
rot_data = rf.get('array', None)
if rot_data:
rotations = list(struct.unpack(f'<{len(rot_data)//4}f', rot_data)) if rot_data else []
# scales: savable CompactVector3Array
scale_id = tf.get('scales', None)
scales = []
if scale_id is not None:
scale_obj = resolve_savable(scale_id, alias_width, classes, location_table, payload, cache)
if scale_obj:
_, sf = scale_obj
scale_data = sf.get('array', None)
if scale_data:
scales = list(struct.unpack(f'<{len(scale_data)//4}f', scale_data)) if scale_data else []
tracks.append({
'boneIndex': bone_idx,
'times': times,
'translations': translations,
'rotations': rotations,
'scales': scales,
})
animations[anim_name_str] = {
'length': anim_len,
'tracks': tracks,
}
return animations
# ── Main extraction ─────────────────────────────────────────────────────────
def extract_j3o(filepath):
"""Extract mesh, skeleton, and animation data from a .j3o file."""
alias_width, classes, location_table, root_id, payload = parse_j3o_header(filepath)
cache = {}
result = {
'file': filepath,
'geometries': [],
'skeletons': [],
'animations': {},
}
# Walk object graph looking for mesh/skeleton/animation data
def walk_obj(obj_id, depth=0):
if depth > 50 or obj_id <= 0:
return
try:
obj = resolve_savable(obj_id, alias_width, classes, location_table, payload, cache)
except Exception:
return
if obj is None:
return
cls_name, fields = obj
# Check for Geometry / Mesh data
if 'Geometry' in cls_name:
mesh_id = fields.get('mesh', None)
if mesh_id and mesh_id > 0:
try:
geo = extract_mesh_geometry(mesh_id, alias_width, classes, location_table, payload, cache)
if geo:
result['geometries'].append({
'name': fields.get('name', 'unknown'),
'geometry': geo,
})
except Exception:
pass
# Check for AnimControl
if 'AnimControl' in cls_name:
skeleton_id = fields.get('skeleton', None)
if skeleton_id and skeleton_id > 0:
skel = extract_skeleton(skeleton_id, alias_width, classes, location_table, payload, cache)
if skel:
result['skeletons'].append(skel)
anim_map = fields.get('animations', None)
if isinstance(anim_map, dict):
anims = extract_animations(anim_map, alias_width, classes, location_table, payload, cache)
result['animations'].update(anims)
# Check for SkeletonControl
if 'SkeletonControl' in cls_name:
skeleton_id = fields.get('skeleton', None)
if skeleton_id and skeleton_id > 0 and not result['skeletons']:
skel = extract_skeleton(skeleton_id, alias_width, classes, location_table, payload, cache)
if skel:
result['skeletons'].append(skel)
# Recurse into children and controls
for list_key in ['children', 'controlsList']:
items = fields.get(list_key, None)
if isinstance(items, list):
for item in items:
if isinstance(item, int) and item > 0:
walk_obj(item, depth + 1)
walk_obj(root_id)
return result
# ── Convert to glTF / JSON ──────────────────────────────────────────────────
def j3o_to_gltf(j3o_data, output_path):
"""Convert extracted J3O data to glTF or OBJ-like JSON."""
geos = j3o_data['geometries']
skels = j3o_data['skeletons']
anims = j3o_data['animations']
if not geos:
print(f" No geometry found in {j3o_data['file']}")
return
# For now, output as simple OBJ-like JSON (positions + indices)
for geo_entry in geos:
g = geo_entry['geometry']
name = geo_entry['name'] or os.path.basename(j3o_data['file']).replace('.j3o', '')
out = {
'name': name,
'vertexCount': g['vertexCount'],
'positions': g['positions'],
'normals': g['normals'],
'indices': g['indices'],
'hasBones': len(g['boneWeights']) > 0,
'boneWeights': g['boneWeights'],
'boneIndices': g['boneIndices'],
}
# Add skeleton if exists
if skels:
out['skeleton'] = skels[0]
if anims:
out['animations'] = anims
out_path = os.path.join(output_path, f'{name}.json')
with open(out_path, 'w') as f:
json.dump(out, f, separators=(',', ':'))
print(f" -> {out_path} ({g['vertexCount']} verts, {len(g['indices'])//3} faces)")
# ── Main ────────────────────────────────────────────────────────────────────
def main():
base = '/var/home/nico/Gamedev/Wackelpeter/extracted/Models'
out_dir = '/var/home/nico/Gamedev/Wackelpeter/web/public/models'
j3o_files = [
('spider', 'spider/spider.mesh.j3o'),
('staff', 'staff/staff.mesh.j3o'),
('cross', 'kross/kross.mesh.j3o'),
]
for name, path in j3o_files:
full = os.path.join(base, path)
if not os.path.exists(full):
print(f'Skipping {name}: not found')
continue
print(f'Extracting {name}...')
try:
data = extract_j3o(full)
j3o_to_gltf(data, out_dir)
print(f" Skeletons: {len(data['skeletons'])}, Animations: {len(data['animations'])}")
except Exception as e:
print(f' ERROR: {e}')
import traceback
traceback.print_exc()
if __name__ == '__main__':
main()