# ============================================================
# Blender Script v2: T-cell Anergy Animation (Cycles, cinematic)
# ============================================================
# USAGE:
#   1. Blender -> Scripting tab -> paste -> Run Script
#   2. Render Properties -> confirm Engine = Cycles (script sets this
#      automatically, but device/GPU settings depend on your machine —
#      set Render Properties > Device > GPU Compute if you have one)
#   3. Scrub frames 1-320 or press Spacebar to preview (Cycles preview
#      is slower than Eevee; use viewport shading = Material Preview
#      while iterating, switch to Rendered only to check final look)
#
# STORY BEATS (by frame, 320 total @ 30fps = ~10.6s):
#   1-50     Idle: cell breathing subtly, camera slow orbital push-in
#   50-110   Peptide-MHC flies in toward docking receptor
#   110-135  Docking / binding moment — camera snap-zooms to interface
#   135-230  Cascading shutdown: dimming wave radiates from docking
#            receptor outward across all receptors + membrane SSS
#   230-290  Cell shrinks into dormant state, camera pulls back to
#            match the ChimeraX hand-off framing (see notes at bottom)
#   290-320  Hold on final dormant wide shot
# ============================================================

import bpy
import math
import random
import mathutils

# ------------------------------------------------------------
# CONFIG
# ------------------------------------------------------------
CELL_RADIUS = 2.0
N_RECEPTORS = 24
ACTIVE_COLOR = (0.08, 0.85, 0.55, 1.0)
ANERGIC_COLOR = (0.30, 0.32, 0.38, 1.0)
PEPTIDE_COLOR = (0.95, 0.5, 0.1, 1.0)
SHRINK_FACTOR = 0.72
CASCADE_SPREAD_FRAMES = 70   # how long the shutdown wave takes to cross all receptors

# ------------------------------------------------------------
# RENDER SETTINGS (Cycles, quality-leaning since render time is available)
# ------------------------------------------------------------
scene = bpy.context.scene
scene.render.engine = 'CYCLES'
scene.cycles.samples = 256
scene.cycles.use_denoising = True
scene.cycles.use_adaptive_sampling = True
scene.cycles.adaptive_threshold = 0.01
scene.cycles.max_bounces = 8
scene.render.resolution_x = 1920
scene.render.resolution_y = 1080
scene.render.fps = 30
scene.frame_start = 1
scene.frame_end = 320
try:
    scene.cycles.device = 'GPU'
except Exception:
    pass

# ------------------------------------------------------------
# CLEAN SCENE
# ------------------------------------------------------------
bpy.ops.object.select_all(action='SELECT')
bpy.ops.object.delete(use_global=False)
for block in list(bpy.data.materials):
    bpy.data.materials.remove(block)

# ------------------------------------------------------------
# BUILD: T-cell body with real SSS membrane
# ------------------------------------------------------------
bpy.ops.mesh.primitive_uv_sphere_add(radius=CELL_RADIUS, location=(0, 0, 0), segments=96, ring_count=48)
cell = bpy.context.active_object
cell.name = "TCell_Body"
cell.modifiers.new(name="Subdiv", type='SUBSURF').levels = 2
cell.modifiers["Subdiv"].render_levels = 3

mat_cell = bpy.data.materials.new(name="TCell_Membrane")
mat_cell.use_nodes = True
nodes = mat_cell.node_tree.nodes
links = mat_cell.node_tree.links
nodes.clear()

output = nodes.new("ShaderNodeOutputMaterial")
bsdf = nodes.new("ShaderNodeBsdfPrincipled")
bsdf.inputs["Base Color"].default_value = ACTIVE_COLOR
bsdf.inputs["Roughness"].default_value = 0.25

# Subsurface scattering — Blender 4.x uses "Subsurface Weight" / "Subsurface Radius"
sss_weight_key = "Subsurface Weight" if "Subsurface Weight" in bsdf.inputs else "Subsurface"
bsdf.inputs[sss_weight_key].default_value = 0.6
if "Subsurface Radius" in bsdf.inputs:
    bsdf.inputs["Subsurface Radius"].default_value = (0.4, 0.15, 0.1)

links.new(bsdf.outputs["BSDF"], output.inputs["Surface"])
cell.data.materials.append(mat_cell)

# ------------------------------------------------------------
# BUILD: Membrane receptors (each with OWN material so we can
# cascade-shutdown them individually)
# ------------------------------------------------------------
receptors = []
receptor_mats = []
random.seed(7)

for i in range(N_RECEPTORS):
    theta = math.acos(1 - 2 * (i + 0.5) / N_RECEPTORS)
    phi = math.pi * (1 + 5**0.5) * i

    x = CELL_RADIUS * math.sin(theta) * math.cos(phi)
    y = CELL_RADIUS * math.sin(theta) * math.sin(phi)
    z = CELL_RADIUS * math.cos(theta)

    bpy.ops.mesh.primitive_cone_add(radius1=0.12, depth=0.4, location=(x, y, z))
    receptor = bpy.context.active_object
    receptor.name = f"Receptor_{i:02d}"

    receptor.rotation_mode = 'QUATERNION'
    up = mathutils.Vector((0, 0, 1))
    target = mathutils.Vector((x, y, z)).normalized()
    receptor.rotation_quaternion = up.rotation_difference(target)

    mat = bpy.data.materials.new(name=f"Receptor_Mat_{i:02d}")
    mat.use_nodes = True
    rnodes = mat.node_tree.nodes
    rlinks = mat.node_tree.links
    rnodes.clear()
    r_out = rnodes.new("ShaderNodeOutputMaterial")
    r_bsdf = rnodes.new("ShaderNodeBsdfPrincipled")
    r_emit = rnodes.new("ShaderNodeEmission")
    r_mix = rnodes.new("ShaderNodeMixShader")

    r_bsdf.inputs["Base Color"].default_value = (0.8, 0.15, 0.25, 1.0)
    r_emit.inputs["Color"].default_value = ACTIVE_COLOR
    r_emit.inputs["Strength"].default_value = 2.5
    r_mix.inputs["Fac"].default_value = 0.5

    rlinks.new(r_bsdf.outputs["BSDF"], r_mix.inputs[1])
    rlinks.new(r_emit.outputs["Emission"], r_mix.inputs[2])
    rlinks.new(r_mix.outputs["Shader"], r_out.inputs["Surface"])

    receptor.data.materials.append(mat)
    receptors.append(receptor)
    receptor_mats.append(mat)

# Docking receptor: pick one facing +X for clean camera read
docking_index = min(range(N_RECEPTORS), key=lambda i: -receptors[i].location.x)
docking_receptor = receptors[docking_index]

# ------------------------------------------------------------
# BUILD: Peptide-MHC complex (placeholder — swap for wheat_pmhc.pdb)
#   Real structure: wheat_pmhc.pdb  (chain A/B = MHC-II alpha/beta,
#   chain P = wheat antigen 15-mer IHNVVHAIILHQQQQ). See NOTES for the
#   Molecular Nodes import + how to reuse this for soy_pmhc.pdb /
#   dairy_pmhc.pdb (identical A/B/P layout).
# ------------------------------------------------------------
dr_loc = docking_receptor.location
start_loc = (dr_loc.x * 4, dr_loc.y * 4, dr_loc.z * 4)
dock_loc = (dr_loc.x * 1.15, dr_loc.y * 1.15, dr_loc.z * 1.15)

bpy.ops.mesh.primitive_cylinder_add(radius=0.2, depth=0.6, location=start_loc)
peptide = bpy.context.active_object
peptide.name = "Peptide_MHC_Complex_PLACEHOLDER"  # swap for wheat_pmhc.pdb via Molecular Nodes (see notes)

mat_peptide = bpy.data.materials.new(name="Peptide_Mat")
mat_peptide.use_nodes = True
mat_peptide.node_tree.nodes["Principled BSDF"].inputs["Base Color"].default_value = PEPTIDE_COLOR
peptide.data.materials.append(mat_peptide)

# ------------------------------------------------------------
# ANIMATE: helper functions
# ------------------------------------------------------------
def kf_loc(obj, frame, loc):
    obj.location = loc
    obj.keyframe_insert(data_path="location", frame=frame)

def kf_scale(obj, frame, scale):
    obj.scale = (scale, scale, scale)
    obj.keyframe_insert(data_path="scale", frame=frame)

def kf_rot(obj, frame, euler):
    obj.rotation_euler = euler
    obj.keyframe_insert(data_path="rotation_euler", frame=frame)

def kf_color(node_input, frame, color):
    node_input.default_value = color
    node_input.keyframe_insert(data_path="default_value", frame=frame)

def kf_value(node_input, frame, value):
    node_input.default_value = value
    node_input.keyframe_insert(data_path="default_value", frame=frame)

def ease_all_fcurves(id_data):
    if id_data.animation_data and id_data.animation_data.action:
        for fc in id_data.animation_data.action.fcurves:
            for kp in fc.keyframe_points:
                kp.interpolation = 'BEZIER'
                kp.easing = 'EASE_IN_OUT'

# --- Peptide flight: idle offscreen -> approach -> dock ---
kf_loc(peptide, 1, start_loc)
kf_loc(peptide, 50, start_loc)
kf_loc(peptide, 110, dock_loc)
kf_loc(peptide, 320, dock_loc)

# --- Cell: subtle idle "breathing" + shrink into dormancy ---
kf_scale(cell, 1, 1.0)
kf_scale(cell, 25, 1.02)
kf_scale(cell, 50, 1.0)
kf_scale(cell, 135, 1.0)         # holds through binding
kf_scale(cell, 230, 1.0)
kf_scale(cell, 290, SHRINK_FACTOR)
kf_scale(cell, 320, SHRINK_FACTOR)

# --- Membrane SSS/base color: active -> anergic, timed with cascade ---
mem_base = mat_cell.node_tree.nodes["Principled BSDF"].inputs["Base Color"]
kf_color(mem_base, 135, ACTIVE_COLOR)
kf_color(mem_base, 230, ANERGIC_COLOR)

# --- CASCADING RECEPTOR SHUTDOWN ---
# Order receptors by distance (angular) from the docking receptor so the
# shutdown wave visibly radiates outward from the binding site.
def angular_distance(r1, r2):
    v1 = mathutils.Vector(r1.location).normalized()
    v2 = mathutils.Vector(r2.location).normalized()
    return v1.angle(v2)

ordered = sorted(range(N_RECEPTORS), key=lambda i: angular_distance(receptors[i], docking_receptor))

shutdown_start_frame = 135
for rank, idx in enumerate(ordered):
    mat = receptor_mats[idx]
    emit_color = mat.node_tree.nodes["Emission"].inputs["Color"]
    emit_strength = mat.node_tree.nodes["Emission"].inputs["Strength"]

    delay = (rank / max(1, N_RECEPTORS - 1)) * CASCADE_SPREAD_FRAMES
    f_start = int(shutdown_start_frame + delay)
    f_end = int(f_start + 20)

    kf_color(emit_color, f_start, ACTIVE_COLOR)
    kf_value(emit_strength, f_start, 2.5)
    kf_color(emit_color, f_end, ANERGIC_COLOR)
    kf_value(emit_strength, f_end, 0.15)

# ------------------------------------------------------------
# CAMERA: cinematic multi-shot move
# ------------------------------------------------------------
bpy.ops.object.camera_add(location=(7, -7, 3.5))
camera = bpy.context.active_object
camera.name = "Main_Camera"
scene.camera = camera

# Track-to constraint keeps camera aimed at cell center throughout
track = camera.constraints.new(type='TRACK_TO')
track.target = cell
track.track_axis = 'TRACK_NEGATIVE_Z'
track.up_axis = 'UP_Y'

# Shot 1: slow orbital push-in during idle (frames 1-50)
kf_loc(camera, 1, (8, -8, 4))
kf_loc(camera, 50, (6, -6, 3))

# Shot 2: continue orbit as peptide approaches (50-110)
kf_loc(camera, 110, (4.5, -4.5, 2.2))

# Shot 3: snap-zoom to binding interface (110-135)
kf_loc(camera, 135, (2.2, -2.2, 1.0))

# Shot 4: hold close during cascade, slight drift (135-230)
kf_loc(camera, 180, (2.0, -2.4, 1.2))
kf_loc(camera, 230, (2.4, -2.0, 1.4))

# Shot 5: pull back to wide reveal as cell goes dormant (230-290)
# NOTE: this final framing is chosen to match the ChimeraX hand-off —
# see notes at the bottom of this script.
kf_loc(camera, 290, (9, -9, 4.5))
kf_loc(camera, 320, (9, -9, 4.5))

ease_all_fcurves(camera)
ease_all_fcurves(peptide)
ease_all_fcurves(cell)
ease_all_fcurves(mat_cell.node_tree)
for mat in receptor_mats:
    ease_all_fcurves(mat.node_tree)

# ------------------------------------------------------------
# LIGHTING (three-point-ish, tuned for Cycles + SSS)
# ------------------------------------------------------------
bpy.ops.object.light_add(type='SUN', location=(6, -6, 8))
key = bpy.context.active_object
key.data.energy = 4.0
key.data.angle = math.radians(2)

bpy.ops.object.light_add(type='AREA', location=(-5, 4, 3))
fill = bpy.context.active_object
fill.data.energy = 300
fill.data.size = 5

bpy.ops.object.light_add(type='AREA', location=(0, 6, -2))
rim = bpy.context.active_object
rim.data.energy = 150
rim.data.size = 3

scene.world.use_nodes = True
bg = scene.world.node_tree.nodes["Background"]
bg.inputs["Color"].default_value = (0.015, 0.015, 0.02, 1.0)
bg.inputs["Strength"].default_value = 1.0

print("Scene built (Cycles). Frames 1-320 @ 30fps (~10.6s). Camera has a Track-To "
      "constraint on the cell, so all camera keyframes are position-only.")

# ============================================================
# NOTES:
#
# SWAPPING IN THE REAL WHEAT pMHC STRUCTURE  (wheat_pmhc.pdb)
#   Install the "Molecular Nodes" Blender add-on (Edit > Preferences >
#   Add-ons > search "Molecular Nodes" or install from
#   https://github.com/BradyAJohnston/MolecularNodes). It imports PDB/
#   mmCIF files directly as styled meshes (cartoon, surface, ribbons)
#   and integrates with Cycles materials. Steps:
#     1. Import wheat_pmhc.pdb via Molecular Nodes (Object > Molecular
#        Nodes > Import, or the MN panel). It contains the full pMHC-II
#        complex: chain A + B (MHC-II) with chain P (wheat peptide) in
#        the groove.
#     2. Style suggestion: cartoon for A/B, sticks/spacefill for chain P,
#        colored with PEPTIDE_COLOR so the antigen reads as the "cargo"
#        flying into the receptor. Scale/orient it to roughly match the
#        placeholder cylinder's footprint (~0.4-0.6 units across).
#     3. Delete "Peptide_MHC_Complex_PLACEHOLDER".
#     4. Rename your imported MN object to "Peptide_MHC_Complex" and
#        re-run just the animation section, OR copy the location
#        keyframes onto it manually:
#          start_loc  at frame 1 and 50   (idle, off toward +X)
#          dock_loc   at frame 110 and 320 (seated at the receptor)
#        The helper kf_loc(obj, frame, loc) does exactly this.
#   REUSING FOR OTHER ANTIGENS: soy_pmhc.pdb and dairy_pmhc.pdb have the
#   identical A/B/P chain layout, so just import the other file instead —
#   no keyframe changes needed.
#
#   BIOLOGY / CONTENT NOTE: the object flying in is a peptide-MHC-II
#   complex being presented, not a naked peptide. If a narrator or
#   caption describes this beat, "antigen-loaded pMHC engaging the T cell"
#   is accurate; "free peptide binding a receptor" is not.
#
# MATCHING THE CHIMERAX HAND-OFF
#   Your ChimeraX script's final shot ("Shot 5: Pull back for final
#   composite shot") ends on a wide, slightly elevated 3/4 view after
#   zooming out 0.5x and a slow x-turn. This Blender scene's frame-1
#   camera position (8, -8, 4) is deliberately a similar wide 3/4
#   elevated angle, so a straight cut (or a 10-15 frame cross-dissolve)
#   between the two clips reads as continuous camera motion rather than
#   a hard scene change. In your edit (DaVinci Resolve/Premiere), place
#   the ChimeraX clip's last ~15 frames overlapping the Blender clip's
#   first ~15 frames with a cross-dissolve — the zoom-out/turn direction
#   in ChimeraX and the push-in start in Blender will blend smoothly.
#   If you want an even tighter match, tell me the exact final ChimeraX
#   camera angle values once you've run it, and I'll tune this script's
#   opening camera position to line up more precisely.
#
# TUNING THE CASCADE
#   CASCADE_SPREAD_FRAMES controls how long the shutdown wave takes to
#   cross all 24 receptors (currently 70 frames / ~2.3s). Shorter =
#   more abrupt "system failure" read; longer = more gradual "fading."
#
# RENDER TIME
#   256 samples + denoising + SSS will be noticeably slower than Eevee.
#   Do a low-sample test render (scene.cycles.samples = 64) first to
#   check framing/timing, then bump back to 256+ for the final pass
#   given your multi-day runway.
# ============================================================
