# Blender (3.2) batch: SKCombat animated tokens → glTF minis for the 3D view (skinned mesh + armature + named clips).
# Run: blender -b --factory-startup --python minis.py -- <minis_jobs.json> <Tokens dir> <out dir> <textures dir> [thumbs dir]
# A job: {"name", "base": fbx, "tex": texture name, "clips": {role: [fbx, first, last]}, "overlays": [[fbx, mesh, anchor], ...]}
# Roles the VTT plays: idle (loops), spawn (dropped in), hit (hurt), act (its card opened), dead (down, holds the last frame).
# Each clip is cut from its take (Unity's frame ranges, see the .fbx.meta files) into one action per animated object,
# pushed to an NLA track named after the role; the glTF exporter merges same-named tracks into one animation.
import bpy, sys, os, json, mathutils
args = sys.argv[sys.argv.index('--') + 1:]
jobs_path, tok_dir, out_dir, tex_dir = args[:4]
thumbs = args[4] if len(args) > 4 else None
PATCH = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'fbxpatch.py')
TILE = 0.015  # one SKCombat floor tile in Blender units after the FBX's own 0.01 scale
jobs = json.load(open(jobs_path))
only = os.environ.get('MINIS_ONLY')

def load(fbx):
    """Import an FBX (skins kept: see fbxpatch.py); return the new objects."""
    before = set(bpy.data.objects)
    bpy.ops.import_scene.fbx(filepath=os.path.join(tok_dir, fbx), anim_offset=0)
    return [o for o in bpy.data.objects if o not in before]

def base_name(n):
    return n.split('.')[0]

def cut(action, a, b, name):
    """A copy of `action` from frame a to b, sampled every frame, starting at 0."""
    out = bpy.data.actions.new(name)
    for fc in action.fcurves:
        nf = out.fcurves.new(fc.data_path, index=fc.array_index, action_group=fc.group.name if fc.group else '')
        n = b - a + 1
        nf.keyframe_points.add(n)
        for i in range(n):
            kp = nf.keyframe_points[i]
            kp.co = (i, fc.evaluate(a + i))
            kp.interpolation = 'LINEAR'
    return out

for j in jobs:
    if only and j['name'] not in only.split(','):
        continue
    bpy.ops.wm.read_factory_settings(use_empty=True)
    exec(open(PATCH).read())
    scene = bpy.context.scene
    objs = load(j['base'])
    arm = next(o for o in objs if o.type == 'ARMATURE')
    print('CONV', j['name'], [list(map(lambda v: round(v, 4), r)) for r in arm.matrix_world])
    # the takes: the base file's own, and other files with the same rig (bone and node names match)
    takes = {j['base']: {base_name(o.name): o.animation_data.action for o in objs if o.animation_data and o.animation_data.action}}
    for fbx in {c[0] for c in j['clips'].values()} - {j['base']}:
        extra = load(fbx)
        takes[fbx] = {base_name(o.name): o.animation_data.action for o in extra if o.animation_data and o.animation_data.action}
        for o in extra:
            bpy.data.objects.remove(o, do_unlink=True)
    for o in objs:
        if o.animation_data:
            o.animation_data.action = None
    named = {base_name(o.name): o for o in objs}
    # overlays (armor, moss…): authored in place around the rest pose; Unity hung each on an anchor node under a
    # bone. Here each is skinned to that bone (weight 1) and joined into the body mesh, so it follows the animation
    # like the rest; a piece reused on the other side (left / right) is carried over by the two bones' rest matrices.
    for pb in arm.pose.bones:
        pb.matrix_basis = mathutils.Matrix.Identity(4)
    bpy.context.view_layer.update()
    def bone_of(anchor):
        a = named.get(anchor)
        return a.parent_bone if a is not None and a.parent_type == 'BONE' else None
    rest = lambda b: arm.matrix_world @ arm.data.bones[b].matrix_local
    body = next(o for o in objs if o.type == 'MESH' and o.parent == arm and any(md.type == 'ARMATURE' for md in o.modifiers))
    extra = []
    for fbx in {ov[0] for ov in j.get('overlays', [])}:
        pieces = load(fbx)
        byname = {base_name(o.name): o for o in pieces if o.type == 'MESH'}
        homes = {}
        for ofbx, mesh, anchor in j['overlays']:
            if ofbx == fbx and mesh in byname and bone_of(anchor):
                homes.setdefault(mesh, []).append(bone_of(anchor))
        for mesh, bones in homes.items():
            src = byname[mesh]
            mid = src.matrix_world @ (sum((mathutils.Vector(c) for c in src.bound_box), mathutils.Vector()) / 8)
            home = min(bones, key=lambda b: (rest(b).translation - mid).length)  # the side it was modelled on
            for b in bones:
                ob = src if b == home else src.copy()
                if ob is not src:
                    ob.data = src.data.copy()
                    scene.collection.objects.link(ob)
                ob.parent = None
                ob.matrix_world = src.matrix_world.copy()
                # bake its place into the vertices, then weight every vertex to the bone; the piece reused on the
                # other side is the authored one mirrored across the body's middle (the rigs are symmetric about x)
                ob.data.transform(ob.matrix_world)
                if b != home:
                    ob.data.transform(mathutils.Matrix.Scale(-1, 4, (1, 0, 0)))
                    ob.data.flip_normals()
                ob.matrix_world = mathutils.Matrix.Identity(4)
                vg = ob.vertex_groups.new(name=b)
                vg.add([v.index for v in ob.data.vertices], 1.0, 'REPLACE')
                extra.append(ob)
        for ofbx, mesh, anchor in j['overlays']:
            if ofbx == fbx and mesh not in homes:
                print('MISSING', j['name'], mesh, anchor)
        for o in pieces:
            if base_name(o.name) not in homes:
                bpy.data.objects.remove(o, do_unlink=True)
    if extra:
        # into the body: same armature, the vertex groups merge by bone name. The body mesh is in armature space
        # (parented, with the armature's scale), so the pieces go through the same transform first.
        inv = body.matrix_world.inverted()
        for ob in extra:
            ob.data.transform(inv)
            ob.parent = body.parent
            ob.matrix_parent_inverse = body.matrix_parent_inverse.copy()
            ob.matrix_basis = body.matrix_basis.copy()
        bpy.ops.object.select_all(action='DESELECT')
        for ob in extra:
            ob.select_set(True)
        body.select_set(True)
        bpy.context.view_layer.objects.active = body
        bpy.ops.object.join()
        objs = [o for o in objs if o.name in bpy.data.objects]
    # drop the editor helpers (anchor / UI nodes: nothing hangs from them now, stray cubes)
    for o in list(bpy.data.objects):
        if o.type == 'EMPTY' or o.name.startswith('Cube'):
            bpy.data.objects.remove(o, do_unlink=True)
    # one material: the token's texture sheet
    ext = '.png' if os.path.exists(os.path.join(tex_dir, j['tex'] + '.png')) else '.jpg'
    img = bpy.data.images.load(os.path.join(tex_dir, j['tex'] + ext))
    img.name = j['tex']
    if img.size[0] == img.size[1] and j['tex'].startswith(('skeleton-', 'spider-')):
        # the token sheets are 1024×512 with the body on the left half (the mesh's UVs stop at u = 0.5); a square
        # sheet (the plain skins) is the body alone: put it on the left half of a wide one
        wide = bpy.data.images.new(j['tex'] + '-wide', img.size[0] * 2, img.size[1], alpha=True)
        px = list(img.pixels)
        w, h = img.size
        out = [0.0] * (w * 2 * h * 4)
        for y in range(h):
            out[y * w * 2 * 4:(y * w * 2 + w) * 4] = px[y * w * 4:(y + 1) * w * 4]
        wide.pixels = out
        wide.filepath_raw = os.path.join(tex_dir, j['tex'] + '-wide.png')
        wide.file_format = 'PNG'
        wide.save()
        img = wide
    mat = bpy.data.materials.new('sk-' + j['tex'])
    mat.use_nodes = True
    nt = mat.node_tree
    bsdf = nt.nodes.get('Principled BSDF')
    tn = nt.nodes.new('ShaderNodeTexImage')
    tn.image = img
    nt.links.new(tn.outputs['Color'], bsdf.inputs['Base Color'])
    bsdf.inputs['Roughness'].default_value = 0.8
    bsdf.inputs['Specular'].default_value = 0.2
    for o in bpy.data.objects:
        if o.type == 'MESH':
            o.data.materials.clear()
            o.data.materials.append(mat)
    # clips → NLA tracks named by role
    for role, (fbx, a, b) in j['clips'].items():
        for oname, act in takes[fbx].items():
            o = bpy.data.objects.get(oname)
            if o is None:
                continue
            o.animation_data_create()
            tr = o.animation_data.nla_tracks.new()
            tr.name = role
            tr.strips.new(role, 0, cut(act, a, b, oname + '|' + role))
    for a in list(bpy.data.actions):
        if '|' not in a.name or a.name.split('|')[-1] not in j['clips']:
            bpy.data.actions.remove(a)
    # size and ground: in the idle pose, base centered on the origin, feet at 0, one SKCombat tile = 1 unit
    idle = j['clips'].get('idle')
    for o in bpy.data.objects:
        if o.animation_data:
            for tr in o.animation_data.nla_tracks:
                tr.mute = tr.name != 'idle'
    scene.frame_set(0)
    dg = bpy.context.evaluated_depsgraph_get()
    pts = []
    for o in bpy.data.objects:
        if o.type == 'MESH':
            ev = o.evaluated_get(dg)
            me = ev.to_mesh()
            pts += [ev.matrix_world @ v.co for v in me.vertices]
            ev.to_mesh_clear()
    zmin = min(p.z for p in pts)
    foot = [p for p in pts if p.z <= zmin + 0.15 * (max(p.z for p in pts) - zmin)]
    cx = (min(p.x for p in foot) + max(p.x for p in foot)) / 2
    cy = (min(p.y for p in foot) + max(p.y for p in foot)) / 2
    k = 1 / TILE
    root = bpy.data.objects.new('mini', None)
    scene.collection.objects.link(root)
    for o in bpy.data.objects:
        if o.parent is None and o is not root:
            mw = o.matrix_world.copy()
            o.parent = root
            o.matrix_world = mw
    root.location = (-cx * k, -cy * k, -zmin * k)
    root.scale = (k, k, k)
    for o in bpy.data.objects:
        if o.animation_data:
            for tr in o.animation_data.nla_tracks:
                tr.mute = False
    os.makedirs(os.path.join(out_dir, 'minis'), exist_ok=True)
    bpy.ops.export_scene.gltf(filepath=os.path.join(out_dir, 'minis', j['name'] + '.gltf'), export_format='GLTF_SEPARATE',
                              export_texture_dir='tex', export_materials='EXPORT', export_yup=True, export_apply=False,
                              export_cameras=False, export_lights=False, export_animations=True, export_nla_strips=True,
                              export_force_sampling=True, export_frame_step=2, export_skins=True, export_def_bones=False)
    print('MINI', j['name'], 'clips', list(j['clips']), 'size', [round((max(getattr(p, ax) for p in pts) - min(getattr(p, ax) for p in pts)) * k, 2) for ax in 'xyz'])
    if thumbs:
        # a quick look (idle pose, 3/4 view) for checking the conversion
        for o in bpy.data.objects:
            if o.animation_data:
                for tr in o.animation_data.nla_tracks:
                    tr.mute = tr.name != 'idle'
        scene.frame_set(10)
        cam = bpy.data.objects.new('cam', bpy.data.cameras.new('cam'))
        scene.collection.objects.link(cam)
        scene.camera = cam
        cam.data.type = 'ORTHO'
        size = [(max(getattr(p, ax) for p in pts) - min(getattr(p, ax) for p in pts)) * k for ax in 'xyz']
        cam.data.ortho_scale = max(size) * 1.35
        aim = bpy.data.objects.new('aim', None)
        scene.collection.objects.link(aim)
        aim.location = (0, 0, size[2] / 2)
        cam.location = (6, -6, 4 + size[2] / 2)
        con = cam.constraints.new('TRACK_TO')
        con.target = aim
        con.track_axis = 'TRACK_NEGATIVE_Z'
        con.up_axis = 'UP_Y'
        scene.render.engine = 'BLENDER_WORKBENCH'
        scene.display.shading.color_type = 'TEXTURE'
        scene.display.shading.light = 'STUDIO'
        scene.render.film_transparent = True
        scene.render.resolution_x = scene.render.resolution_y = 256
        scene.render.filepath = os.path.join(thumbs, j['name'] + '.png')
        bpy.ops.render.render(write_still=True)
