305 lines
12 KiB
Python
305 lines
12 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
|
|
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)
|