Files
Pr3tz/knot_animation/handler.py
T
2026-05-31 17:35:06 -04:00

226 lines
8.7 KiB
Python

"""
handler.py
----------
Frame-change handler and timing utilities.
`knot_frame_handler` is the heart of the animation system. It fires every
frame (registered to `bpy.app.handlers.frame_change_post`) and:
1. Determines which KnotItem is active based on `frames_per_knot * cycle_rate`.
2. Optionally interpolates geometry and material into the next knot during a
transition window.
3. Calls `geometry._make_torus_knot(config, ...)` with explicit resolution /
bevel_resolution / knot_scale values (no bpy.context reads inside the
geometry function).
4. Drives the `_KnotBlendMix.Fac` node on the pre-built blend material to
cross-fade between two shaders during transitions.
"""
from __future__ import annotations
import math
import bpy
from bpy.app.handlers import persistent
from .constants import KNOT_OBJ_NAME
from .geometry import _make_torus_knot
from .materials import get_effective_material
# ---------------------------------------------------------------------------
# Playlist duration helper (also used by operators and UI)
# ---------------------------------------------------------------------------
def compute_playlist_duration(scene) -> int:
"""Return the total number of frames for one full pass through the playlist.
Sums each knot's effective duration (``frames_per_knot * cycle_rate``,
minimum 1 frame per knot). Returns 0 if the playlist is empty.
"""
glob = scene.knot_globals
return sum(
max(1, int(glob.frames_per_knot * scene.knot_list[k].cycle_rate))
for k in range(len(scene.knot_list))
)
# ---------------------------------------------------------------------------
# Easing functions
# ---------------------------------------------------------------------------
def _apply_easing(t: float, mode: str) -> float:
if mode == 'LINEAR':
return t
if mode == 'QUAD_IN_OUT':
return 2.0 * t * t if t < 0.5 else -1.0 + (4.0 - 2.0 * t) * t
if mode == 'SMOOTHSTEP':
return t * t * (3.0 - 2.0 * t)
return t
# ---------------------------------------------------------------------------
# Frame-change handler
# ---------------------------------------------------------------------------
@persistent
def knot_frame_handler(scene, depsgraph=None) -> None:
"""Called after every frame change; rebuilds the knot for the new frame."""
if not hasattr(scene, "knot_list") or len(scene.knot_list) == 0:
return
glob = scene.knot_globals
f = scene.frame_current
# Apply global speed and phase offset to get an effective frame counter.
effective_f = f * glob.global_speed + glob.animation_phase
# Reactivity scales all per-knot animation rate properties.
reactivity = glob.reactivity_factor
# Determine which knot slot is active based on per-knot cycle rate.
total_knots = len(scene.knot_list)
loop_len = compute_playlist_duration(scene)
if loop_len < 1:
loop_len = 1
ef_int = int(effective_f) % loop_len
idx = 0
frames_in = 0
effective_fpk = glob.frames_per_knot
accumulated = 0
for k in range(total_knots):
fpk = max(1, int(glob.frames_per_knot * scene.knot_list[k].cycle_rate))
if ef_int < accumulated + fpk:
idx = k
frames_in = ef_int - accumulated
effective_fpk = fpk
break
accumulated += fpk
else:
# Fallback: clamp to last slot (should never happen after modulo wrap)
idx = total_knots - 1
effective_fpk = max(1, int(glob.frames_per_knot * scene.knot_list[idx].cycle_rate))
frames_in = 0
item = scene.knot_list[idx]
frames_left = effective_fpk - frames_in
# ── Transition factor ────────────────────────────────────────────────────
transition_active = False
t = 0.0
if item.transition_frames > 0 and frames_left <= item.transition_frames:
transition_active = True
t_raw = 1.0 - (frames_left / float(item.transition_frames))
t = _apply_easing(max(0.0, min(1.0, t_raw)), item.transition_easing)
config = item.to_dict()
next_idx = (idx + 1) % total_knots
next_item = scene.knot_list[next_idx]
if transition_active:
next_config = next_item.to_dict()
# Keys managed explicitly below — exclude from generic interpolation.
_ANIMATED_KEYS = {
"torus_sP", "torus_rP", "torus_h", "_scale_override",
"spin_phase_rate", "rev_phase_rate", "height_rate",
"scale_rate", "scale_amplitude",
}
# Interpolate all standard float properties between current and next knot.
for key in list(config):
if key in _ANIMATED_KEYS:
continue
val_a = config[key]
val_b = next_config.get(key, val_a)
if isinstance(val_a, float) and isinstance(val_b, float):
config[key] = val_a + (val_b - val_a) * t
elif t >= 0.5:
config[key] = val_b
# Animated spin phase
sP_a = item.torus_sP + effective_f * item.spin_phase_rate * reactivity
sP_b = next_item.torus_sP + effective_f * next_item.spin_phase_rate * reactivity
config["torus_sP"] = sP_a + (sP_b - sP_a) * t
# Animated revolution phase
rP_a = item.torus_rP + effective_f * item.rev_phase_rate * reactivity
rP_b = next_item.torus_rP + effective_f * next_item.rev_phase_rate * reactivity
config["torus_rP"] = rP_a + (rP_b - rP_a) * t
# Animated height pulse
h_a = item.torus_h + (
math.sin(effective_f * item.height_rate * reactivity) * abs(item.height_rate) * 5.0
if item.height_rate != 0.0 else 0.0
)
h_b = next_item.torus_h + (
math.sin(effective_f * next_item.height_rate * reactivity) * abs(next_item.height_rate) * 5.0
if next_item.height_rate != 0.0 else 0.0
)
config["torus_h"] = h_a + (h_b - h_a) * t
# Animated scale oscillation
sc_a = (
1.0 + item.scale_amplitude * math.sin(effective_f * item.scale_rate * reactivity)
if item.scale_amplitude > 0.0 and item.scale_rate != 0.0 else None
)
sc_b = (
1.0 + next_item.scale_amplitude * math.sin(effective_f * next_item.scale_rate * reactivity)
if next_item.scale_amplitude > 0.0 and next_item.scale_rate != 0.0 else None
)
if sc_a is not None or sc_b is not None:
sa = sc_a if sc_a is not None else 1.0
sb = sc_b if sc_b is not None else 1.0
config["_scale_override"] = sa + (sb - sa) * t
else:
# Apply animated rates with reactivity (no transition)
if item.spin_phase_rate != 0.0:
config["torus_sP"] += effective_f * item.spin_phase_rate * reactivity
if item.rev_phase_rate != 0.0:
config["torus_rP"] += effective_f * item.rev_phase_rate * reactivity
if item.height_rate != 0.0:
config["torus_h"] += (
math.sin(effective_f * item.height_rate * reactivity) * abs(item.height_rate) * 5.0
)
if item.scale_amplitude > 0.0 and item.scale_rate != 0.0:
config["_scale_override"] = (
1.0 + item.scale_amplitude * math.sin(effective_f * item.scale_rate * reactivity)
)
# ── Cross-material blend ─────────────────────────────────────────────────
cross_material = False
if transition_active:
mat_a = get_effective_material(item)
mat_b = get_effective_material(next_item)
cross_material = mat_a != mat_b
if cross_material:
config["_skip_material"] = True
# Resolve globals once and pass explicitly — no bpy.context inside geometry
_make_torus_knot(
config,
resolution=glob.resolution,
bevel_resolution=glob.bevel_resolution,
knot_scale=glob.knot_scale,
scene=scene,
)
if cross_material:
blend_name = f"KnotBlend_Item_{idx}"
blend_mat = bpy.data.materials.get(blend_name)
if blend_mat:
mix_node = blend_mat.node_tree.nodes.get("_KnotBlendMix")
if mix_node:
mix_node.inputs["Fac"].default_value = float(t)
blend_obj = bpy.data.objects.get(KNOT_OBJ_NAME)
if blend_obj:
if len(blend_obj.data.materials) == 0:
blend_obj.data.materials.append(blend_mat)
else:
blend_obj.data.materials[0] = blend_mat