""" 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" bl_description = "Add a new knot entry to the end of the playlist" 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" bl_description = "Remove the selected knot entry from the playlist" 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" bl_description = "Move the selected knot up or down in the playlist order" direction: bpy.props.EnumProperty(items=[('UP', 'Up', 'Move knot earlier in the playlist'), ('DOWN', 'Down', 'Move knot later in the playlist')]) 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" bl_description = "Clear the playlist and load the 10 built-in example knots (trefoil, cinquefoil, Möbius, Lissajous, and more)" 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" bl_description = "Immediately regenerate the viewport knot using the currently selected playlist entry's settings, without waiting for a frame change" 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.preview_resolution, bevel_resolution=glob.preview_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" bl_description = "Clear the playlist and fill it with randomly configured knots using the ranges and toggles set in the Randomise Toggles section below" 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'}