339 lines
13 KiB
Python
339 lines
13 KiB
Python
"""
|
|
operators.py
|
|
------------
|
|
All KNOT_OT_* operator classes for knot_animation.
|
|
|
|
P3 fix — module-level randomisation helpers
|
|
--------------------------------------------
|
|
The four helpers that were previously defined as nested closures inside
|
|
``KNOT_OT_GenerateRandom.execute()`` are now module-level functions. They
|
|
take ``gen`` (the KnotGeneratorSettings) explicitly, making them independently
|
|
callable and testable.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import random
|
|
|
|
import bpy
|
|
|
|
from .constants import KNOT_CONFIGS, SHADER_BLEND_MAT_NAME
|
|
from .materials import (
|
|
SHADER_IDS,
|
|
prebuild_playlist_blend_materials,
|
|
prewarm_materials_and_blends,
|
|
_ensure_item_preset_material,
|
|
)
|
|
from .geometry import _make_torus_knot
|
|
from .handler import compute_playlist_duration
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Module-level randomisation helpers (P3)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _rand_apply_int(gen, item, prop: str) -> None:
|
|
"""Set item.{prop} to a random int in [gen.min_{prop}, gen.max_{prop}]
|
|
if gen.r_{prop} is True."""
|
|
if getattr(gen, f"r_{prop}"):
|
|
setattr(item, prop, random.randint(
|
|
getattr(gen, f"min_{prop}"),
|
|
getattr(gen, f"max_{prop}"),
|
|
))
|
|
|
|
|
|
def _rand_apply_float(gen, item, prop: str) -> None:
|
|
"""Set item.{prop} to a random float in [gen.min_{prop}, gen.max_{prop}]
|
|
if gen.r_{prop} is True."""
|
|
if getattr(gen, f"r_{prop}"):
|
|
setattr(item, prop, random.uniform(
|
|
getattr(gen, f"min_{prop}"),
|
|
getattr(gen, f"max_{prop}"),
|
|
))
|
|
|
|
|
|
def _rand_apply_bool(gen, item, prop: str) -> None:
|
|
"""Set item.{prop} to True/False with probability gen.prob_{prop}
|
|
if gen.r_{prop} is True."""
|
|
if getattr(gen, f"r_{prop}"):
|
|
prob = getattr(gen, f"prob_{prop}", 0.5)
|
|
setattr(item, prop, random.random() < prob)
|
|
|
|
|
|
def _rand_apply_choice(gen, item, prop: str, choices: list) -> None:
|
|
"""Set item.{prop} to a random element of choices if gen.r_{prop} is True."""
|
|
if getattr(gen, f"r_{prop}"):
|
|
setattr(item, prop, random.choice(choices))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Operators
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class KNOT_OT_Add(bpy.types.Operator):
|
|
bl_idname = "knot.add_item"
|
|
bl_label = "Add Knot"
|
|
|
|
def execute(self, context):
|
|
item = context.scene.knot_list.add()
|
|
item.uid = f"knot_{random.randint(100000, 999999)}"
|
|
item.name = f"Knot {len(context.scene.knot_list)}"
|
|
item.material_mode = 'PRESET'
|
|
context.scene.knot_list_index = len(context.scene.knot_list) - 1
|
|
prebuild_playlist_blend_materials(context.scene)
|
|
return {'FINISHED'}
|
|
|
|
|
|
class KNOT_OT_Remove(bpy.types.Operator):
|
|
bl_idname = "knot.remove_item"
|
|
bl_label = "Remove Knot"
|
|
|
|
def execute(self, context):
|
|
idx = context.scene.knot_list_index
|
|
if 0 <= idx < len(context.scene.knot_list):
|
|
context.scene.knot_list.remove(idx)
|
|
if idx > 0:
|
|
context.scene.knot_list_index = idx - 1
|
|
prebuild_playlist_blend_materials(context.scene)
|
|
return {'FINISHED'}
|
|
|
|
|
|
class KNOT_OT_Move(bpy.types.Operator):
|
|
bl_idname = "knot.move_item"
|
|
bl_label = "Move Knot"
|
|
direction: bpy.props.EnumProperty(items=[('UP', 'Up', ''), ('DOWN', 'Down', '')])
|
|
|
|
def execute(self, context):
|
|
idx = context.scene.knot_list_index
|
|
new_idx = idx - 1 if self.direction == 'UP' else idx + 1
|
|
if 0 <= new_idx < len(context.scene.knot_list):
|
|
context.scene.knot_list.move(idx, new_idx)
|
|
context.scene.knot_list_index = new_idx
|
|
prebuild_playlist_blend_materials(context.scene)
|
|
return {'FINISHED'}
|
|
|
|
|
|
class KNOT_OT_Populate(bpy.types.Operator):
|
|
bl_idname = "knot.populate"
|
|
bl_label = "Populate Default List"
|
|
|
|
def execute(self, context):
|
|
context.scene.knot_list.clear()
|
|
for i, config in enumerate(KNOT_CONFIGS):
|
|
item = context.scene.knot_list.add()
|
|
item.uid = f"knot_{random.randint(100000, 999999)}"
|
|
item.name = config.get("name", f"Knot {i+1}")
|
|
item.material_mode = 'PRESET'
|
|
for k, v in config.items():
|
|
if k == "name": continue
|
|
if hasattr(item, k):
|
|
setattr(item, k, v)
|
|
context.scene.knot_list_index = 0
|
|
prewarm_materials_and_blends(context.scene)
|
|
return {'FINISHED'}
|
|
|
|
|
|
class KNOT_OT_BakePreview(bpy.types.Operator):
|
|
bl_idname = "knot.bake_preview"
|
|
bl_label = "Update Preview"
|
|
|
|
def execute(self, context):
|
|
idx = context.scene.knot_list_index
|
|
if 0 <= idx < len(context.scene.knot_list):
|
|
config = context.scene.knot_list[idx].to_dict()
|
|
glob = context.scene.knot_globals
|
|
_make_torus_knot(
|
|
config,
|
|
resolution=glob.resolution,
|
|
bevel_resolution=glob.bevel_resolution,
|
|
knot_scale=glob.knot_scale,
|
|
scene=context.scene,
|
|
)
|
|
return {'FINISHED'}
|
|
|
|
|
|
class KNOT_OT_SyncGeneratorMaterials(bpy.types.Operator):
|
|
bl_idname = "knot.sync_generator_materials"
|
|
bl_label = "Sync Materials & Presets"
|
|
bl_description = "Reload all available presets and project materials into the generator allowed list"
|
|
|
|
def execute(self, context):
|
|
scene = context.scene
|
|
gen = scene.knot_generator
|
|
|
|
# Preserve previously enabled states
|
|
enabled_presets = {}
|
|
enabled_mats = {}
|
|
for entry in gen.allowed_materials:
|
|
if entry.is_preset:
|
|
enabled_presets[entry.preset_id] = entry.enabled
|
|
elif entry.material:
|
|
enabled_mats[entry.material.name] = entry.enabled
|
|
|
|
gen.allowed_materials.clear()
|
|
|
|
# 1. All shader presets
|
|
for sid in SHADER_IDS:
|
|
entry = gen.allowed_materials.add()
|
|
entry.name = sid.replace('_', ' ').title()
|
|
entry.preset_id = sid
|
|
entry.is_preset = True
|
|
entry.enabled = enabled_presets.get(sid, True)
|
|
|
|
# 2. All project materials (excluding generated ones)
|
|
for mat in bpy.data.materials:
|
|
name = mat.name
|
|
if (name.startswith("KnotItem_Preset_") or name.startswith("KnotBlend_")
|
|
or name == SHADER_BLEND_MAT_NAME):
|
|
continue
|
|
entry = gen.allowed_materials.add()
|
|
entry.name = name
|
|
entry.material = mat
|
|
entry.is_preset = False
|
|
entry.enabled = enabled_mats.get(name, True)
|
|
|
|
return {'FINISHED'}
|
|
|
|
|
|
class KNOT_OT_GenerateRandom(bpy.types.Operator):
|
|
bl_idname = "knot.generate_random"
|
|
bl_label = "Generate Playlist"
|
|
|
|
def execute(self, context):
|
|
scene = context.scene
|
|
gen = scene.knot_generator
|
|
base = gen.base_knot
|
|
|
|
scene.knot_list.clear()
|
|
|
|
for i in range(gen.num_knots):
|
|
item = scene.knot_list.add()
|
|
item.name = f"Random Knot {i+1}"
|
|
|
|
# Copy base defaults
|
|
for k, v in base.to_dict().items():
|
|
if hasattr(item, k):
|
|
setattr(item, k, v)
|
|
|
|
# Apply randomisations using module-level helpers (P3)
|
|
if gen.r_shape_type:
|
|
_rand_apply_choice(gen, item, "shape_type", ['TORUS_KNOT', 'MOBIUS', 'LISSAJOUS', 'SPIRAL'])
|
|
|
|
_rand_apply_int(gen, item, "torus_p")
|
|
_rand_apply_int(gen, item, "torus_q")
|
|
_rand_apply_int(gen, item, "mobius_twists")
|
|
_rand_apply_int(gen, item, "liss_kx")
|
|
_rand_apply_int(gen, item, "liss_ky")
|
|
_rand_apply_int(gen, item, "liss_kz")
|
|
_rand_apply_int(gen, item, "spiral_turns")
|
|
_rand_apply_int(gen, item, "torus_u")
|
|
_rand_apply_int(gen, item, "torus_v")
|
|
_rand_apply_int(gen, item, "transition_frames")
|
|
|
|
_rand_apply_float(gen, item, "torus_R")
|
|
_rand_apply_float(gen, item, "torus_r")
|
|
_rand_apply_float(gen, item, "mobius_width")
|
|
_rand_apply_float(gen, item, "liss_amp")
|
|
_rand_apply_float(gen, item, "spiral_R")
|
|
_rand_apply_float(gen, item, "torus_eR")
|
|
_rand_apply_float(gen, item, "torus_iR")
|
|
_rand_apply_float(gen, item, "geo_extrude")
|
|
_rand_apply_float(gen, item, "geo_offset")
|
|
_rand_apply_float(gen, item, "geo_bDepth")
|
|
_rand_apply_float(gen, item, "torus_h")
|
|
_rand_apply_float(gen, item, "torus_rP")
|
|
_rand_apply_float(gen, item, "torus_sP")
|
|
_rand_apply_float(gen, item, "spin_phase_rate")
|
|
_rand_apply_float(gen, item, "rev_phase_rate")
|
|
_rand_apply_float(gen, item, "height_rate")
|
|
_rand_apply_float(gen, item, "scale_rate")
|
|
_rand_apply_float(gen, item, "scale_amplitude")
|
|
_rand_apply_float(gen, item, "cycle_rate")
|
|
|
|
if getattr(gen, "r_modulations", False):
|
|
import random
|
|
for k in [
|
|
"mod_torus_R", "mod_torus_r", "mod_torus_eR", "mod_torus_iR", "mod_torus_h",
|
|
"mod_mobius_width", "mod_liss_amp", "mod_spiral_R",
|
|
"mod_geo_extrude", "mod_geo_offset", "mod_geo_bDepth"
|
|
]:
|
|
setattr(item, k, random.uniform(-0.5, 0.5))
|
|
for k in [
|
|
"mod_torus_p", "mod_torus_q", "mod_mobius_twists",
|
|
"mod_liss_kx", "mod_liss_ky", "mod_liss_kz", "mod_spiral_turns"
|
|
]:
|
|
setattr(item, k, random.uniform(-2.0, 2.0))
|
|
|
|
_rand_apply_bool(gen, item, "multiple_links")
|
|
_rand_apply_bool(gen, item, "use_colors")
|
|
_rand_apply_bool(gen, item, "random_colors")
|
|
_rand_apply_bool(gen, item, "flip_p")
|
|
_rand_apply_bool(gen, item, "flip_q")
|
|
|
|
if gen.r_colorSet:
|
|
item.colorSet = '2' if random.random() < gen.prob_colorSet else '1'
|
|
if gen.r_mode:
|
|
item.mode = 'EXT_INT' if random.random() < gen.prob_mode else 'MAJOR_MINOR'
|
|
|
|
_rand_apply_choice(gen, item, "transition_easing",
|
|
['LINEAR', 'QUAD_IN_OUT', 'SMOOTHSTEP'])
|
|
|
|
item.uid = f"knot_{random.randint(100000, 999999)}"
|
|
|
|
if gen.r_material:
|
|
enabled_items = [am for am in gen.allowed_materials if am.enabled]
|
|
if enabled_items:
|
|
chosen = random.choice(enabled_items)
|
|
if chosen.is_preset:
|
|
item.material_mode = 'PRESET'
|
|
item.shader_id = chosen.preset_id
|
|
if getattr(gen, "r_preset_params", True):
|
|
item.preset_color = (random.random(), random.random(), random.random())
|
|
item.preset_roughness = random.uniform(0.0, 1.0)
|
|
item.preset_metallic = random.uniform(0.0, 1.0)
|
|
item.preset_emission_strength = random.uniform(0.5, 5.0)
|
|
else:
|
|
item.material_mode = 'PROJECT'
|
|
item.project_material = chosen.material
|
|
else:
|
|
item.material_mode = 'PRESET'
|
|
item.shader_id = 'GLOSS_BLUE'
|
|
|
|
scene.knot_list_index = 0
|
|
prewarm_materials_and_blends(scene)
|
|
return {'FINISHED'}
|
|
|
|
|
|
class KNOT_OT_FitTimeline(bpy.types.Operator):
|
|
"""Set frame_end so the full playlist fits exactly once in the timeline."""
|
|
bl_idname = "knot.fit_timeline"
|
|
bl_label = "Fit Timeline to Playlist"
|
|
bl_description = "Extend Total Frames so every knot plays through once"
|
|
|
|
def execute(self, context):
|
|
scene = context.scene
|
|
glob = scene.knot_globals
|
|
total = compute_playlist_duration(scene) or glob.frames_per_knot
|
|
scene.frame_end = max(scene.frame_start, total)
|
|
return {'FINISHED'}
|
|
|
|
|
|
class KNOT_OT_FitPlaylist(bpy.types.Operator):
|
|
"""Adjust frames_per_knot so the playlist fills the current frame_end exactly."""
|
|
bl_idname = "knot.fit_playlist"
|
|
bl_label = "Fit Playlist to Timeline"
|
|
bl_description = "Shrink/grow Frames per Knot so all knots fill the current Total Frames"
|
|
|
|
def execute(self, context):
|
|
scene = context.scene
|
|
glob = scene.knot_globals
|
|
n = len(scene.knot_list)
|
|
if n < 1:
|
|
self.report({'WARNING'}, "Playlist is empty")
|
|
return {'CANCELLED'}
|
|
avg_rate = sum(scene.knot_list[k].cycle_rate for k in range(n)) / n
|
|
avg_rate = max(0.01, avg_rate)
|
|
duration = scene.frame_end - scene.frame_start + 1
|
|
glob.frames_per_knot = max(1, int(duration / (n * avg_rate)))
|
|
return {'FINISHED'}
|