"""Build the intro cook, retarget the existing motion library, and export a GLB.

Run with Blender 4.5: blender --background --python tools/character-art/build_intro_cook.py
Everything is authored in meters, facing -Y in Blender (+Z after glTF export).
The generated meshes never define gameplay collision or combat reach.
"""
import bpy
import math
import json
from pathlib import Path
from mathutils import Vector, Matrix, Quaternion

ROOT = Path(__file__).resolve().parents[2]
OUT = ROOT / 'assets/models/player/intro_cook'
SOURCE = ROOT / 'tools/character-art/source'
REVIEW = ROOT / '_backups/intro-cook-review'
OUT.mkdir(parents=True, exist_ok=True)
SOURCE.mkdir(parents=True, exist_ok=True)
(SOURCE / '.gdignore').write_text('', encoding='utf-8')
REVIEW.mkdir(parents=True, exist_ok=True)
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.gltf(filepath=str(ROOT / 'assets/models/player/clockwork_balloonkeep.glb'))
source = next(o for o in bpy.data.objects if o.type == 'ARMATURE')
source.name = 'MotionReference'
source_actions = {a.name: a for a in bpy.data.actions}
for obj in list(bpy.data.objects):
    if obj != source:
        bpy.data.objects.remove(obj, do_unlink=True)

def material(name, color, roughness=0.75, metallic=0.0):
    mat = bpy.data.materials.new(name)
    mat.diffuse_color = (*color, 1)
    mat.use_nodes = True
    shader = mat.node_tree.nodes.get('Principled BSDF')
    shader.inputs['Base Color'].default_value = (*color, 1)
    shader.inputs['Roughness'].default_value = roughness
    shader.inputs['Metallic'].default_value = metallic
    return mat

SKIN = material('Warm skin', (0.63, 0.365, 0.215))
SKIN_LIGHT = material('Face planes', (0.72, 0.445, 0.28))
SKIN_SHADOW = material('Ear and lip shading', (0.42, 0.19, 0.12))
SHIRT = material('Ivory chef cotton', (0.81, 0.79, 0.70))
SHIRT_LIGHT = material('Rolled cotton and collar', (0.94, 0.915, 0.83))
DENIM = material('Indigo denim', (0.075, 0.13, 0.19))
DENIM_LIGHT = material('Denim pockets and knee patches', (0.115, 0.185, 0.25))
SEAM = material('Ochre topstitching', (0.49, 0.345, 0.18))
LEATHER = material('Charcoal boot leather', (0.035, 0.028, 0.024), 0.47)
SOLE = material('Rubber soles', (0.017, 0.018, 0.02))
BELT = material('Worn leather belt and pouch', (0.115, 0.074, 0.045))
BRASS = material('Aged brass', (0.56, 0.34, 0.12), 0.36, 0.65)
HAIR = material('Black hair', (0.009, 0.012, 0.018), 0.58)
HAIR_LIGHT = material('Hair charcoal facets', (0.027, 0.034, 0.043), 0.65)
WHITE = material('Eyes ivory', (0.96, 0.94, 0.85), 0.36)
IRIS = material('Hazel irises', (0.19, 0.115, 0.055), 0.32)
DARK = material('Pupils and expression', (0.009, 0.007, 0.007), 0.8)

rig_data = bpy.data.armatures.new('IntroCookSkeleton')
rig = bpy.data.objects.new('IntroCookRig', rig_data)
bpy.context.collection.objects.link(rig)
bpy.context.view_layer.objects.active = rig
rig.select_set(True)
bpy.ops.object.mode_set(mode='EDIT')
bones = {}

def bone(name, head, tail, parent=None):
    b = rig_data.edit_bones.new(name)
    b.head, b.tail = head, tail
    if parent:
        b.parent = rig_data.edit_bones[parent]
    bones[name] = (Vector(head), Vector(tail))

bone('Hips', (0, 0, 1.02), (0, 0, 1.10))
bone('Spine02', (0, 0, 1.10), (0, 0, 1.27), 'Hips')
bone('Spine01', (0, 0, 1.27), (0, 0, 1.44), 'Spine02')
bone('Spine', (0, 0, 1.44), (0, 0, 1.61), 'Spine01')
bone('neck', (0, 0, 1.61), (0, 0, 1.77), 'Spine')
bone('Head', (0, 0, 1.77), (0, 0, 2.105), 'neck')
bone('head_end', (0, 0, 2.105), (0, 0, 2.20), 'Head')
bone('headfront', (0, -0.14, 1.93), (0, -0.24, 1.93), 'Head')
for side, sign in [('Left', 1), ('Right', -1)]:
    bone(side+'Shoulder', (sign*.065, 0, 1.56), (sign*.255, 0, 1.56), 'Spine')
    bone(side+'Arm', (sign*.255, 0, 1.56), (sign*.355, -.015, 1.275), side+'Shoulder')
    bone(side+'ForeArm', (sign*.355, -.015, 1.275), (sign*.425, -.055, 1.005), side+'Arm')
    bone(side+'Hand', (sign*.425, -.055, 1.005), (sign*.445, -.105, .88), side+'ForeArm')
    bone(side+'UpLeg', (sign*.108, 0, 1.025), (sign*.145, -.025, .565), 'Hips')
    bone(side+'Leg', (sign*.145, -.025, .565), (sign*.155, .005, .145), side+'UpLeg')
    bone(side+'Foot', (sign*.155, .005, .145), (sign*.155, -.16, .065), side+'Leg')
    bone(side+'ToeBase', (sign*.155, -.16, .065), (sign*.155, -.24, .065), side+'Foot')
bpy.ops.object.mode_set(mode='OBJECT')
rig.select_set(False)
parts = []

def bind(obj, weights):
    for b in (weights if isinstance(weights, dict) else {weights: 1.0}):
        obj.vertex_groups.new(name=b)
    if isinstance(weights, str):
        obj.vertex_groups[weights].add(list(range(len(obj.data.vertices))), 1, 'REPLACE')
    else:
        for b, weight in weights.items():
            obj.vertex_groups[b].add(list(range(len(obj.data.vertices))), weight, 'REPLACE')
    mod = obj.modifiers.new('Skin', 'ARMATURE')
    mod.object = rig
    obj.parent = rig
    parts.append(obj)
    return obj

def mesh_obj(name, vertices, faces, mat, weights='Head', smooth=True):
    data = bpy.data.meshes.new(name)
    data.from_pydata(vertices, [], faces)
    data.materials.append(mat)
    data.update()
    obj = bpy.data.objects.new(name, data)
    bpy.context.collection.objects.link(obj)
    for poly in data.polygons:
        poly.use_smooth = smooth
    return bind(obj, weights)

def apply_transform(obj):
    bpy.ops.object.select_all(action='DESELECT')
    obj.select_set(True)
    bpy.context.view_layer.objects.active = obj
    bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
    obj.select_set(False)

def ellipsoid(name, at, scale, mat, weights='Head', segments=20, rings=12, rotation=None):
    bpy.ops.mesh.primitive_uv_sphere_add(segments=segments, ring_count=rings, location=at)
    obj = bpy.context.object
    obj.name = name
    obj.scale = scale
    if rotation is not None:
        obj.rotation_euler = rotation
    apply_transform(obj)
    obj.data.materials.append(mat)
    for p in obj.data.polygons:
        p.use_smooth = True
    return bind(obj, weights)

def stroke(name, points, radius, mat, weights='Head'):
    curve = bpy.data.curves.new(name, 'CURVE')
    curve.dimensions = '3D'
    curve.resolution_u = 8
    curve.bevel_depth = radius
    curve.bevel_resolution = 2
    spline = curve.splines.new('BEZIER')
    spline.bezier_points.add(len(points)-1)
    for bp, co in zip(spline.bezier_points, points):
        bp.co = co
        bp.handle_left_type = 'AUTO'
        bp.handle_right_type = 'AUTO'
    obj = bpy.data.objects.new(name, curve)
    bpy.context.collection.objects.link(obj)
    bpy.ops.object.select_all(action='DESELECT')
    obj.select_set(True)
    bpy.context.view_layer.objects.active = obj
    bpy.ops.object.convert(target='MESH')
    obj = bpy.context.object
    obj.data.materials.append(mat)
    obj.select_set(False)
    return bind(obj, weights)

def loft(name, sections, mat, weight_fn, sides=20):
    # Sections: center XYZ and elliptical cross-section radii in X/Y.
    vertices, faces = [], []
    for x, y, z, rx, ry in sections:
        for j in range(sides):
            angle = 2*math.pi*j/sides
            vertices.append((x+rx*math.cos(angle), y+ry*math.sin(angle), z))
    for i in range(len(sections)-1):
        for j in range(sides):
            a = i*sides+j
            b = i*sides+(j+1)%sides
            faces.append((a, b, b+sides, a+sides))
    faces.append(tuple(reversed(range(sides))))
    faces.append(tuple(range((len(sections)-1)*sides, len(sections)*sides)))
    obj = mesh_obj(name, vertices, faces, mat, 'Hips')
    obj.vertex_groups.clear()
    for v in obj.data.vertices:
        for b, w in weight_fn(v.co).items():
            group = obj.vertex_groups.get(b) or obj.vertex_groups.new(name=b)
            group.add([v.index], w, 'REPLACE')
    return obj

def blend(a, b, t):
    t = min(1.0, max(0.0, t))
    return {a: 1-t, b: t}

def torso_weights(p):
    if p.z < 1.17:
        return blend('Hips', 'Spine02', (p.z-1.05)/.12)
    if p.z < 1.36:
        return blend('Spine02', 'Spine01', (p.z-1.20)/.16)
    return blend('Spine01', 'Spine', (p.z-1.38)/.14)

# Fitted shirt and overall waist: broad shoulders, tapered waist, robust legs.
loft('Chef shirt', [(0,0,1.055,.174,.10),(0,0,1.14,.177,.108),(0,0,1.28,.185,.114),
    (0,0,1.42,.224,.132),(0,0,1.53,.241,.125),(0,0,1.60,.17,.094),(0,0,1.65,.068,.069)], SHIRT, torso_weights)
loft('Overall hips', [(0,0,.98,.182,.116),(0,0,1.045,.182,.118),(0,0,1.12,.178,.11)], DENIM, lambda p: {'Hips':1})
loft('Leather belt', [(0,0,1.067,.185,.122),(0,0,1.103,.183,.12)], BELT, lambda p: {'Hips':1})
ellipsoid('Belt buckle', (0,-.129,1.085), (.034,.009,.023), BRASS, 'Hips')
ellipsoid('Buckle inset', (0,-.139,1.085), (.023,.004,.014), BELT, 'Hips')

# Denim bib and suspenders follow the moving chest, with visible topstitching.
bib = mesh_obj('Overall bib', [(-.153,-.142,1.16),(.153,-.142,1.16),(.143,-.143,1.43),
    (.112,-.147,1.505),(-.112,-.147,1.505),(-.143,-.143,1.43)], [(0,1,2,3,4,5)], DENIM, 'Spine01')
solid = bib.modifiers.new('Cotton thickness', 'SOLIDIFY'); solid.thickness=.008
for s in [-1,1]:
    stroke('Bib side stitching', [(s*.14,-.153,1.17),(s*.137,-.155,1.41),(s*.108,-.155,1.492)], .0025, SEAM, 'Spine01')
    strap = stroke('Denim suspender', [(s*.105,-.151,1.48),(s*.159,-.118,1.565),
        (s*.169,-.017,1.619),(s*.16,.107,1.55),(s*.105,.122,1.24)], .025, DENIM, 'Spine')
    stroke('Suspender stitching', [(s*.108,-.178,1.49),(s*.163,-.142,1.57),(s*.174,-.021,1.645)], .0023, SEAM, 'Spine')
    ellipsoid('Suspender brass clasp', (s*.108,-.179,1.493), (.023,.008,.032), BRASS, 'Spine')
    ellipsoid('Suspender clasp center', (s*.108,-.188,1.498), (.013,.004,.02), DENIM, 'Spine')
    ellipsoid('Bib rivet', (s*.11,-.185,1.465), (.007,.006,.007), BRASS, 'Spine01', 12, 8)
mesh_obj('Chest pocket', [(-.079,-.158,1.272),(0,-.163,1.246),(.079,-.158,1.272),
    (.079,-.16,1.362),(-.079,-.16,1.362)], [(0,1,2,3,4)], DENIM_LIGHT, 'Spine01')
stroke('Chest pocket stitching', [(-.074,-.165,1.355),(-.074,-.165,1.278),(0,-.168,1.253),(.074,-.165,1.278),(.074,-.165,1.355)], .0025, SEAM, 'Spine01')
for s in [-1,1]:
    mesh_obj('Pointed chef collar', [(s*.018,-.073,1.657),(s*.085,-.084,1.632),
        (s*.122,-.126,1.532),(s*.047,-.135,1.573)], [(0,1,2,3)], SHIRT_LIGHT, 'Spine')
ellipsoid('Neck', (0,0,1.675), (.067,.066,.115), SKIN, 'neck')

# Sleeves, bare forearms and stylized closed hands.
for side, s in [('Left',1),('Right',-1)]:
    arm, fore, hand = side+'Arm', side+'ForeArm', side+'Hand'
    loft(side+' sleeve', [(s*.25,0,1.565,.104,.096),(s*.286,-.002,1.48,.098,.094),
        (s*.317,-.009,1.39,.084,.086),(s*.344,-.014,1.307,.079,.081)], SHIRT, lambda p, a=arm:{a:1})
    loft(side+' rolled cuff', [(s*.337,-.014,1.346,.09,.087),(s*.345,-.014,1.306,.09,.087),
        (s*.35,-.015,1.287,.081,.082)], SHIRT_LIGHT, lambda p,a=arm:{a:1})
    stroke(side+' cuff fold', [(s*.281,-.071,1.317),(s*.345,-.099,1.317),(s*.409,-.07,1.317)], .0045, SHIRT, arm)
    loft(side+' forearm', [(s*.35,-.015,1.295,.071,.07),(s*.371,-.025,1.22,.073,.068),
        (s*.404,-.044,1.10,.057,.055),(s*.425,-.055,1.002,.045,.042)], SKIN, lambda p,f=fore,a=arm:blend(a,f,(1.31-p.z)/.07))
    ellipsoid(side+' palm', (s*.439,-.072,.966), (.059,.046,.085), SKIN, hand)
    for finger in range(4):
        x = s*(.403+finger*.021)
        ellipsoid(side+' curled finger', (x,-.101,.925+abs(finger-1.4)*.006), (.017,.032,.043), SKIN_LIGHT, hand, 12, 8)
    ellipsoid(side+' thumb', (s*.39,-.106,.972), (.026,.034,.045), SKIN, hand, 16, 10, (0,s*.3,s*.3))

# Trousers, knee patches, rolled hems, chunky boots and individually modeled laces.
for side,s in [('Left',1),('Right',-1)]:
    thigh, shin, foot = side+'UpLeg', side+'Leg', side+'Foot'
    loft(side+' trouser leg', [(s*.10,0,1.03,.108,.115),(s*.117,-.002,.90,.109,.114),
        (s*.132,-.014,.74,.098,.097),(s*.145,-.025,.59,.085,.084),
        (s*.148,-.018,.48,.08,.078),(s*.152,-.005,.34,.074,.072),(s*.155,.002,.245,.074,.071)],
        DENIM, lambda p,t=thigh,k=shin:blend(t,k,(.64-p.z)/.14))
    ellipsoid(side+' knee patch', (s*.142,-.104,.619), (.07,.013,.10), DENIM_LIGHT, thigh, 20,12)
    stroke(side+' patch stitching', [(s*.095,-.12,.683),(s*.087,-.121,.609),(s*.135,-.122,.532),
        (s*.194,-.12,.59),(s*.197,-.116,.673)], .0024, SEAM, thigh)
    stroke(side+' trouser seam upper', [(s*.204,-.062,.99),(s*.224,-.061,.82),(s*.226,-.056,.64)], .0024, SEAM, thigh)
    stroke(side+' trouser seam lower', [(s*.223,-.052,.55),(s*.225,-.043,.39),(s*.225,-.035,.28)], .0024, SEAM, shin)
    loft(side+' folded denim hem', [(s*.155,.002,.25,.083,.077),(s*.155,.002,.29,.085,.079),
        (s*.155,.002,.31,.081,.075)], DENIM_LIGHT, lambda p,k=shin:{k:1})
    ellipsoid(side+' boot sole', (s*.155,-.081,.061), (.097,.198,.04), SOLE, foot, 24,12)
    ellipsoid(side+' boot toe', (s*.155,-.139,.104), (.089,.126,.075), LEATHER, foot, 24,12)
    loft(side+' boot upper', [(s*.155,.015,.09,.083,.08),(s*.155,.02,.19,.076,.078),
        (s*.155,.02,.31,.075,.074)], LEATHER, lambda p,k=foot,l=shin:blend(k,l,(p.z-.15)/.14))
    stroke(side+' welt', [(s*.076,-.19,.077),(s*.086,-.249,.077),(s*.155,-.272,.077),
        (s*.226,-.249,.077),(s*.235,-.19,.077)], .004, SEAM, foot)
    for lace in range(5):
        z=.135+lace*.031
        y=-.10+lace*.008
        for dx in [-.035,.035]:
            ellipsoid(side+' eyelet', (s*.155+dx,y,z), (.008,.004,.008), BRASS, foot, 10,6)
        stroke(side+' boot lace', [(s*.155-.032,y-.006,z),(s*.155,y-.014,z+.007),
            (s*.155+.032,y-.006,z+.024)], .0038, SHIRT, foot)
    ellipsoid(side+' back trouser pocket', (s*.115,.112,.963), (.063,.009,.077), DENIM_LIGHT, 'Hips')

ellipsoid('Utility pouch', (.223,.007,1.013), (.054,.077,.105), BELT, 'Hips')
stroke('Pouch flap stitching', [(.258,-.054,1.073),(.275,0,1.063),(.258,.054,1.073)], .003, SEAM, 'Hips')
ellipsoid('Pouch button', (.277,-.004,1.048), (.004,.011,.011), BRASS, 'Hips')

# The face is a shaped ring mesh: narrow chin, squared jaw, broad cheekbones.
loft('Face and skull', [(0,0,1.741,.059,.066),(0,-.014,1.768,.108,.093),
    (0,-.013,1.814,.155,.117),(0,-.006,1.875,.179,.14),(0,.002,1.943,.184,.153),
    (0,.008,2.015,.185,.145),(0,.015,2.075,.158,.131),(0,.018,2.115,.112,.096),
    (0,.02,2.133,.018,.02)], SKIN_LIGHT, lambda p:{'Head':1}, 32)
for s in [-1,1]:
    ellipsoid('Ear', (s*.183,.003,1.933), (.036,.026,.059), SKIN, 'Head')
    ellipsoid('Ear inner', (s*.207,-.015,1.935), (.013,.011,.034), SKIN_SHADOW, 'Head', 16,10)
    # Slightly narrowed eyes and sharply tilted brows convey the intro's smirk.
    ellipsoid('Eye socket', (s*.079,-.133,1.965), (.06,.018,.034), SKIN, 'Head')
    ellipsoid('Eye white', (s*.078,-.149,1.962), (.047,.014,.025), WHITE, 'Head')
    ellipsoid('Hazel iris', (s*.072,-.164,1.964), (.018,.006,.020), IRIS, 'Head', 18,12)
    ellipsoid('Pupil', (s*.072,-.171,1.964), (.009,.004,.015), DARK, 'Head', 16,10)
    ellipsoid('Eye catchlight', (s*.068-.004,-.175,1.973), (.004,.002,.004), WHITE, 'Head', 10,6)
    stroke('Upper eyelid', [(s*.034,-.158,1.967),(s*.072,-.173,1.986),(s*.12,-.144,1.982)], .0045, DARK)
    stroke('Confident eyebrow', [(s*.029,-.15,2.006),(s*.078,-.155,2.03),(s*.131,-.128,2.024)], .012, HAIR)
    ellipsoid('Sideburn', (s*.17,-.008,1.984), (.014,.031,.065), HAIR)

mesh_obj('Sculpted nose', [(-.022,-.145,1.988),(.022,-.145,1.988),(-.025,-.186,1.918),
    (.025,-.186,1.918),(-.023,-.173,1.9),(.023,-.173,1.9),(0,-.213,1.917)],
    [(0,1,3,6,2),(2,6,4),(6,3,5,4),(0,2,4),(1,5,3)], SKIN_LIGHT)
for s in [-1,1]:
    ellipsoid('Nostril', (s*.018,-.182,1.904), (.006,.003,.004), SKIN_SHADOW, 'Head', 10,6)
stroke('Crooked smile', [(-.07,-.124,1.855),(-.027,-.144,1.846),(.023,-.147,1.851),(.077,-.126,1.878)], .0042, SKIN_SHADOW)
stroke('Lower lip', [(-.032,-.138,1.837),(.009,-.145,1.835),(.045,-.132,1.849)], .0038, SKIN)

# Hair cap plus swept, tapered locks. No stacked spheres on the visible crown.
vertices, faces = [], []
for row in range(9):
    phi = .02 + row/8*1.72
    for j in range(32):
        theta=2*math.pi*j/32
        vertices.append((.198*math.sin(phi)*math.cos(theta), .02+.155*math.sin(phi)*math.sin(theta), 2.023+.14*math.cos(phi)))
for row in range(8):
    for j in range(32):
        a=row*32+j; b=row*32+(j+1)%32
        faces.append((a,b,b+32,a+32))
mesh_obj('Hair cap', vertices, faces, HAIR)

def lock(name, points, width, thickness, mat=HAIR):
    pts=[Vector(p) for p in points]
    verts, faces=[],[]
    for i,center in enumerate(pts):
        tangent=(pts[min(i+1,len(pts)-1)]-pts[max(0,i-1)]).normalized()
        axis=tangent.cross(Vector((0,1,0))).normalized()
        if axis.length < .1: axis=Vector((1,0,0))
        normal=tangent.cross(axis).normalized()
        taper=[.72,1,.64,.025][min(i,3)]
        for j in range(8):
            a=j*math.pi/4
            verts.append(center+axis*math.cos(a)*width*taper+normal*math.sin(a)*thickness*taper)
    for i in range(len(pts)-1):
        for j in range(8):
            a=i*8+j; b=i*8+(j+1)%8
            faces.append((a,b,b+8,a+8))
    faces.extend([tuple(reversed(range(8))),tuple(range((len(pts)-1)*8,len(pts)*8))])
    mesh_obj(name,verts,faces,mat)

# Broad overlapping wedges sweep sideways; irregular lengths avoid a comb of
# identical dangling locks. The silhouette stays readable from the game camera.
for index, (x, tip_z) in enumerate([(-.16,2.057),(-.098,2.02),(-.025,2.052),(.05,2.04),(.112,2.08)]):
    lock('Swept fringe', [(x+.023,.001,2.103),(x+.013,-.076,2.157),
        (x-.04,-.137,2.121),(x-.079,-.151,tip_z)], .073,.029)
for index, (x,y,z) in enumerate([(-.14,.016,2.209),(-.06,.025,2.248),(.021,.01,2.254),
        (.096,.046,2.217),(-.101,.10,2.235),(.012,.12,2.229),(.116,.105,2.19)]):
    lock('Tousled crown',[(x,y,2.10),(x-.022,y,2.16),
        (x-.04,y+.01,z-.025),(x-.091,y+.044,z)], .068,.044)
for s in [-1,1]:
    for index in range(4):
        z=2.03-index*.035
        lock('Side and nape hair',[(s*.145,.055,z),(s*.191,.074,z+.033),
            (s*.208,.087,z+.018),(s*(.24-index*.012),.12,z+.02)], .038,.025)

# Apply non-skin detail modifiers, then merge by material into one skinned mesh.
bpy.ops.object.select_all(action='DESELECT')
for obj in parts:
    bpy.context.view_layer.objects.active=obj
    for mod in list(obj.modifiers):
        if mod.type != 'ARMATURE': bpy.ops.object.modifier_apply(modifier=mod.name)
    obj.select_set(True)
bpy.context.view_layer.objects.active=parts[0]
bpy.ops.object.join()
body=bpy.context.object
body.name='IntroCookBody'
for mod in list(body.modifiers):
    if mod.type == 'ARMATURE' and mod != body.modifiers[0]: body.modifiers.remove(mod)
body.select_set(False)

def set_action(obj, action):
    obj.animation_data_create()
    obj.animation_data.action=action
    if action.slots:
        obj.animation_data.action_slot=action.slots[0]

# Reconstruct local rotations from source world-space rest/pose deltas. New
# limb lengths stay authored, while the existing motion timing is retained.
ordered=sorted(rig.pose.bones, key=lambda b: len(b.parent_recursive))
rest_q={b.name:b.matrix_local.to_quaternion() for b in rig.data.bones}
source_rest={b.name:b.matrix_local.to_quaternion() for b in source.data.bones}
target_actions={}
scene=bpy.context.scene
scene.render.fps=24
for name in ['Walking','Running','RunFast','Triple_Combo_Attack','Dead','Skill_01']:
    original=source_actions[name]
    set_action(source, original)
    action=bpy.data.actions.new(name+'_Cook')
    set_action(rig, action)
    start,end=original.frame_range
    frame_count=max(2, round(end-start)+1)
    base_height=None
    for frame in range(frame_count+1):
        sample=start+(end-start)*frame/frame_count
        scene.frame_set(int(sample),subframe=sample-int(sample))
        source_height=(source.matrix_world @ source.pose.bones['Hips'].matrix).translation.z
        if base_height is None: base_height=source_height
        for pb in ordered:
            pb.rotation_mode='QUATERNION'
            pb.location=(0,0,0)
            pb.scale=(1,1,1)
            source_pb=source.pose.bones.get(pb.name)
            if source_pb is not None:
                delta=source_pb.matrix.to_quaternion() @ source_rest[pb.name].inverted()
                wanted=delta @ rest_q[pb.name]
                if pb.parent:
                    relative=pb.parent.bone.matrix_local.inverted() @ pb.bone.matrix_local
                    basis=(pb.parent.matrix @ relative).to_quaternion()
                else:
                    basis=rest_q[pb.name]
                pb.rotation_quaternion=basis.inverted() @ wanted
                if pb.name=='Hips':
                    pb.location=pb.bone.matrix_local.to_3x3().inverted() @ Vector((0,0,(source_height-base_height)*.75))
            bpy.context.view_layer.update()
            pb.keyframe_insert('rotation_quaternion',frame=frame)
            pb.keyframe_insert('location',frame=frame)
            pb.keyframe_insert('scale',frame=frame)
    target_actions[name]=action

# A calm breathing loop and a short recoil are authored for states absent from
# the source GLB. Keep their names explicit for the existing gameplay mapping.
for name, length in [('Idle',48),('Hurt',12)]:
    action=bpy.data.actions.new(name+'_Cook'); set_action(rig,action)
    for frame in range(length+1):
        for pb in rig.pose.bones:
            pb.location=(0,0,0); pb.rotation_mode='QUATERNION'; pb.rotation_quaternion=Quaternion(); pb.scale=(1,1,1)
        wave=math.sin(frame/length*math.tau) if name=='Idle' else math.sin(frame/length*math.pi)
        rig.pose.bones['Spine'].rotation_quaternion=Quaternion((1,0,0),wave*(.018 if name=='Idle' else -.16))
        rig.pose.bones['Head'].rotation_quaternion=Quaternion((0,1,0),wave*.018)
        for side,sign in [('Left',1),('Right',-1)]:
            rig.pose.bones[side+'Arm'].rotation_quaternion=Quaternion((0,0,1),sign*(.07+wave*.015))
        for pb in rig.pose.bones:
            pb.keyframe_insert('rotation_quaternion',frame=frame)
            pb.keyframe_insert('location',frame=frame)
            pb.keyframe_insert('scale',frame=frame)
    target_actions[name]=action

# Source assets never ship with the new cook.
bpy.data.objects.remove(source,do_unlink=True)
for a in source_actions.values():
    bpy.data.actions.remove(a)
rig.animation_data.action=None
for name,action in target_actions.items():
    action.name=name
    track=rig.animation_data.nla_tracks.new(); track.name=name
    strip=track.strips.new(name,0,action)
    strip.action_slot=action.slots[0]
    track.mute=True
    action.use_fake_user=True
for pb in rig.pose.bones:
    pb.location=(0,0,0); pb.rotation_quaternion=Quaternion(); pb.scale=(1,1,1)
bpy.context.view_layer.update()
bpy.ops.object.select_all(action='DESELECT')
rig.select_set(True); body.select_set(True); bpy.context.view_layer.objects.active=rig
bpy.ops.export_scene.gltf(filepath=str(OUT/'intro_cook.glb'),export_format='GLB',use_selection=True,
    export_animations=True,export_animation_mode='NLA_TRACKS',export_nla_strips=True,
    export_force_sampling=True,export_frame_range=False,export_anim_slide_to_zero=True,
    export_materials='EXPORT',export_yup=True)

# A reusable lit inspection scene is kept in the .blend, outside the GLB.
floor_mat=material('Preview ground',(.15,.17,.19))
bpy.ops.mesh.primitive_plane_add(size=200)
floor=bpy.context.object; floor.name='PreviewGround'; floor.data.materials.append(floor_mat)
world=bpy.data.worlds.new('PreviewWorld'); world.use_nodes=True
world.node_tree.nodes['Background'].inputs[0].default_value=(.32,.37,.45,1)
world.node_tree.nodes['Background'].inputs[1].default_value=.4
scene.world=world
for name,loc,power,size in [('Key',(-3,-4,5),550,4),('Fill',(4,-2,3),300,3),('Rim',(1,3,4),650,3)]:
    light=bpy.data.lights.new(name,'AREA'); light.energy=power; light.shape='DISK'; light.size=size
    obj=bpy.data.objects.new(name,light); bpy.context.collection.objects.link(obj); obj.location=loc
    obj.rotation_euler=(Vector((0,0,1.2))-obj.location).to_track_quat('-Z','Y').to_euler()
camera_data=bpy.data.cameras.new('PreviewCamera'); camera=bpy.data.objects.new('PreviewCamera',camera_data)
bpy.context.collection.objects.link(camera)
camera.location=(3,-6,2.8); camera.rotation_euler=(Vector((0,0,1.14))-camera.location).to_track_quat('-Z','Y').to_euler()
camera_data.type='ORTHO'; camera_data.ortho_scale=2.7
scene.camera=camera
scene.render.engine='CYCLES'; scene.cycles.samples=24
scene.render.resolution_x=900; scene.render.resolution_y=1000; scene.render.resolution_percentage=100
scene.view_settings.view_transform='AgX'
scene.render.image_settings.file_format='PNG'
set_action(rig,target_actions['Idle']); scene.frame_set(0)
scene.frame_start=0; scene.frame_end=48
bpy.ops.wm.save_as_mainfile(filepath=str(SOURCE/'intro_cook.blend'))
for name,frame in [('Idle',0),('Walking',7),('Triple_Combo_Attack',24)]:
    set_action(rig,target_actions[name]); scene.frame_set(frame)
    scene.render.filepath=str(REVIEW/(name+'.png'))
    bpy.ops.render.render(write_still=True)
report={'vertices':len(body.data.vertices),'polygons':len(body.data.polygons),
    'bones':len(rig.data.bones),'animations':list(target_actions),
    'reference':'res://assets/story/intro/08_omelette_single.png',
    'motion_source':'res://assets/models/player/clockwork_balloonkeep.glb'}
(OUT/'build_manifest.json').write_text(json.dumps(report,indent=2)+'\n',encoding='utf-8')
print('INTRO_COOK_BUILD',json.dumps(report))
