""" 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 sP_b = next_item.torus_sP + effective_f * next_item.spin_phase_rate config["torus_sP"] = sP_a + (sP_b - sP_a) * t # Animated revolution phase rP_a = item.torus_rP + effective_f * item.rev_phase_rate rP_b = next_item.torus_rP + effective_f * next_item.rev_phase_rate 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) * abs(item.height_rate) * 5.0 * reactivity if item.height_rate != 0.0 else 0.0 ) h_b = next_item.torus_h + ( math.sin(effective_f * next_item.height_rate) * abs(next_item.height_rate) * 5.0 * reactivity 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 if item.rev_phase_rate != 0.0: config["torus_rP"] += effective_f * item.rev_phase_rate if item.height_rate != 0.0: config["torus_h"] += ( math.sin(effective_f * item.height_rate) * abs(item.height_rate) * 5.0 * reactivity ) 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 ) # ── Apply Attenuverter Modulations ─────────────────────────────────────── # Float mods for key in [ "torus_R", "torus_r", "torus_eR", "torus_iR", "torus_h", "mobius_width", "liss_amp", "spiral_R", "geo_extrude", "geo_offset", "geo_bDepth" ]: if f"mod_{key}" in config and config[f"mod_{key}"] != 0.0: config[key] += config[f"mod_{key}"] * reactivity # Integer mods (clamp to minimum 1 for topology safety) for key in [ "torus_p", "torus_q", "mobius_twists", "liss_kx", "liss_ky", "liss_kz", "spiral_turns" ]: if f"mod_{key}" in config and config[f"mod_{key}"] != 0.0: config[key] = max(1, config[key] + int(config[f"mod_{key}"] * 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 # ── Apply Global Settings & Overrides ──────────────────────────────────── is_render = False if depsgraph is not None and getattr(depsgraph, "mode", 'VIEWPORT') == 'RENDER': is_render = True res = glob.render_resolution if is_render else glob.preview_resolution bevel_res = glob.render_bevel_resolution if is_render else glob.preview_bevel_resolution if "geo_bDepth" in config: config["geo_bDepth"] *= glob.global_master_thickness if "preset_emission_strength" in config: config["preset_emission_strength"] *= glob.global_emission_multiplier if "preset_color" in config and glob.global_hue_shift != 0.0: import colorsys color_val = config["preset_color"] if len(color_val) == 4: r, g, b, a = color_val else: r, g, b = color_val a = 1.0 h_hsv, s, v = colorsys.rgb_to_hsv(r, g, b) h_hsv = (h_hsv + glob.global_hue_shift) % 1.0 nr, ng, nb = colorsys.hsv_to_rgb(h_hsv, s, v) if len(color_val) == 4: config["preset_color"] = (nr, ng, nb, a) else: config["preset_color"] = (nr, ng, nb) # Resolve globals once and pass explicitly — no bpy.context inside geometry _make_torus_knot( config, resolution=res, bevel_resolution=bevel_res, knot_scale=glob.knot_scale, scene=scene, turbulence=glob.global_turbulence, smooth_shading=glob.smooth_shading, ) if cross_material: blend_name = f"KnotBlend_Item_{idx}" blend_mat = bpy.data.materials.get(blend_name) blend_obj = bpy.data.objects.get(KNOT_OBJ_NAME) if blend_obj: if blend_mat: mix_node = blend_mat.node_tree.nodes.get("_KnotBlendMix") if mix_node: mix_node.inputs["Fac"].default_value = float(t) if len(blend_obj.data.materials) == 0: blend_obj.data.materials.append(blend_mat) else: blend_obj.data.materials[0] = blend_mat elif mat_a: # Fallback if blend material wasn't generated if len(blend_obj.data.materials) == 0: blend_obj.data.materials.append(mat_a) else: blend_obj.data.materials[0] = mat_a # ── Post-Geometry Object Overrides ─────────────────────────────────────── knot_obj = bpy.data.objects.get(KNOT_OBJ_NAME) if knot_obj: knot_obj.display_type = 'WIRE' if glob.viewport_wireframe else 'TEXTURED' knot_obj.rotation_euler[2] = effective_f * glob.auto_turntable_speed # ── Camera Shake ───────────────────────────────────────────────────────── cam = scene.camera if cam: shake = glob.camera_shake_amplitude if shake > 0.0: import random # Deterministic noise based on frame for stable rendering random.seed(int(f * 1000)) cam.delta_location = ( random.uniform(-shake, shake), random.uniform(-shake, shake), random.uniform(-shake, shake) ) else: cam.delta_location = (0.0, 0.0, 0.0)