diff --git a/knot_animation/__init__.py b/knot_animation/__init__.py new file mode 100644 index 0000000..5337e62 --- /dev/null +++ b/knot_animation/__init__.py @@ -0,0 +1,205 @@ +""" +knot_animation/__init__.py +-------------------------- +Blender add-on entry-point for the AnimKnots procedural animation system. + +What this add-on does +--------------------- +1. Registers a VIEW_3D N-panel ("AnimKnots") with: + - A per-scene playlist of KnotItem entries, each specifying topology, + geometry, animation rates, transition settings, and a material preset. + - Operators for adding, removing, reordering, and randomly generating + playlist entries. + - Global playback controls (speed, phase, reactivity) that can be + keyframed or driven for audio-reactive animation. + +2. On every frame change (frame_change_post handler), procedurally rebuilds + a single reused NURBS curve object ("AnimKnot") in-place using the + parametric torus-knot equations — no bpy.ops, no edit-mode, no depsgraph + races. + +3. Interpolates geometry and cross-fades shader materials between adjacent + playlist entries during configurable transition windows. + +4. Provides a catalogue of 20 built-in shader presets (GLOSS_BLUE, NEON_GLOW, + METALLIC, GLASS, HOLOGRAM, LAVA, IRIDESCENT, …) plus support for arbitrary + project materials. + +Module layout +------------- +types.py — KnotConfig TypedDict (shared data contract) +constants.py — pure-data defaults (KNOT_CONFIGS, camera/light positions, …) +compat.py — one-shot compatibility patches (headless align_matrix fix) +materials.py — 20 shader builders + material cache + blend system +geometry.py — _make_torus_knot() (parametric NURBS, no bpy.context reads) +handler.py — @persistent frame-change handler + easing + playlist timing +properties.py — Blender PropertyGroups (KnotItem, KnotGlobalSettings, …) +operators.py — KNOT_OT_* operators +ui.py — KNOT_UL_* lists, draw_knot_properties(), KNOT_PT_Panel +scene_setup.py — setup_scene() (camera, light, world, render settings) + +How to use +---------- +Option A – Blender Text Editor: + Open knot_animation/__init__.py, click Run Script (Alt+P). + +Option B – Command line: + blender.exe --python knot_animation/__init__.py + +Option C – Persistent add-on: + Copy the knot_animation/ folder to Blender's addons directory and enable + it from Edit → Preferences → Add-ons. The panel appears under the + "AnimKnots" tab in the 3-D viewport N-panel. +""" + +if "bpy" in locals(): + import importlib + importlib.reload(compat) + importlib.reload(constants) + importlib.reload(types) + importlib.reload(materials) + importlib.reload(geometry) + importlib.reload(handler) + importlib.reload(properties) + importlib.reload(operators) + importlib.reload(ui) + importlib.reload(scene_setup) +else: + import bpy + from . import compat, constants, types, materials, geometry, handler, properties, operators, ui, scene_setup + +from .compat import apply_compat_patches +from .properties import ( + KnotAllowedMaterial, + KnotGlobalSettings, + KnotItem, + KnotGeneratorSettings, +) +from .operators import ( + KNOT_OT_Add, + KNOT_OT_Remove, + KNOT_OT_Move, + KNOT_OT_Populate, + KNOT_OT_BakePreview, + KNOT_OT_SyncGeneratorMaterials, + KNOT_OT_FitTimeline, + KNOT_OT_FitPlaylist, + KNOT_OT_GenerateRandom, +) +from .ui import ( + KNOT_UL_List, + KNOT_UL_AllowedMaterialsList, + KNOT_PT_Panel, +) +from .geometry import _remove_existing_knot +from .handler import knot_frame_handler +from .scene_setup import setup_scene +from .constants import KNOT_MAT_NAME, SHADER_BLEND_MAT_NAME + +# --------------------------------------------------------------------------- +# Add-on metadata +# --------------------------------------------------------------------------- + +bl_info = { + "name": "AnimKnots", + "author": "knot_animation project", + "version": (2, 0, 0), + "blender": (4, 0, 0), + "location": "View3D > Sidebar > AnimKnots", + "description": "Procedural torus-knot animation with playlist, transitions, and 20 shader presets", + "category": "Animation", +} + +# --------------------------------------------------------------------------- +# Registered Blender classes (order matters for PropertyGroup dependencies) +# --------------------------------------------------------------------------- + +classes = ( + KnotAllowedMaterial, + KnotGlobalSettings, + KnotItem, + KnotGeneratorSettings, + KNOT_UL_List, + KNOT_UL_AllowedMaterialsList, + KNOT_OT_SyncGeneratorMaterials, + KNOT_OT_Add, + KNOT_OT_Remove, + KNOT_OT_Move, + KNOT_OT_Populate, + KNOT_OT_BakePreview, + KNOT_OT_FitTimeline, + KNOT_OT_FitPlaylist, + KNOT_OT_GenerateRandom, + KNOT_PT_Panel, +) + + +# --------------------------------------------------------------------------- +# Lifecycle +# --------------------------------------------------------------------------- + +def register() -> None: + apply_compat_patches() + + for cls in classes: + bpy.utils.register_class(cls) + + bpy.types.Scene.knot_list = bpy.props.CollectionProperty(type=KnotItem) + bpy.types.Scene.knot_list_index = bpy.props.IntProperty(name="Index", default=0) + bpy.types.Scene.knot_generator = bpy.props.PointerProperty(type=KnotGeneratorSettings) + bpy.types.Scene.knot_globals = bpy.props.PointerProperty(type=KnotGlobalSettings) + + # Register to frame_change_post so the handler fires *after* Blender has + # evaluated the new frame, preventing the freshly-built mesh from being + # overwritten by the depsgraph update. + handlers = bpy.app.handlers.frame_change_post + for h in list(handlers): + if getattr(h, "__name__", "") == knot_frame_handler.__name__: + handlers.remove(h) + handlers.append(knot_frame_handler) + + print("[KnotScript] Handler and UI registered.") + + +def unregister() -> None: + for cls in reversed(classes): + bpy.utils.unregister_class(cls) + + del bpy.types.Scene.knot_list + del bpy.types.Scene.knot_list_index + del bpy.types.Scene.knot_generator + del bpy.types.Scene.knot_globals + + handlers = bpy.app.handlers.frame_change_post + for h in list(handlers): + if getattr(h, "__name__", "") == knot_frame_handler.__name__: + handlers.remove(h) + + _remove_existing_knot() + + # Purge all cached shader and blend materials on unregister to avoid + # data-block buildup on script reload. Only remove zero-user materials. + for mat in list(bpy.data.materials): + if (mat.name.startswith("KnotShader_") + or mat.name.startswith("KnotBlend_") + or mat.name.startswith("KnotItem_Preset_") + or mat.name in (KNOT_MAT_NAME, SHADER_BLEND_MAT_NAME)): + if mat.users == 0: + bpy.data.materials.remove(mat) + + print("[KnotScript] Handler and UI unregistered.") + + +# --------------------------------------------------------------------------- +# Entry point (run as script or via --python flag) +# --------------------------------------------------------------------------- + +if __name__ == "__main__": + register() + setup_scene() + + # Generate the knot for the current frame immediately so the viewport is + # not empty when the script first runs. + knot_frame_handler(bpy.context.scene) + print("[KnotScript] Ready. Start scrubbing the timeline!") + print(" Press Space to play, or render with Ctrl+F12.") diff --git a/knot_animation/compat.py b/knot_animation/compat.py new file mode 100644 index 0000000..3b7d5c0 --- /dev/null +++ b/knot_animation/compat.py @@ -0,0 +1,64 @@ +""" +compat.py +--------- +One-shot compatibility patches applied at add-on load time: + + 1. Enables 'add_curve_extra_objects' (provides Torus Knot Plus operator). + 2. Monkey-patches add_curve_torus_knots.align_matrix so it does not crash + when Blender is running in headless mode (no space_data on context). + +Usage:: + + from .compat import apply_compat_patches + apply_compat_patches() # call once from register() / __main__ +""" +import sys + + +def apply_compat_patches() -> None: + """Apply all compatibility patches. Safe to call more than once.""" + _enable_extra_curves() + _patch_align_matrix() + + +# --------------------------------------------------------------------------- +# Patch 1 — enable the built-in curve extras add-on +# --------------------------------------------------------------------------- + +def _enable_extra_curves() -> None: + try: + import addon_utils + addon_utils.enable("add_curve_extra_objects", default_set=True) + except Exception: + pass # module name may differ across Blender versions — silently skip + + +# --------------------------------------------------------------------------- +# Patch 2 — safe align_matrix for headless (CLI) mode +# --------------------------------------------------------------------------- + +def _patch_align_matrix() -> None: + """Monkey-patch the buggy align_matrix that crashes without space_data.""" + for mod_name, mod in sys.modules.items(): + if "add_curve_torus_knots" not in mod_name: + continue + if not hasattr(mod, "align_matrix"): + continue + if hasattr(mod, "_original_align_matrix"): + continue # already patched + + mod._original_align_matrix = mod.align_matrix + + def safe_align_matrix(self, context, _orig=mod._original_align_matrix): + if not getattr(context, "space_data", None): + import mathutils + user_loc = mathutils.Matrix.Translation(self.location) + user_rot = self.rotation.to_matrix().to_4x4() + if self.absolute_location: + loc = mathutils.Matrix.Translation(mathutils.Vector((0, 0, 0))) + else: + loc = mathutils.Matrix.Translation(context.scene.cursor.location) + return user_loc @ loc @ mathutils.Matrix() @ user_rot + return _orig(self, context) + + mod.align_matrix = safe_align_matrix diff --git a/knot_animation/constants.py b/knot_animation/constants.py new file mode 100644 index 0000000..c620526 --- /dev/null +++ b/knot_animation/constants.py @@ -0,0 +1,53 @@ +""" +constants.py +------------ +All module-level constants and default playlist configurations. + +Pure Python — no bpy imports — so this module can be imported and +inspected without a running Blender instance. +""" +import math + +# --------------------------------------------------------------------------- +# Default playlist +# Each entry is a partial KnotConfig dict; missing keys fall back to the +# defaults defined in KnotItem's PropertyGroup. +# --------------------------------------------------------------------------- +KNOT_CONFIGS = [ + # 1. Standard Trefoil – classic glossy blue + {"name": "Trefoil Knot", "torus_p": 2, "torus_q": 3, "shader_id": "GLOSS_BLUE"}, + # 2. Cinquefoil – polished gold metal + {"name": "Cinquefoil", "torus_p": 2, "torus_q": 5, "torus_R": 2.5, "torus_r": 0.5, "shader_id": "METALLIC"}, + # 3. Mobius Strip + {"name": "Mobius Strip", "shape_type": "MOBIUS", "mobius_twists": 1, "mobius_width": 1.5, "shader_id": "IRIDESCENT"}, + # 4. Lissajous Figure + {"name": "Lissajous 3D", "shape_type": "LISSAJOUS", "liss_kx": 3, "liss_ky": 2, "liss_kz": 4, "liss_amp": 2.5, "shader_id": "NEON_GLOW"}, + # 5. Spherical Spiral + {"name": "Spherical Spiral", "shape_type": "SPIRAL", "spiral_turns": 15, "spiral_R": 2.5, "shader_id": "GLASS"}, + # 6. Extruded ribbon – glass + {"name": "Glass Ribbon", "torus_p": 4, "torus_q": 5, "geo_extrude": 0.1, "geo_offset": 0.05, "geo_bDepth": 0.0, "shader_id": "GLASS"}, + # 7. Exterior/Interior mode – lava + {"name": "Lava Ring", "torus_p": 2, "torus_q": 7, "mode": "EXT_INT", "torus_eR": 3.0, "torus_iR": 1.0, "shader_id": "LAVA"}, + # 8. Height variation – neon glow + {"name": "Tall Pulse", "torus_p": 3, "torus_q": 7, "torus_h": 2.0, "shader_id": "NEON_GLOW"}, + # 9. Flipped direction – metallic + {"name": "Flipped Metallic", "torus_p": 4, "torus_q": 7, "flip_p": True, "flip_q": False, "shader_id": "METALLIC"}, + # 10. Complex multiplier & multiple links – iridescent + {"name": "Complex Multi-Link", "torus_p": 6, "torus_q": 9, "multiple_links": True, "torus_u": 3, "torus_v": 2, "shader_id": "HOLOGRAM"}, +] + +# --------------------------------------------------------------------------- +# Scene defaults +# --------------------------------------------------------------------------- +CAMERA_LOCATION = (0.0, -8.0, 3.0) +CAMERA_ROTATION = (math.radians(75), 0.0, 0.0) + +LIGHT_LOCATION = (4.0, -4.0, 6.0) +LIGHT_ENERGY = 500.0 + +# --------------------------------------------------------------------------- +# Well-known object / material name tags +# --------------------------------------------------------------------------- +KNOT_OBJ_NAME = "AnimKnot" +KNOT_MAT_NAME = "KnotMaterial" # legacy name kept for unregister cleanup +SHADER_BLEND_MAT_NAME = "KnotBlendMaterial" # cross-shader transition blend material diff --git a/knot_animation/geometry.py b/knot_animation/geometry.py new file mode 100644 index 0000000..d157ea4 --- /dev/null +++ b/knot_animation/geometry.py @@ -0,0 +1,260 @@ +""" +geometry.py +----------- +Procedural torus-knot geometry engine. + +Key design choice (P2 fix) +-------------------------- +`_make_torus_knot` no longer reads from `bpy.context`. The three +scene-global parameters that change between calls — + resolution, bevel_resolution, knot_scale +— are passed in as explicit keyword arguments by the caller (either +`knot_frame_handler` or `KNOT_OT_BakePreview.execute`). This makes +the function independently testable and keeps geometry logic separate +from scene-state queries. + +Torus-knot parametric equations +-------------------------------- +For each link l of gcd total links: + + t ∈ [0, 2π) (steps = resolution) + rev = (p/gcd)·t + rP + (l·2π/gcd) + spin = (q/gcd)·t + sP + rad = R + r·cos(spin·v) + x = rad·cos(rev·u) + y = rad·sin(rev·u) + z = r·sin(spin·v)·h +""" +from __future__ import annotations + +import math + +import bpy + +from .constants import KNOT_OBJ_NAME + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + +def _make_torus_knot( + config: dict, + *, + resolution: int, + bevel_resolution: int, + knot_scale: float, + scene=None, +) -> bpy.types.Object: + """Procedurally build / update the AnimKnot NURBS curve in-place. + + This avoids ALL crashes associated with bpy.ops, edit-mode switching, + and dependency-graph race conditions during playback or rendering. + + Parameters + ---------- + config: + KnotConfig dict produced by ``KnotItem.to_dict()`` (optionally + augmented by the frame handler with ``_scale_override`` / + ``_skip_material`` keys). + resolution: + Number of NURBS control points per spline. + bevel_resolution: + Curve bevel resolution (controls tube facets). + knot_scale: + Global scale factor from ``KnotGlobalSettings.knot_scale``. + scene: + The active Blender scene. Used only when the AnimKnot object does + not yet exist and needs to be linked to the scene collection. + Falls back to ``bpy.context.scene`` if not provided. + """ + if scene is None: + scene = bpy.context.scene + + # 1. Find or create the reusable curve object + obj = bpy.data.objects.get(KNOT_OBJ_NAME) + if not obj: + curve_data = bpy.data.curves.new(name=KNOT_OBJ_NAME, type='CURVE') + obj = bpy.data.objects.new(KNOT_OBJ_NAME, curve_data) + scene.collection.objects.link(obj) + else: + curve_data = obj.data + # NOTE: Do NOT call curve_data.splines.clear() here. + # The rendered viewport keeps an evaluated depsgraph copy alive during + # frame_change_post. Clearing splines frees the underlying C SplinePoint + # arrays while the evaluator's GeomCache still holds a pointer to them. + # TBB's allocator then detects the use-after-free on the next malloc and + # raises EXCEPTION_ACCESS_VIOLATION inside tbbmalloc.dll. + # Instead we reconcile the spline count in-place (add/remove from tail) + # and only reallocate point arrays when the count actually changes. + + # 2. Curve geometry properties + curve_data.dimensions = '3D' + curve_data.fill_mode = 'FULL' + curve_data.extrude = config.get('geo_extrude', 0.0) + curve_data.offset = config.get('geo_offset', 0.0) + curve_data.bevel_depth = config.get('geo_bDepth', 0.04) + curve_data.bevel_resolution = bevel_resolution # ← no bpy.context read + + # 3. Resolve shape parameters + shape_type = config.get('shape_type', 'TORUS_KNOT') + + if shape_type == 'TORUS_KNOT': + p = config.get('torus_p', 2) + q = config.get('torus_q', 3) + if config.get('flip_p', False): p = -p + if config.get('flip_q', False): q = -q + + mode = config.get('mode', 'MAJOR_MINOR') + if mode == 'EXT_INT': + eR = config.get('torus_eR', 3.0) + iR = config.get('torus_iR', 1.0) + R = (eR + iR) / 2.0 + r = (eR - iR) / 2.0 + else: + R = config.get('torus_R', 2.0) + r = config.get('torus_r', 1.0) + + u = config.get('torus_u', 1) + v = config.get('torus_v', 1) + rP = config.get('torus_rP', 0.0) * math.pi * 2.0 + sP = config.get('torus_sP', 0.0) * math.pi * 2.0 + h = config.get('torus_h', 1.0) + + multiple_links = config.get('multiple_links', False) + gcd = math.gcd(abs(p), abs(q)) if p and q else 1 + if not multiple_links: + gcd = 1 + is_cyclic = True + + elif shape_type == 'MOBIUS': + twists = config.get('mobius_twists', 1) + w = config.get('mobius_width', 1.0) + R = config.get('torus_R', 2.0) # Use torus major radius for the ring size + gcd = 2 if twists % 2 == 0 else 1 + is_cyclic = True + + elif shape_type == 'LISSAJOUS': + kx = config.get('liss_kx', 3) + ky = config.get('liss_ky', 2) + kz = config.get('liss_kz', 4) + amp = config.get('liss_amp', 2.0) + gcd = 1 + is_cyclic = True + + elif shape_type == 'SPIRAL': + turns = config.get('spiral_turns', 10) + R = config.get('spiral_R', 2.0) + gcd = 1 + is_cyclic = False + + else: + gcd = 1 + is_cyclic = True + + + # 4. Reconcile spline count without freeing existing splines. + steps = resolution # ← no bpy.context read + current_count = len(curve_data.splines) + if current_count < gcd: + for _ in range(gcd - current_count): + sp = curve_data.splines.new('NURBS') + sp.use_cyclic_u = is_cyclic + sp.use_endpoint_u = not is_cyclic + elif current_count > gcd: + for _ in range(current_count - gcd): + curve_data.splines.remove(curve_data.splines[-1]) + + # 5. Write point coordinates in-place for each spline. + TAU = 2.0 * math.pi + for link, spline in enumerate(curve_data.splines): + spline.use_cyclic_u = is_cyclic + spline.use_endpoint_u = not is_cyclic + + # Grow or shrink the point array only when the size changes. + current_pts = len(spline.points) + if current_pts < steps: + spline.points.add(steps - current_pts) + elif current_pts > steps: + curve_data.splines.remove(spline) + spline = curve_data.splines.new('NURBS') + spline.use_cyclic_u = is_cyclic + spline.use_endpoint_u = not is_cyclic + spline.points.add(steps - 1) + + for i in range(steps): + if shape_type == 'TORUS_KNOT': + t_param = (i / steps) * TAU + rev = (p / gcd) * t_param + rP + (link * TAU / gcd) + spin = (q / gcd) * t_param + sP + rad = R + r * math.cos(spin * v) + x = rad * math.cos(rev * u) + y = rad * math.sin(rev * u) + z = r * math.sin(spin * v) * h + + elif shape_type == 'MOBIUS': + t_max = 2.0 * TAU if gcd == 1 else TAU + t_param = (i / steps) * t_max + w_eff = w if link == 0 else -w + + x = (R + w_eff * math.cos(twists * t_param / 2.0)) * math.cos(t_param) + y = (R + w_eff * math.cos(twists * t_param / 2.0)) * math.sin(t_param) + z = w_eff * math.sin(twists * t_param / 2.0) + + elif shape_type == 'LISSAJOUS': + t_param = (i / steps) * TAU + x = amp * math.sin(kx * t_param) + y = amp * math.sin(ky * t_param + (TAU / 4.0)) + z = amp * math.sin(kz * t_param) + + elif shape_type == 'SPIRAL': + t_param = -math.pi / 2.0 + (i / max(1, steps - 1)) * math.pi + theta = t_param + phi = turns * 2.0 * t_param + x = R * math.cos(theta) * math.cos(phi) + y = R * math.cos(theta) * math.sin(phi) + z = R * math.sin(theta) + + else: + x, y, z = 0.0, 0.0, 0.0 + + spline.points[i].co = (x, y, z, 1.0) + + # 6. Transform and material + obj.location = (0.0, 0.0, 0.0) + + # Apply per-knot scale oscillation override if set by the handler + scale_override = config.get("_scale_override", None) + effective_scale = knot_scale if scale_override is None else knot_scale * scale_override + obj.scale = (effective_scale, effective_scale, effective_scale) + + # Assign material unless the handler has already set up a cross-shader blend + if not config.get("_skip_material", False): + if config.get("material_mode") == 'PROJECT': + mat = config.get("project_material") + else: + uid = config.get("uid") + mat = bpy.data.materials.get(f"KnotItem_Preset_{uid}") + + if mat: + if len(obj.data.materials) == 0: + obj.data.materials.append(mat) + else: + obj.data.materials[0] = mat + + return obj + + +def _remove_existing_knot() -> None: + """Remove the AnimKnot object and its curve data block from the scene. + + Called by ``unregister()`` to clean up when the script is reloaded or + disabled. ``_make_torus_knot()`` reuses the object in-place during + animation playback, so this function is intentionally NOT called there. + """ + obj = bpy.data.objects.get(KNOT_OBJ_NAME) + if obj is not None: + curve_data = obj.data + bpy.data.objects.remove(obj, do_unlink=True) + if curve_data and curve_data.users == 0: + bpy.data.curves.remove(curve_data) diff --git a/knot_animation/handler.py b/knot_animation/handler.py new file mode 100644 index 0000000..e4c4db8 --- /dev/null +++ b/knot_animation/handler.py @@ -0,0 +1,225 @@ +""" +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 diff --git a/knot_animation/materials.py b/knot_animation/materials.py new file mode 100644 index 0000000..b96ca21 --- /dev/null +++ b/knot_animation/materials.py @@ -0,0 +1,729 @@ +""" +materials.py +------------ +Shader catalogue and material management for knot_animation. + +Shader builders +--------------- +Each `_inner_` function receives an already-cleared node tree +(nodes, links, y_offset) and returns the final output shader socket. +They do NOT add an Output Material node — that is the caller's responsibility. + +Adding a new shader +------------------- +1. Define `_inner_MYSHADER(nodes, links, y=0, color=..., roughness=..., + metallic=..., emission_strength=..., **kwargs)`. +2. Add its name to `_TRANSPARENT_SHADERS` below ONLY if it needs alpha blending. + +No other changes are required — INNER_FACTORIES and SHADER_IDS are built +automatically from the naming convention. +""" +from __future__ import annotations + +import bpy + +from .constants import KNOT_OBJ_NAME, KNOT_MAT_NAME, SHADER_BLEND_MAT_NAME + + +# --------------------------------------------------------------------------- +# Inner shader builders +# Convention: _inner_(nodes, links, y, color, roughness, metallic, +# emission_strength, **kwargs) -> output_socket +# --------------------------------------------------------------------------- + +def _inner_GLOSS_BLUE(nodes, links, y=0, color=(0.2, 0.6, 1.0, 1.0), + roughness=0.1, metallic=0.0, emission_strength=1.0, **kwargs): + mix = nodes.new("ShaderNodeMixShader"); mix.location = (300, y) + glos = nodes.new("ShaderNodeBsdfGlossy"); glos.location = (0, y + 80) + emit = nodes.new("ShaderNodeEmission"); emit.location = (0, y - 80) + glos.inputs["Color"].default_value = color + glos.inputs["Roughness"].default_value = roughness + emit.inputs["Color"].default_value = (0.4, 0.8, 1.0, 1.0) + emit.inputs["Strength"].default_value = emission_strength * 0.5 + mix.inputs["Fac"].default_value = 0.3 + links.new(glos.outputs["BSDF"], mix.inputs[1]) + links.new(emit.outputs["Emission"], mix.inputs[2]) + return mix.outputs["Shader"] + + +def _inner_NEON_GLOW(nodes, links, y=0, color=(0.0, 1.0, 0.9, 1.0), + roughness=0.1, metallic=0.0, emission_strength=1.0, **kwargs): + mix = nodes.new("ShaderNodeMixShader"); mix.location = (300, y) + em1 = nodes.new("ShaderNodeEmission"); em1.location = (0, y + 80) + em2 = nodes.new("ShaderNodeEmission"); em2.location = (0, y - 80) + em1.inputs["Color"].default_value = color + em1.inputs["Strength"].default_value = emission_strength * 3.0 + em2.inputs["Color"].default_value = (1.0, 0.05, 0.8, 1.0) + em2.inputs["Strength"].default_value = emission_strength * 3.0 + mix.inputs["Fac"].default_value = 0.5 + links.new(em1.outputs["Emission"], mix.inputs[1]) + links.new(em2.outputs["Emission"], mix.inputs[2]) + return mix.outputs["Shader"] + + +def _inner_METALLIC(nodes, links, y=0, color=(1.0, 0.78, 0.28, 1.0), + roughness=0.05, metallic=1.0, emission_strength=1.0, **kwargs): + prin = nodes.new("ShaderNodeBsdfPrincipled"); prin.location = (0, y) + prin.inputs["Base Color"].default_value = color + prin.inputs["Metallic"].default_value = metallic + prin.inputs["Roughness"].default_value = roughness + try: + prin.inputs["Specular IOR Level"].default_value = 1.0 + except KeyError: + pass # name differs in older Blender versions + return prin.outputs["BSDF"] + + +def _inner_GLASS(nodes, links, y=0, color=(0.85, 0.95, 1.0, 1.0), + roughness=0.0, metallic=0.0, emission_strength=1.0, **kwargs): + prin = nodes.new("ShaderNodeBsdfPrincipled"); prin.location = (0, y) + prin.inputs["Base Color"].default_value = color + prin.inputs["Metallic"].default_value = metallic + prin.inputs["Roughness"].default_value = roughness + prin.inputs["IOR"].default_value = 1.45 + for key in ("Transmission Weight", "Transmission"): + if key in prin.inputs: + prin.inputs[key].default_value = 1.0 + break + return prin.outputs["BSDF"] + + +def _inner_HOLOGRAM(nodes, links, y=0, color=(0.2, 1.0, 0.5, 1.0), + roughness=0.1, metallic=0.0, emission_strength=1.0, **kwargs): + mix = nodes.new("ShaderNodeMixShader"); mix.location = (400, y) + emit = nodes.new("ShaderNodeEmission"); emit.location = (0, y + 100) + trans = nodes.new("ShaderNodeBsdfTransparent"); trans.location = (0, y - 100) + fres = nodes.new("ShaderNodeFresnel"); fres.location = (0, y + 220) + emit.inputs["Color"].default_value = color + emit.inputs["Strength"].default_value = emission_strength * 2.0 + fres.inputs["IOR"].default_value = 1.3 + links.new(fres.outputs["Fac"], mix.inputs["Fac"]) + links.new(emit.outputs["Emission"], mix.inputs[1]) + links.new(trans.outputs["BSDF"], mix.inputs[2]) + return mix.outputs["Shader"] + + +def _inner_LAVA(nodes, links, y=0, color=(1.0, 0.1, 0.0, 1.0), + roughness=1.0, metallic=0.0, emission_strength=1.0, **kwargs): + add = nodes.new("ShaderNodeAddShader"); add.location = (500, y) + diff = nodes.new("ShaderNodeBsdfDiffuse"); diff.location = (0, y + 100) + emit = nodes.new("ShaderNodeEmission"); emit.location = (0, y - 100) + ramp = nodes.new("ShaderNodeValToRGB"); ramp.location = (-250, y - 100) + noise = nodes.new("ShaderNodeTexNoise"); noise.location = (-500, y - 100) + coord = nodes.new("ShaderNodeTexCoord"); coord.location = (-700, y - 100) + diff.inputs["Color"].default_value = (color[0] * 0.08, color[1] * 0.08, color[2] * 0.08, 1.0) + diff.inputs["Roughness"].default_value = roughness + emit.inputs["Strength"].default_value = emission_strength * 4.0 + noise.inputs["Scale"].default_value = 4.0 + noise.inputs["Detail"].default_value = 8.0 + cr = ramp.color_ramp + cr.elements[0].position = 0.0; cr.elements[0].color = (0.0, 0.0, 0.0, 1.0) + cr.elements[1].position = 1.0; cr.elements[1].color = color + cr.elements.new(0.55).color = (color[0], color[1] * 0.1, color[2] * 0.1, 1.0) + cr.elements.new(0.85).color = (color[0], color[1] * 0.9, color[2] * 0.9, 1.0) + links.new(coord.outputs["Generated"], noise.inputs["Vector"]) + links.new(noise.outputs["Fac"], ramp.inputs["Fac"]) + links.new(ramp.outputs["Color"], emit.inputs["Color"]) + links.new(diff.outputs["BSDF"], add.inputs[0]) + links.new(emit.outputs["Emission"], add.inputs[1]) + return add.outputs["Shader"] + + +def _inner_IRIDESCENT(nodes, links, y=0, color=(1.0, 1.0, 1.0, 1.0), + roughness=0.05, metallic=0.0, emission_strength=1.0, **kwargs): + add = nodes.new("ShaderNodeAddShader"); add.location = (500, y) + glos = nodes.new("ShaderNodeBsdfGlossy"); glos.location = (0, y + 100) + emit = nodes.new("ShaderNodeEmission"); emit.location = (0, y - 100) + ramp = nodes.new("ShaderNodeValToRGB"); ramp.location = (-250, y - 100) + lw = nodes.new("ShaderNodeLayerWeight"); lw.location = (-450, y - 100) + glos.inputs["Roughness"].default_value = roughness + emit.inputs["Strength"].default_value = emission_strength * 1.2 + lw.inputs["Blend"].default_value = 0.5 + cr = ramp.color_ramp + cr.interpolation = 'LINEAR' + rainbow = [ + (0.0, (1.0, 0.0, 0.0, 1.0)), + (0.17, (1.0, 0.5, 0.0, 1.0)), + (0.33, (1.0, 1.0, 0.0, 1.0)), + (0.5, (0.0, 1.0, 0.0, 1.0)), + (0.67, (0.0, 0.5, 1.0, 1.0)), + (0.83, (0.5, 0.0, 1.0, 1.0)), + (1.0, (1.0, 0.0, 0.5, 1.0)), + ] + for i, (pos, col) in enumerate(rainbow): + if i < 2: + cr.elements[i].position = pos + cr.elements[i].color = col + else: + cr.elements.new(pos).color = col + links.new(lw.outputs["Facing"], ramp.inputs["Fac"]) + links.new(ramp.outputs["Color"], emit.inputs["Color"]) + links.new(ramp.outputs["Color"], glos.inputs["Color"]) + links.new(glos.outputs["BSDF"], add.inputs[0]) + links.new(emit.outputs["Emission"], add.inputs[1]) + return add.outputs["Shader"] + + +def _inner_MATTE_CLAY(nodes, links, y=0, color=(0.6, 0.4, 0.3, 1.0), + roughness=0.95, metallic=0.0, emission_strength=1.0, **kwargs): + prin = nodes.new("ShaderNodeBsdfPrincipled"); prin.location = (0, y) + prin.inputs["Base Color"].default_value = color + prin.inputs["Roughness"].default_value = roughness + if "Specular" in prin.inputs: + prin.inputs["Specular"].default_value = 0.1 + return prin.outputs["BSDF"] + + +def _inner_GHOST(nodes, links, y=0, color=(0.7, 0.8, 1.0, 1.0), + roughness=0.1, metallic=0.0, emission_strength=1.0, **kwargs): + mix = nodes.new("ShaderNodeMixShader"); mix.location = (400, y) + trans = nodes.new("ShaderNodeBsdfTransparent"); trans.location = (0, y) + emit = nodes.new("ShaderNodeEmission"); emit.location = (0, y + 150) + fres = nodes.new("ShaderNodeFresnel"); fres.location = (0, y + 300) + emit.inputs["Color"].default_value = color + emit.inputs["Strength"].default_value = emission_strength * 2.0 + fres.inputs["IOR"].default_value = 1.05 + links.new(fres.outputs["Fac"], mix.inputs["Fac"]) + links.new(trans.outputs["BSDF"], mix.inputs[1]) + links.new(emit.outputs["Emission"], mix.inputs[2]) + return mix.outputs["Shader"] + + +def _inner_CAR_PAINT(nodes, links, y=0, color=(0.8, 0.05, 0.1, 1.0), + roughness=0.3, metallic=0.8, emission_strength=1.0, **kwargs): + prin = nodes.new("ShaderNodeBsdfPrincipled"); prin.location = (0, y) + prin.inputs["Base Color"].default_value = color + prin.inputs["Metallic"].default_value = metallic + prin.inputs["Roughness"].default_value = roughness + if "Clearcoat" in prin.inputs: + prin.inputs["Clearcoat"].default_value = 1.0 + prin.inputs["Clearcoat Roughness"].default_value = 0.03 + elif "Coat Weight" in prin.inputs: + prin.inputs["Coat Weight"].default_value = 1.0 + prin.inputs["Coat Roughness"].default_value = 0.03 + return prin.outputs["BSDF"] + + +def _inner_CHROME(nodes, links, y=0, color=(0.95, 0.95, 0.95, 1.0), + roughness=0.01, metallic=1.0, emission_strength=1.0, **kwargs): + prin = nodes.new("ShaderNodeBsdfPrincipled"); prin.location = (0, y) + prin.inputs["Base Color"].default_value = color + prin.inputs["Metallic"].default_value = metallic + prin.inputs["Roughness"].default_value = roughness + return prin.outputs["BSDF"] + + +def _inner_RUBY_GLASS(nodes, links, y=0, color=(1.0, 0.05, 0.05, 1.0), + roughness=0.02, metallic=0.0, emission_strength=1.0, **kwargs): + prin = nodes.new("ShaderNodeBsdfPrincipled"); prin.location = (0, y) + prin.inputs["Base Color"].default_value = color + prin.inputs["Metallic"].default_value = metallic + prin.inputs["Roughness"].default_value = roughness + prin.inputs["IOR"].default_value = 1.6 + for key in ("Transmission Weight", "Transmission"): + if key in prin.inputs: + prin.inputs[key].default_value = 1.0 + break + return prin.outputs["BSDF"] + + +def _inner_FROSTED_ICE(nodes, links, y=0, color=(0.9, 0.95, 1.0, 1.0), + roughness=0.4, metallic=0.0, emission_strength=1.0, **kwargs): + prin = nodes.new("ShaderNodeBsdfPrincipled"); prin.location = (0, y) + prin.inputs["Base Color"].default_value = color + prin.inputs["Metallic"].default_value = metallic + prin.inputs["Roughness"].default_value = roughness + prin.inputs["IOR"].default_value = 1.31 + for key in ("Transmission Weight", "Transmission"): + if key in prin.inputs: + prin.inputs[key].default_value = 1.0 + break + return prin.outputs["BSDF"] + + +def _inner_LICHEN_ROUGH(nodes, links, y=0, color=(0.3, 0.6, 0.1, 1.0), + roughness=0.9, metallic=0.0, emission_strength=1.0, **kwargs): + mix = nodes.new("ShaderNodeMixShader"); mix.location = (300, y) + diff1 = nodes.new("ShaderNodeBsdfDiffuse"); diff1.location = (0, y + 100) + diff2 = nodes.new("ShaderNodeBsdfDiffuse"); diff2.location = (0, y - 100) + noise = nodes.new("ShaderNodeTexNoise"); noise.location = (-400, y) + ramp = nodes.new("ShaderNodeValToRGB"); ramp.location = (-200, y) + coord = nodes.new("ShaderNodeTexCoord"); coord.location = (-600, y) + diff1.inputs["Color"].default_value = (0.15, 0.15, 0.15, 1.0) + diff1.inputs["Roughness"].default_value = 1.0 + diff2.inputs["Color"].default_value = color + diff2.inputs["Roughness"].default_value = roughness + noise.inputs["Scale"].default_value = 8.0 + noise.inputs["Detail"].default_value = 6.0 + cr = ramp.color_ramp + cr.elements[0].position = 0.45; cr.elements[0].color = (0.0, 0.0, 0.0, 1.0) + cr.elements[1].position = 0.55; cr.elements[1].color = (1.0, 1.0, 1.0, 1.0) + links.new(coord.outputs["Generated"], noise.inputs["Vector"]) + links.new(noise.outputs["Fac"], ramp.inputs["Fac"]) + links.new(ramp.outputs["Color"], mix.inputs["Fac"]) + links.new(diff1.outputs["BSDF"], mix.inputs[1]) + links.new(diff2.outputs["BSDF"], mix.inputs[2]) + return mix.outputs["Shader"] + + +def _inner_COPPER_PATINA(nodes, links, y=0, color=(0.2, 0.6, 0.45, 1.0), + roughness=0.15, metallic=1.0, emission_strength=1.0, **kwargs): + mix = nodes.new("ShaderNodeMixShader"); mix.location = (300, y) + prin1 = nodes.new("ShaderNodeBsdfPrincipled"); prin1.location = (0, y + 120) + prin2 = nodes.new("ShaderNodeBsdfPrincipled"); prin2.location = (0, y - 120) + noise = nodes.new("ShaderNodeTexNoise"); noise.location = (-400, y) + ramp = nodes.new("ShaderNodeValToRGB"); ramp.location = (-200, y) + coord = nodes.new("ShaderNodeTexCoord"); coord.location = (-600, y) + prin1.inputs["Base Color"].default_value = (0.95, 0.45, 0.3, 1.0) + prin1.inputs["Metallic"].default_value = metallic + prin1.inputs["Roughness"].default_value = roughness + prin2.inputs["Base Color"].default_value = color + prin2.inputs["Metallic"].default_value = 0.0 + prin2.inputs["Roughness"].default_value = 0.85 + noise.inputs["Scale"].default_value = 6.0 + noise.inputs["Detail"].default_value = 4.0 + cr = ramp.color_ramp + cr.elements[0].position = 0.4; cr.elements[0].color = (0.0, 0.0, 0.0, 1.0) + cr.elements[1].position = 0.6; cr.elements[1].color = (1.0, 1.0, 1.0, 1.0) + links.new(coord.outputs["Generated"], noise.inputs["Vector"]) + links.new(noise.outputs["Fac"], ramp.inputs["Fac"]) + links.new(ramp.outputs["Color"], mix.inputs["Fac"]) + links.new(prin1.outputs["BSDF"], mix.inputs[1]) + links.new(prin2.outputs["BSDF"], mix.inputs[2]) + return mix.outputs["Shader"] + + +def _inner_ZEBRA_STRIPES(nodes, links, y=0, color=(0.98, 0.98, 0.98, 1.0), + roughness=0.1, metallic=0.0, emission_strength=1.0, **kwargs): + mix = nodes.new("ShaderNodeMixShader"); mix.location = (300, y) + prin1 = nodes.new("ShaderNodeBsdfPrincipled"); prin1.location = (0, y + 100) + prin2 = nodes.new("ShaderNodeBsdfPrincipled"); prin2.location = (0, y - 100) + wave = nodes.new("ShaderNodeTexWave"); wave.location = (-400, y) + ramp = nodes.new("ShaderNodeValToRGB"); ramp.location = (-200, y) + coord = nodes.new("ShaderNodeTexCoord"); coord.location = (-600, y) + prin1.inputs["Base Color"].default_value = (0.02, 0.02, 0.02, 1.0) + prin1.inputs["Metallic"].default_value = metallic + prin1.inputs["Roughness"].default_value = roughness + prin2.inputs["Base Color"].default_value = color + prin2.inputs["Metallic"].default_value = metallic + prin2.inputs["Roughness"].default_value = roughness + wave.inputs["Scale"].default_value = 5.0 + cr = ramp.color_ramp + cr.interpolation = 'CONSTANT' + cr.elements[0].position = 0.0; cr.elements[0].color = (0.0, 0.0, 0.0, 1.0) + cr.elements[1].position = 0.5; cr.elements[1].color = (1.0, 1.0, 1.0, 1.0) + links.new(coord.outputs["Generated"], wave.inputs["Vector"]) + links.new(wave.outputs["Color"], ramp.inputs["Fac"]) + links.new(ramp.outputs["Color"], mix.inputs["Fac"]) + links.new(prin1.outputs["BSDF"], mix.inputs[1]) + links.new(prin2.outputs["BSDF"], mix.inputs[2]) + return mix.outputs["Shader"] + + +def _inner_WOOD_VENEER(nodes, links, y=0, color=(0.4, 0.2, 0.08, 1.0), + roughness=0.2, metallic=0.0, emission_strength=1.0, **kwargs): + prin = nodes.new("ShaderNodeBsdfPrincipled"); prin.location = (300, y) + noise = nodes.new("ShaderNodeTexNoise"); noise.location = (-100, y) + ramp = nodes.new("ShaderNodeValToRGB"); ramp.location = (100, y) + mapping = nodes.new("ShaderNodeMapping"); mapping.location = (-300, y) + coord = nodes.new("ShaderNodeTexCoord"); coord.location = (-500, y) + mapping.inputs["Scale"].default_value = (1.0, 10.0, 1.0) + noise.inputs["Scale"].default_value = 4.0 + noise.inputs["Detail"].default_value = 4.0 + cr = ramp.color_ramp + cr.interpolation = 'LINEAR' + cr.elements[0].position = 0.0; cr.elements[0].color = (color[0] * 0.4, color[1] * 0.4, color[2] * 0.4, 1.0) + cr.elements[1].position = 1.0; cr.elements[1].color = color + cr.elements.new(0.5).color = (color[0] * 0.7, color[1] * 0.7, color[2] * 0.7, 1.0) + prin.inputs["Metallic"].default_value = metallic + prin.inputs["Roughness"].default_value = roughness + links.new(coord.outputs["Generated"], mapping.inputs["Vector"]) + links.new(mapping.outputs["Vector"], noise.inputs["Vector"]) + links.new(noise.outputs["Fac"], ramp.inputs["Fac"]) + links.new(ramp.outputs["Color"], prin.inputs["Base Color"]) + return prin.outputs["BSDF"] + + +def _inner_CARBON_FIBER(nodes, links, y=0, color=(0.1, 0.1, 0.1, 1.0), + roughness=0.15, metallic=0.0, emission_strength=1.0, **kwargs): + mix = nodes.new("ShaderNodeMixShader"); mix.location = (300, y) + prin1 = nodes.new("ShaderNodeBsdfPrincipled"); prin1.location = (0, y + 100) + prin2 = nodes.new("ShaderNodeBsdfPrincipled"); prin2.location = (0, y - 100) + check = nodes.new("ShaderNodeTexChecker"); check.location = (-200, y) + coord = nodes.new("ShaderNodeTexCoord"); coord.location = (-400, y) + prin1.inputs["Base Color"].default_value = (0.05, 0.05, 0.05, 1.0) + prin1.inputs["Metallic"].default_value = 0.5 + prin1.inputs["Roughness"].default_value = roughness + prin2.inputs["Base Color"].default_value = color + prin2.inputs["Metallic"].default_value = 0.5 + prin2.inputs["Roughness"].default_value = roughness + check.inputs["Scale"].default_value = 25.0 + links.new(coord.outputs["Generated"], check.inputs["Vector"]) + links.new(check.outputs["Color"], mix.inputs["Fac"]) + links.new(prin1.outputs["BSDF"], mix.inputs[1]) + links.new(prin2.outputs["BSDF"], mix.inputs[2]) + return mix.outputs["Shader"] + + +def _inner_PEARLESCENT(nodes, links, y=0, color=(1.0, 0.4, 0.7, 1.0), + roughness=0.05, metallic=0.1, emission_strength=1.0, **kwargs): + prin = nodes.new("ShaderNodeBsdfPrincipled"); prin.location = (300, y) + ramp = nodes.new("ShaderNodeValToRGB"); ramp.location = (100, y) + lw = nodes.new("ShaderNodeLayerWeight"); lw.location = (-100, y) + lw.inputs["Blend"].default_value = 0.35 + cr = ramp.color_ramp + cr.interpolation = 'LINEAR' + cr.elements[0].position = 0.0; cr.elements[0].color = (0.1, 0.8, 1.0, 1.0) + cr.elements[1].position = 1.0; cr.elements[1].color = color + prin.inputs["Metallic"].default_value = metallic + prin.inputs["Roughness"].default_value = roughness + if "Clearcoat" in prin.inputs: + prin.inputs["Clearcoat"].default_value = 1.0 + prin.inputs["Clearcoat Roughness"].default_value = 0.02 + links.new(lw.outputs["Facing"], ramp.inputs["Fac"]) + links.new(ramp.outputs["Color"], prin.inputs["Base Color"]) + return prin.outputs["BSDF"] + + +def _inner_PLASMA_GLOW(nodes, links, y=0, color=(1.0, 0.2, 0.8, 1.0), + roughness=0.1, metallic=0.0, emission_strength=1.0, **kwargs): + emit = nodes.new("ShaderNodeEmission"); emit.location = (300, y) + ramp = nodes.new("ShaderNodeValToRGB"); ramp.location = (100, y) + voro = nodes.new("ShaderNodeTexVoronoi"); voro.location = (-100, y) + coord = nodes.new("ShaderNodeTexCoord"); coord.location = (-300, y) + voro.inputs["Scale"].default_value = 6.0 + cr = ramp.color_ramp + cr.interpolation = 'LINEAR' + cr.elements[0].position = 0.0; cr.elements[0].color = (0.1, 0.0, 0.3, 1.0) + cr.elements[1].position = 1.0; cr.elements[1].color = (1.0, 0.9, 0.5, 1.0) + cr.elements.new(0.5).color = color + emit.inputs["Strength"].default_value = emission_strength * 8.0 + links.new(coord.outputs["Generated"], voro.inputs["Vector"]) + links.new(voro.outputs["Distance"], ramp.inputs["Fac"]) + links.new(ramp.outputs["Color"], emit.inputs["Color"]) + return emit.outputs["Emission"] + + +# --------------------------------------------------------------------------- +# Auto-discovery — P5 fix +# INNER_FACTORIES and SHADER_IDS are built from the _inner_* naming convention. +# Python 3.7+ dicts preserve insertion order, so SHADER_IDS matches the +# function definition order above. +# --------------------------------------------------------------------------- + +INNER_FACTORIES: dict = { + name[len("_inner_"):]: fn + for name, fn in globals().items() + if name.startswith("_inner_") and callable(fn) +} + +# Stable ordered list used by EnumProperty items and the unregister purge. +SHADER_IDS: list = list(INNER_FACTORIES) + +# Shaders that require alpha blending on the material. +_TRANSPARENT_SHADERS: frozenset = frozenset({ + "GLASS", "HOLOGRAM", "GHOST", "RUBY_GLASS", "FROSTED_ICE", +}) + + +# --------------------------------------------------------------------------- +# Material name helpers +# --------------------------------------------------------------------------- + +def _shader_mat_name(shader_id: str) -> str: + return f"KnotShader_{shader_id}" + + +# --------------------------------------------------------------------------- +# Shader material cache +# --------------------------------------------------------------------------- + +def _get_shader_material(shader_id: str): + """Look up the named shader material. Returns None if not yet built. + + This is the HANDLER-SAFE path — it never allocates. + """ + return bpy.data.materials.get(_shader_mat_name(shader_id)) + + +def _ensure_shader_material(shader_id: str): + """Get-or-create the named shader material with default properties. + + MAIN THREAD ONLY. + """ + mat_name = _shader_mat_name(shader_id) + mat = bpy.data.materials.get(mat_name) + if mat is None: + mat = bpy.data.materials.new(name=mat_name) + if hasattr(mat, "use_nodes"): + try: + mat.use_nodes = True + except AttributeError: + pass + nodes = mat.node_tree.nodes + links = mat.node_tree.links + nodes.clear() + out = nodes.new("ShaderNodeOutputMaterial") + out.location = (600, 0) + sock = INNER_FACTORIES[shader_id](nodes, links, y=0) + links.new(sock, out.inputs["Surface"]) + if shader_id in _TRANSPARENT_SHADERS: + mat.blend_method = 'BLEND' + return mat + + +def _ensure_item_preset_material(item): + """Ensure the per-KnotItem preset material exists and is up to date. + + MAIN THREAD ONLY. Call before playback or from property update callbacks. + """ + import random + if not item.uid: + item.uid = f"knot_{random.randint(100000, 999999)}" + + mat_name = f"KnotItem_Preset_{item.uid}" + mat = bpy.data.materials.get(mat_name) + if mat is None: + mat = bpy.data.materials.new(name=mat_name) + + if hasattr(mat, "use_nodes"): + try: + mat.use_nodes = True + except AttributeError: + pass + + nodes = mat.node_tree.nodes + links = mat.node_tree.links + nodes.clear() + + out = nodes.new("ShaderNodeOutputMaterial") + out.location = (600, 0) + + color = (item.preset_color[0], item.preset_color[1], item.preset_color[2], 1.0) + roughness = item.preset_roughness + metallic = item.preset_metallic + emission_strength = item.preset_emission_strength + + sock = INNER_FACTORIES[item.shader_id]( + nodes, links, y=0, + color=color, + roughness=roughness, + metallic=metallic, + emission_strength=emission_strength, + ) + links.new(sock, out.inputs["Surface"]) + + if item.shader_id in _TRANSPARENT_SHADERS: + mat.blend_method = 'BLEND' + else: + mat.blend_method = 'OPAQUE' + return mat + + +def get_effective_material(item): + """Return the bpy.types.Material currently active for this KnotItem.""" + if item.material_mode == 'PROJECT': + return item.project_material + return _ensure_item_preset_material(item) + + +def _update_viewport_knot_material(scene) -> None: + """Instantly update the viewport AnimKnot object's material slot.""" + obj = bpy.data.objects.get(KNOT_OBJ_NAME) + if not obj: + return + idx = scene.knot_list_index + if 0 <= idx < len(scene.knot_list): + item = scene.knot_list[idx] + mat = get_effective_material(item) + if mat: + if len(obj.data.materials) == 0: + obj.data.materials.append(mat) + else: + obj.data.materials[0] = mat + + +# --------------------------------------------------------------------------- +# Node-copy helper for cross-material blend materials +# --------------------------------------------------------------------------- + +def _copy_nodes_to_blend(src_mat, dst_mat, offset_y: int, prefix: str): + """Robustly duplicate a source material's node setup into a blend material. + + Returns the final output shader socket so the caller can wire it into a + MixShader. Falls back to a plain Principled BSDF if anything goes wrong. + """ + if not src_mat or getattr(src_mat, "node_tree", None) is None: + nodes = dst_mat.node_tree.nodes + fallback = nodes.new("ShaderNodeBsdfPrincipled") + fallback.name = f"{prefix}_fallback" + fallback.location = (-300, offset_y) + if src_mat: + fallback.inputs["Base Color"].default_value = src_mat.diffuse_color + return fallback.outputs["BSDF"] + + nodes = dst_mat.node_tree.nodes + links = dst_mat.node_tree.links + + node_map = {} + for src_node in src_mat.node_tree.nodes: + if src_node.type == 'OUTPUT_MATERIAL': + continue + new_node = nodes.new(src_node.bl_idname) + new_node.name = f"{prefix}_{src_node.name}" + new_node.label = src_node.label + new_node.location = (src_node.location.x - 300, src_node.location.y + offset_y) + + # Copy only non-readonly primitive values + for prop in src_node.bl_rna.properties: + if prop.identifier in {'rna_type', 'type', 'inputs', 'outputs', + 'internal_links', 'bl_idname'}: + continue + if not prop.is_readonly and prop.type in {'BOOLEAN', 'INT', 'FLOAT', 'STRING', 'ENUM'}: + try: + setattr(new_node, prop.identifier, getattr(src_node, prop.identifier)) + except Exception: + pass + + # Special-case: ColorRamp needs manual element duplication + if src_node.type == 'VALTO_RGB': + try: + new_ramp = new_node.color_ramp + src_ramp = src_node.color_ramp + new_ramp.interpolation = src_ramp.interpolation + while len(new_ramp.elements) < len(src_ramp.elements): + new_ramp.elements.new(0.5) + while len(new_ramp.elements) > len(src_ramp.elements): + new_ramp.elements.remove(new_ramp.elements[-1]) + for idx, el in enumerate(src_ramp.elements): + new_ramp.elements[idx].position = el.position + new_ramp.elements[idx].color = el.color + except Exception: + pass + + for i, inp in enumerate(src_node.inputs): + try: + new_node.inputs[i].default_value = inp.default_value + except Exception: + pass + + node_map[src_node] = new_node + + # Reconnect internal links + for link in src_mat.node_tree.links: + if link.to_node.type == 'OUTPUT_MATERIAL': + continue + try: + from_node = node_map.get(link.from_node) + to_node = node_map.get(link.to_node) + if not from_node or not to_node: + continue + try: + out_sock = from_node.outputs[link.from_socket.identifier] + except (KeyError, IndexError): + f_idx = list(link.from_node.outputs).index(link.from_socket) + out_sock = from_node.outputs[f_idx] if f_idx < len(from_node.outputs) else None + try: + in_sock = to_node.inputs[link.to_socket.identifier] + except (KeyError, IndexError): + t_idx = list(link.to_node.inputs).index(link.to_socket) + in_sock = to_node.inputs[t_idx] if t_idx < len(to_node.inputs) else None + if out_sock and in_sock: + links.new(out_sock, in_sock) + except Exception: + pass + + # Find the source surface output socket via the Material Output node + src_out = next( + (n for n in src_mat.node_tree.nodes if n.type == 'OUTPUT_MATERIAL' and n.is_active_output), + None, + ) or next( + (n for n in src_mat.node_tree.nodes if n.type == 'OUTPUT_MATERIAL'), + None, + ) + + if src_out and len(src_out.inputs["Surface"].links) > 0: + src_link = src_out.inputs["Surface"].links[0] + mapped_node = node_map.get(src_link.from_node) + if mapped_node and len(mapped_node.outputs) > 0: + try: + return mapped_node.outputs[src_link.from_socket.identifier] + except (KeyError, IndexError): + pass + try: + f_idx = list(src_link.from_node.outputs).index(src_link.from_socket) + if f_idx < len(mapped_node.outputs): + return mapped_node.outputs[f_idx] + except (ValueError, IndexError): + pass + return mapped_node.outputs[0] + + # Final fallback + fallback = nodes.new("ShaderNodeBsdfPrincipled") + fallback.name = f"{prefix}_fallback" + fallback.location = (-300, offset_y) + return fallback.outputs["BSDF"] + + +# --------------------------------------------------------------------------- +# Playlist blend material system +# --------------------------------------------------------------------------- + +def prebuild_playlist_blend_materials(scene) -> None: + """Pre-build cross-fade blend materials for every adjacent playlist pair. + + Called from the main thread (operators, property callbacks) so the frame + handler never needs to allocate node trees itself. + """ + if not hasattr(scene, "knot_list") or len(scene.knot_list) == 0: + return + + for i, item in enumerate(scene.knot_list): + if item.transition_frames <= 0: + continue + next_item = scene.knot_list[(i + 1) % len(scene.knot_list)] + mat_a = get_effective_material(item) + mat_b = get_effective_material(next_item) + + blend_name = f"KnotBlend_Item_{i}" + blend_mat = bpy.data.materials.get(blend_name) + if not blend_mat: + blend_mat = bpy.data.materials.new(name=blend_name) + + if hasattr(blend_mat, "use_nodes"): + try: + blend_mat.use_nodes = True + except AttributeError: + pass + + nodes = blend_mat.node_tree.nodes + links = blend_mat.node_tree.links + nodes.clear() + + out = nodes.new("ShaderNodeOutputMaterial") + out.location = (600, 0) + mix = nodes.new("ShaderNodeMixShader") + mix.name = "_KnotBlendMix" + mix.location = (300, 0) + + sock_a = _copy_nodes_to_blend(mat_a, blend_mat, 250, "A") + sock_b = _copy_nodes_to_blend(mat_b, blend_mat, -250, "B") + + links.new(sock_a, mix.inputs[1]) + links.new(sock_b, mix.inputs[2]) + links.new(mix.outputs["Shader"], out.inputs["Surface"]) + + if (mat_a and mat_a.blend_method == 'BLEND') or (mat_b and mat_b.blend_method == 'BLEND'): + blend_mat.blend_method = 'BLEND' + else: + blend_mat.blend_method = 'OPAQUE' + + +def prewarm_materials_and_blends(scene) -> None: + """Ensure all playlist preset materials are generated and transitions pre-built.""" + if not hasattr(scene, "knot_list"): + return + for item in scene.knot_list: + if item.material_mode == 'PRESET': + _ensure_item_preset_material(item) + prebuild_playlist_blend_materials(scene) diff --git a/knot_animation/operators.py b/knot_animation/operators.py new file mode 100644 index 0000000..d986486 --- /dev/null +++ b/knot_animation/operators.py @@ -0,0 +1,324 @@ +""" +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") + + _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'} diff --git a/knot_animation/properties.py b/knot_animation/properties.py new file mode 100644 index 0000000..708d41b --- /dev/null +++ b/knot_animation/properties.py @@ -0,0 +1,381 @@ +""" +properties.py +------------- +All Blender PropertyGroup classes for knot_animation. + +P4 fix — KnotGeneratorSettings +-------------------------------- +Instead of 70+ individual property declarations, three helper functions +(_add_rand_int, _add_rand_float, _add_rand_bool) append range-triplets +directly to the class's __annotations__ dict before registration. + + _add_rand_int → r_{prop} (Bool) + min_{prop} / max_{prop} (Int) + _add_rand_float→ r_{prop} (Bool) + min_{prop} / max_{prop} (Float) + _add_rand_bool → r_{prop} (Bool) + prob_{prop} (Float factor) + +The property *identifiers* are identical to the monolith, so existing +.blend files deserialise without changes. +""" +from __future__ import annotations + +import random + +import bpy + +from .constants import KNOT_OBJ_NAME +from .materials import ( + SHADER_IDS, + _ensure_item_preset_material, + prebuild_playlist_blend_materials, + _update_viewport_knot_material, +) + + +# --------------------------------------------------------------------------- +# Range-property registration helpers (P4) +# --------------------------------------------------------------------------- + +def _add_rand_int(cls, prop: str, label: str, min_d: int, max_d: int, + rand_default: bool = False, val_min: int | None = None) -> None: + """Add r_{prop} (Bool) + min_{prop}/max_{prop} (Int) to a PropertyGroup.""" + cls.__annotations__[f"r_{prop}"] = bpy.props.BoolProperty(name=label, default=rand_default) + int_kw: dict = {"name": "Min", "default": min_d} + if val_min is not None: + int_kw["min"] = val_min + cls.__annotations__[f"min_{prop}"] = bpy.props.IntProperty(**int_kw) + cls.__annotations__[f"max_{prop}"] = bpy.props.IntProperty( + name="Max", default=max_d, **({} if val_min is None else {"min": val_min}) + ) + + +def _add_rand_float(cls, prop: str, label: str, min_d: float, max_d: float, + rand_default: bool = False, val_min: float | None = None) -> None: + """Add r_{prop} (Bool) + min_{prop}/max_{prop} (Float) to a PropertyGroup.""" + cls.__annotations__[f"r_{prop}"] = bpy.props.BoolProperty(name=label, default=rand_default) + float_kw: dict = {"name": "Min", "default": min_d} + if val_min is not None: + float_kw["min"] = val_min + cls.__annotations__[f"min_{prop}"] = bpy.props.FloatProperty(**float_kw) + cls.__annotations__[f"max_{prop}"] = bpy.props.FloatProperty( + name="Max", default=max_d, **({} if val_min is None else {"min": val_min}) + ) + + +def _add_rand_bool(cls, prop: str, label: str, rand_default: bool = False, + prob_default: float = 0.5, prob_label: str = "% True") -> None: + """Add r_{prop} (Bool) + prob_{prop} (Float factor) to a PropertyGroup.""" + cls.__annotations__[f"r_{prop}"] = bpy.props.BoolProperty(name=label, default=rand_default) + cls.__annotations__[f"prob_{prop}"] = bpy.props.FloatProperty( + name=prob_label, default=prob_default, min=0.0, max=1.0, subtype='FACTOR' + ) + + +# --------------------------------------------------------------------------- +# Property update callback +# --------------------------------------------------------------------------- + +def _update_knot_material_cb(self, context) -> None: + """Called whenever a material-related KnotItem property changes.""" + if not self.uid: + self.uid = f"knot_{random.randint(100000, 999999)}" + + if self.material_mode == 'PRESET': + _ensure_item_preset_material(self) + + # Rebuild blend materials to keep transitions up to date + prebuild_playlist_blend_materials(context.scene) + + # Force redraw of 3-D viewport + for area in context.screen.areas: + if area.type == 'VIEW_3D': + area.tag_redraw() + + # Instantly update the active viewport knot's material + _update_viewport_knot_material(context.scene) + + +# --------------------------------------------------------------------------- +# PropertyGroups +# --------------------------------------------------------------------------- + +class KnotAllowedMaterial(bpy.types.PropertyGroup): + """Entry in the generator's allowed-materials filter list.""" + material: bpy.props.PointerProperty(type=bpy.types.Material, name="Material") + preset_id: bpy.props.StringProperty(name="Preset ID", default="") + is_preset: bpy.props.BoolProperty(name="Is Preset", default=False) + enabled: bpy.props.BoolProperty(name="Enabled", default=True) + name: bpy.props.StringProperty(name="Name", default="") + + +class KnotGlobalSettings(bpy.types.PropertyGroup): + """Scene-level playback and rendering settings.""" + frames_per_knot: bpy.props.IntProperty( + name="Frames per Knot", default=12, min=1) + resolution: bpy.props.IntProperty( + name="Curve Resolution", default=128, min=3, max=1024) + bevel_resolution: bpy.props.IntProperty( + name="Bevel Resolution", default=8, min=0, max=64) + knot_scale: bpy.props.FloatProperty( + name="Global Scale", default=1.0, min=0.01) + global_speed: bpy.props.FloatProperty( + name="Global Speed", default=1.0, min=0.01, + description="Multiplies the effective frame counter for all knots. Keyframeable.") + animation_phase: bpy.props.FloatProperty( + name="Animation Phase", default=0.0, + description="Frame offset added before all calculations. Keyframe or drive this for reactive control.") + reactivity_factor: bpy.props.FloatProperty( + name="Reactivity", default=1.0, min=0.0, max=10.0, + description="Scales all per-knot animation rates. Wire a driver here for audio-reactive animation.") + + +class KnotItem(bpy.types.PropertyGroup): + """One entry in the knot playlist.""" + name: bpy.props.StringProperty(name="Name", default="Knot") + uid: bpy.props.StringProperty(name="Unique ID", default="") + + # Shape Type + shape_type: bpy.props.EnumProperty( + name="Shape", + items=[ + ('TORUS_KNOT', "Torus Knot", ""), + ('MOBIUS', "Mobius Strip", ""), + ('LISSAJOUS', "Lissajous 3D", ""), + ('SPIRAL', "Spherical Spiral", ""), + ], + default='TORUS_KNOT' + ) + + # Topology (Torus Knot) + torus_p: bpy.props.IntProperty(name="Revolutions (p)", default=2, min=1) + torus_q: bpy.props.IntProperty(name="Spins (q)", default=3, min=1) + + # Topology (Mobius) + mobius_twists: bpy.props.IntProperty(name="Half Twists", default=1, min=1) + mobius_width: bpy.props.FloatProperty(name="Width", default=1.0, min=0.1) + + # Topology (Lissajous 3D) + liss_kx: bpy.props.IntProperty(name="kx (Freq X)", default=3, min=1) + liss_ky: bpy.props.IntProperty(name="ky (Freq Y)", default=2, min=1) + liss_kz: bpy.props.IntProperty(name="kz (Freq Z)", default=4, min=1) + liss_amp: bpy.props.FloatProperty(name="Amplitude", default=2.0, min=0.1) + + # Topology (Spherical Spiral) + spiral_turns: bpy.props.IntProperty(name="Turns", default=10, min=1) + spiral_R: bpy.props.FloatProperty(name="Radius", default=2.0, min=0.1) + + # Radii (Torus Knot) + torus_R: bpy.props.FloatProperty(name="Major", default=2.0, min=0.0) + torus_r: bpy.props.FloatProperty(name="Minor", default=1.0, min=0.0) + + # Colors (legacy TKP path) + multiple_links: bpy.props.BoolProperty(name="Multiple Links", default=False) + use_colors: bpy.props.BoolProperty(name="Use Colors", default=False) + colorSet: bpy.props.EnumProperty( + name="Color Set", + items=[('1', "RGBish", ""), ('2', "Rainbow", "")], + default='1') + random_colors: bpy.props.BoolProperty(name="Randomize Colors", default=False) + + # Multipliers & phases + torus_u: bpy.props.IntProperty( name="Rev. Multiplier", default=1, min=1) + torus_v: bpy.props.IntProperty( name="Spin Multiplier", default=1, min=1) + torus_rP: bpy.props.FloatProperty(name="Rev. Phase", default=0.0) + torus_sP: bpy.props.FloatProperty(name="Spin Phase", default=0.0) + + # Animation rates + spin_phase_rate: bpy.props.FloatProperty( + name="Spin Phase Rate", default=0.0, + description="Animate spin phase (tube rotation) over time. Scaled by Reactivity.") + rev_phase_rate: bpy.props.FloatProperty( + name="Rev. Phase Rate", default=0.0, + description="Animate revolution phase (orbit rotation) over time. Scaled by Reactivity.") + height_rate: bpy.props.FloatProperty( + name="Height Pulse Rate", default=0.0, + description="Oscillates torus height over time. Scaled by Reactivity.") + scale_rate: bpy.props.FloatProperty( + name="Scale Oscillation Rate", default=0.0, + description="Frequency of per-knot scale oscillation. Scaled by Reactivity.") + scale_amplitude: bpy.props.FloatProperty( + name="Scale Oscillation Amplitude", default=0.0, min=0.0, + description="Amplitude of per-knot scale oscillation (0 = none).") + cycle_rate: bpy.props.FloatProperty( + name="Cycle Rate", default=1.0, min=0.01, + description="Multiplies frames_per_knot for this knot only.") + + # Transition + transition_frames: bpy.props.IntProperty( + name="Transition Frames", default=0, min=0, + description="Number of frames to morph into the next knot (0 = instant)") + transition_easing: bpy.props.EnumProperty( + name="Easing", + items=[ + ('LINEAR', "Linear", ""), + ('QUAD_IN_OUT', "Quadratic In/Out", ""), + ('SMOOTHSTEP', "Smoothstep", ""), + ], + default='QUAD_IN_OUT') + + # Geometry + geo_extrude: bpy.props.FloatProperty(name="Extrude", default=0.0, min=0.0) + geo_offset: bpy.props.FloatProperty(name="Offset", default=0.0) + geo_bDepth: bpy.props.FloatProperty(name="Bevel Depth", default=0.04, min=0.0) + + # Dimensions mode + mode: bpy.props.EnumProperty( + name="Dimensions Mode", + items=[('MAJOR_MINOR', "Major/Minor", ""), ('EXT_INT', "Exterior/Interior", "")], + default='MAJOR_MINOR') + torus_eR: bpy.props.FloatProperty(name="Exterior", default=3.0, min=0.0) + torus_iR: bpy.props.FloatProperty(name="Interior", default=1.0, min=0.0) + torus_h: bpy.props.FloatProperty(name="Height", default=1.0, min=0.0) + + # Direction + flip_p: bpy.props.BoolProperty(name="Flip p", default=False) + flip_q: bpy.props.BoolProperty(name="Flip q", default=False) + + # Material + material_mode: bpy.props.EnumProperty( + name="Material Mode", + items=[('PRESET', "Shader Preset", ""), ('PROJECT', "Project Material", "")], + default='PRESET', + update=_update_knot_material_cb) + project_material: bpy.props.PointerProperty( + name="Material", type=bpy.types.Material, + description="Referenced material from the project", + update=_update_knot_material_cb) + shader_id: bpy.props.EnumProperty( + name="Shader Preset", + description="Material preset applied to this knot", + items=[(s, s.replace('_', ' ').title(), '') for s in SHADER_IDS], + default='GLOSS_BLUE', + update=_update_knot_material_cb) + preset_color: bpy.props.FloatVectorProperty( + name="Preset Color", subtype='COLOR', size=3, + default=(0.2, 0.6, 1.0), min=0.0, max=1.0, + update=_update_knot_material_cb) + preset_roughness: bpy.props.FloatProperty( + name="Preset Roughness", default=0.1, min=0.0, max=1.0, + update=_update_knot_material_cb) + preset_metallic: bpy.props.FloatProperty( + name="Preset Metallic", default=0.0, min=0.0, max=1.0, + update=_update_knot_material_cb) + preset_emission_strength: bpy.props.FloatProperty( + name="Preset Emission Strength", default=1.0, min=0.0, max=100.0, + update=_update_knot_material_cb) + + def to_dict(self) -> dict: + return { + "shape_type": self.shape_type, + "torus_p": self.torus_p, + "torus_q": self.torus_q, + "mobius_twists": self.mobius_twists, + "mobius_width": self.mobius_width, + "liss_kx": self.liss_kx, + "liss_ky": self.liss_ky, + "liss_kz": self.liss_kz, + "liss_amp": self.liss_amp, + "spiral_turns": self.spiral_turns, + "spiral_R": self.spiral_R, + "torus_R": self.torus_R, + "torus_r": self.torus_r, + "multiple_links": self.multiple_links, + "use_colors": self.use_colors, + "colorSet": self.colorSet, + "random_colors": self.random_colors, + "torus_u": self.torus_u, + "torus_v": self.torus_v, + "torus_rP": self.torus_rP, + "torus_sP": self.torus_sP, + "spin_phase_rate": self.spin_phase_rate, + "rev_phase_rate": self.rev_phase_rate, + "height_rate": self.height_rate, + "scale_rate": self.scale_rate, + "scale_amplitude": self.scale_amplitude, + "cycle_rate": self.cycle_rate, + "transition_frames": self.transition_frames, + "transition_easing": self.transition_easing, + "geo_extrude": self.geo_extrude, + "geo_offset": self.geo_offset, + "geo_bDepth": self.geo_bDepth, + "mode": self.mode, + "torus_eR": self.torus_eR, + "torus_iR": self.torus_iR, + "torus_h": self.torus_h, + "flip_p": self.flip_p, + "flip_q": self.flip_q, + "material_mode": self.material_mode, + "project_material": self.project_material, + "shader_id": self.shader_id, + "preset_color": self.preset_color, + "preset_roughness": self.preset_roughness, + "preset_metallic": self.preset_metallic, + "preset_emission_strength": self.preset_emission_strength, + "uid": self.uid, + } + + +class KnotGeneratorSettings(bpy.types.PropertyGroup): + """Settings for the random knot generator. + + Range triplets (r_*/min_*/max_*) and bool/prob pairs are added below the + class definition via _add_rand_* helpers — see P4 in the refactor notes. + """ + num_knots: bpy.props.IntProperty(name="Number of Knots", default=10, min=1, max=100) + base_knot: bpy.props.PointerProperty(type=KnotItem) + + # Standalone booleans without an associated range or prob + r_shape_type: bpy.props.BoolProperty(name="Randomize Shape Type", default=True) + r_transition_easing: bpy.props.BoolProperty(name="Easing", default=False) + r_material: bpy.props.BoolProperty( + name="Randomize Material", default=True, + description="Randomize the material/preset for each generated knot") + r_preset_params: bpy.props.BoolProperty( + name="Randomize Preset Parameters", default=True, + description="Randomize color, roughness, metallic, etc. for preset materials") + + # Generator allowed-materials filter + allowed_materials: bpy.props.CollectionProperty(type=KnotAllowedMaterial) + allowed_materials_index: bpy.props.IntProperty(name="Index", default=0) + + +# ── Integer range triplets ──────────────────────────────────────────────────── +_add_rand_int(KnotGeneratorSettings, "torus_p", "Revolutions (p)", 1, 8, rand_default=True, val_min=1) +_add_rand_int(KnotGeneratorSettings, "torus_q", "Spins (q)", 1, 8, rand_default=True, val_min=1) +_add_rand_int(KnotGeneratorSettings, "mobius_twists", "Mobius Twists", 1, 5, val_min=1) +_add_rand_int(KnotGeneratorSettings, "liss_kx", "Lissajous kx", 1, 5, val_min=1) +_add_rand_int(KnotGeneratorSettings, "liss_ky", "Lissajous ky", 1, 5, val_min=1) +_add_rand_int(KnotGeneratorSettings, "liss_kz", "Lissajous kz", 1, 5, val_min=1) +_add_rand_int(KnotGeneratorSettings, "spiral_turns", "Spiral Turns", 5, 20, val_min=1) +_add_rand_int(KnotGeneratorSettings, "torus_u", "Rev. Multiplier", 1, 4, val_min=1) +_add_rand_int(KnotGeneratorSettings, "torus_v", "Spin Multiplier", 1, 4, val_min=1) +_add_rand_int(KnotGeneratorSettings, "transition_frames","Transition Frames",0, 36, val_min=0) + +# ── Float range triplets ────────────────────────────────────────────────────── +_add_rand_float(KnotGeneratorSettings, "torus_R", "Major Radius", 1.0, 4.0, val_min=0.0) +_add_rand_float(KnotGeneratorSettings, "torus_r", "Minor Radius", 0.1, 1.5, val_min=0.0) +_add_rand_float(KnotGeneratorSettings, "mobius_width", "Mobius Width", 0.5, 2.0, val_min=0.1) +_add_rand_float(KnotGeneratorSettings, "liss_amp", "Lissajous Amp", 1.0, 4.0, val_min=0.1) +_add_rand_float(KnotGeneratorSettings, "spiral_R", "Spiral Radius", 1.0, 4.0, val_min=0.1) +_add_rand_float(KnotGeneratorSettings, "torus_eR", "Exterior Radius", 2.0, 5.0, val_min=0.0) +_add_rand_float(KnotGeneratorSettings, "torus_iR", "Interior Radius", 0.5, 2.0, val_min=0.0) +_add_rand_float(KnotGeneratorSettings, "geo_extrude", "Extrude", 0.0, 0.5, val_min=0.0) +_add_rand_float(KnotGeneratorSettings, "geo_offset", "Offset", 0.0, 0.5) +_add_rand_float(KnotGeneratorSettings, "geo_bDepth", "Bevel Depth", 0.01, 0.2, rand_default=True, val_min=0.0) +_add_rand_float(KnotGeneratorSettings, "torus_h", "Height", 0.5, 3.0, val_min=0.0) +_add_rand_float(KnotGeneratorSettings, "torus_rP", "Rev. Phase", 0.0, 2.0) +_add_rand_float(KnotGeneratorSettings, "torus_sP", "Spin Phase", 0.0, 2.0) +_add_rand_float(KnotGeneratorSettings, "spin_phase_rate", "Spin Phase Rate", -0.5, 0.5) +_add_rand_float(KnotGeneratorSettings, "rev_phase_rate", "Rev. Phase Rate", -0.5, 0.5) +_add_rand_float(KnotGeneratorSettings, "height_rate", "Height Pulse Rate", 0.0, 0.2, val_min=0.0) +_add_rand_float(KnotGeneratorSettings, "scale_rate", "Scale Osc. Rate", 0.0, 0.3, val_min=0.0) +_add_rand_float(KnotGeneratorSettings, "scale_amplitude", "Scale Osc. Amplitude", 0.0, 0.4, val_min=0.0) +_add_rand_float(KnotGeneratorSettings, "cycle_rate", "Cycle Rate", 0.5, 2.0, val_min=0.01) + +# ── Bool/prob pairs ─────────────────────────────────────────────────────────── +_add_rand_bool(KnotGeneratorSettings, "multiple_links", "Multiple Links") +_add_rand_bool(KnotGeneratorSettings, "use_colors", "Use Colors", rand_default=True) +_add_rand_bool(KnotGeneratorSettings, "colorSet", "Color Set", rand_default=True, prob_label="% Set 2") +_add_rand_bool(KnotGeneratorSettings, "random_colors", "Randomize Colors", rand_default=True) +_add_rand_bool(KnotGeneratorSettings, "flip_p", "Flip p") +_add_rand_bool(KnotGeneratorSettings, "flip_q", "Flip q") +_add_rand_bool(KnotGeneratorSettings, "mode", "Mode", prob_label="% Ext/Int") diff --git a/knot_animation/scene_setup.py b/knot_animation/scene_setup.py new file mode 100644 index 0000000..21be30f --- /dev/null +++ b/knot_animation/scene_setup.py @@ -0,0 +1,74 @@ +""" +scene_setup.py +-------------- +One-shot scene initialisation: camera, area light, world background, +render engine, and default playlist. + +Call ``setup_scene()`` once after ``register()`` (typically from +``__main__`` or a startup hook). +""" +from __future__ import annotations + +import bpy + +from .constants import ( + CAMERA_LOCATION, CAMERA_ROTATION, + LIGHT_LOCATION, LIGHT_ENERGY, +) +from .materials import prewarm_materials_and_blends + + +def setup_scene() -> None: + """Configure the Blender scene for knot animation playback.""" + scene = bpy.context.scene + + # ── Timeline ───────────────────────────────────────────────────────────── + scene.frame_start = 1 + scene.frame_end = 600 + scene.render.fps = 24 + + # ── Render engine ──────────────────────────────────────────────────────── + scene.render.engine = 'CYCLES' + scene.cycles.samples = 64 + + # ── Remove default objects ─────────────────────────────────────────────── + for name in ("Cube", "Light", "Camera"): + obj = bpy.data.objects.get(name) + if obj: + bpy.data.objects.remove(obj, do_unlink=True) + + # ── Camera ─────────────────────────────────────────────────────────────── + cam_data = bpy.data.cameras.new("KnotCamera") + cam_data.lens = 50 + cam_obj = bpy.data.objects.new("KnotCamera", cam_data) + cam_obj.location = CAMERA_LOCATION + cam_obj.rotation_euler = CAMERA_ROTATION + scene.collection.objects.link(cam_obj) + scene.camera = cam_obj + + # ── Area Light ─────────────────────────────────────────────────────────── + light_data = bpy.data.lights.new("KnotLight", type='AREA') + light_data.energy = LIGHT_ENERGY + light_data.size = 3.0 + light_obj = bpy.data.objects.new("KnotLight", light_data) + light_obj.location = LIGHT_LOCATION + scene.collection.objects.link(light_obj) + + # ── World background: dark gradient ────────────────────────────────────── + world = scene.world + if world is None: + world = bpy.data.worlds.new("KnotWorld") + scene.world = world + bg_node = world.node_tree.nodes.get("Background") + if bg_node: + bg_node.inputs["Color"].default_value = (0.02, 0.02, 0.05, 1.0) + bg_node.inputs["Strength"].default_value = 0.2 + + # ── Default playlist ───────────────────────────────────────────────────── + # NOTE: An empty CollectionProperty is falsy in Python; use len() to test. + if len(scene.knot_list) == 0: + bpy.ops.knot.populate() # also calls prewarm_materials_and_blends() + else: + prewarm_materials_and_blends(scene) # ensure shaders exist for existing list + + print("[KnotScript] Scene setup complete.") diff --git a/knot_animation/types.py b/knot_animation/types.py new file mode 100644 index 0000000..45bcde8 --- /dev/null +++ b/knot_animation/types.py @@ -0,0 +1,95 @@ +""" +types.py +-------- +Shared type definitions for the knot_animation package. + +KnotConfig is the typed dictionary that flows between: + KnotItem.to_dict() → knot_frame_handler → _make_torus_knot + and the material / blend system. + +All keys are optional (total=False) so callers can provide only the +relevant subset; consumers must use .get() with a sensible default. +""" +from __future__ import annotations + +try: + from typing import TypedDict +except ImportError: # Python < 3.8 + from typing_extensions import TypedDict # type: ignore[no-redef] + + +class KnotConfig(TypedDict, total=False): + # ── Shape Type ─────────────────────────────────────────────────────────── + shape_type: str # 'TORUS_KNOT' | 'MOBIUS' | 'LISSAJOUS' | 'SPIRAL' + + # ── Topology (Torus Knot) ──────────────────────────────────────────────── + torus_p: int + torus_q: int + flip_p: bool + flip_q: bool + multiple_links: bool + + # ── Topology (Mobius) ──────────────────────────────────────────────────── + mobius_twists: int + mobius_width: float + + # ── Topology (Lissajous 3D) ────────────────────────────────────────────── + liss_kx: int + liss_ky: int + liss_kz: int + liss_amp: float + + # ── Topology (Spherical Spiral) ────────────────────────────────────────── + spiral_turns: int + spiral_R: float + + # ── Radii (Major/Minor mode) ────────────────────────────────────────────── + torus_R: float + torus_r: float + + # ── Radii (Ext/Int mode) ───────────────────────────────────────────────── + mode: str # 'MAJOR_MINOR' | 'EXT_INT' + torus_eR: float + torus_iR: float + + # ── Multipliers & phases ────────────────────────────────────────────────── + torus_u: int + torus_v: int + torus_rP: float + torus_sP: float + torus_h: float + + # ── Per-knot animation rates ────────────────────────────────────────────── + spin_phase_rate: float + rev_phase_rate: float + height_rate: float + scale_rate: float + scale_amplitude: float + cycle_rate: float + + # ── Geometry ────────────────────────────────────────────────────────────── + geo_extrude: float + geo_offset: float + geo_bDepth: float + + # ── Transition ──────────────────────────────────────────────────────────── + transition_frames: int + transition_easing: str # 'LINEAR' | 'QUAD_IN_OUT' | 'SMOOTHSTEP' + + # ── Legacy TKP colour path ──────────────────────────────────────────────── + use_colors: bool + colorSet: str + random_colors: bool + + # ── Material ────────────────────────────────────────────────────────────── + material_mode: str # 'PRESET' | 'PROJECT' + shader_id: str + preset_color: tuple + preset_roughness: float + preset_metallic: float + preset_emission_strength: float + uid: str + + # ── Private handler-injected keys (not stored in KnotItem) ─────────────── + _skip_material: bool # tells _make_torus_knot to skip material assignment + _scale_override: float # per-frame scale multiplier computed by the handler diff --git a/knot_animation/ui.py b/knot_animation/ui.py new file mode 100644 index 0000000..b979314 --- /dev/null +++ b/knot_animation/ui.py @@ -0,0 +1,283 @@ +""" +ui.py +----- +All Blender UI classes for knot_animation: + + KNOT_UL_List — playlist UIList + KNOT_UL_AllowedMaterialsList— generator filter UIList + draw_knot_properties() — shared property layout helper + KNOT_PT_Panel — main N-panel (VIEW_3D > UI > AnimKnots) + +This module contains only layout code. It imports compute_playlist_duration +from handler.py for the live duration readout; it does not call any geometry +or material functions directly. +""" +from __future__ import annotations + +import bpy + +from .handler import compute_playlist_duration + + +# --------------------------------------------------------------------------- +# UILists +# --------------------------------------------------------------------------- + +class KNOT_UL_List(bpy.types.UIList): + def draw_item(self, context, layout, data, item, icon, + active_data, active_propname, index): + layout.label(text=item.name, icon='CURVE_PATH') + + +class KNOT_UL_AllowedMaterialsList(bpy.types.UIList): + def draw_item(self, context, layout, data, item, icon, + active_data, active_propname, index): + row = layout.row(align=True) + row.prop(item, "enabled", text="") + if item.is_preset: + row.label(text=f"Preset: {item.name}", icon='SHADING_RENDERED') + else: + row.label(text=f"Material: {item.name}", icon='MATERIAL') + + +# --------------------------------------------------------------------------- +# Shared property layout helper +# --------------------------------------------------------------------------- + +def draw_knot_properties(layout, item) -> None: + """Draw all KnotItem properties into *layout*. + + Used by both the main panel (selected item) and the generator base-knot + section so the layout stays in sync automatically. + """ + layout.prop(item, "shape_type") + + if item.shape_type == 'TORUS_KNOT': + row = layout.row(align=True) + row.prop(item, "torus_p") + row.prop(item, "torus_q") + row = layout.row(align=True) + row.prop(item, "flip_p", toggle=True, icon='ARROW_LEFTRIGHT') + row.prop(item, "flip_q", toggle=True, icon='ARROW_LEFTRIGHT') + + layout.prop(item, "mode") + if item.mode == 'MAJOR_MINOR': + row = layout.row(align=True) + row.prop(item, "torus_R") + row.prop(item, "torus_r") + else: + row = layout.row(align=True) + row.prop(item, "torus_eR") + row.prop(item, "torus_iR") + + elif item.shape_type == 'MOBIUS': + row = layout.row(align=True) + row.prop(item, "mobius_twists") + row.prop(item, "mobius_width") + + elif item.shape_type == 'LISSAJOUS': + row = layout.row(align=True) + row.prop(item, "liss_kx") + row.prop(item, "liss_ky") + row.prop(item, "liss_kz") + layout.prop(item, "liss_amp") + + elif item.shape_type == 'SPIRAL': + row = layout.row(align=True) + row.prop(item, "spiral_turns") + row.prop(item, "spiral_R") + + row = layout.row(align=True) + row.prop(item, "geo_extrude") + row.prop(item, "geo_offset") + layout.prop(item, "geo_bDepth") + layout.prop(item, "torus_h") + + col = layout.column(align=True) + col.prop(item, "multiple_links") + row = col.row(align=True) + row.prop(item, "torus_u") + row.prop(item, "torus_v") + row = col.row(align=True) + row.prop(item, "torus_rP") + row.prop(item, "torus_sP") + + anim_box = layout.box() + anim_box.label(text="Animation Rates", icon='ANIM') + anim_box.prop(item, "cycle_rate") + col = anim_box.column(align=True) + col.prop(item, "spin_phase_rate") + col.prop(item, "rev_phase_rate") + col.prop(item, "height_rate") + row = anim_box.row(align=True) + row.prop(item, "scale_rate") + row.prop(item, "scale_amplitude") + + mat_box = layout.box() + mat_box.label(text="Material Options", icon='MATERIAL') + mat_box.prop(item, "material_mode", expand=True) + if item.material_mode == 'PRESET': + mat_box.prop(item, "shader_id") + col = mat_box.column(align=True) + col.prop(item, "preset_color") + col.prop(item, "preset_roughness", slider=True) + col.prop(item, "preset_metallic", slider=True) + col.prop(item, "preset_emission_strength") + else: + mat_box.prop(item, "project_material", text="Material") + + layout.prop(item, "use_colors") + if item.use_colors: + row = layout.row(align=True) + row.prop(item, "colorSet") + row.prop(item, "random_colors") + + trans_box = layout.box() + trans_box.label(text="Transition to Next Knot", icon='MOD_TIME') + trans_box.prop(item, "transition_frames") + if item.transition_frames > 0: + trans_box.prop(item, "transition_easing") + + +# --------------------------------------------------------------------------- +# Main panel +# --------------------------------------------------------------------------- + +class KNOT_PT_Panel(bpy.types.Panel): + bl_label = "AnimKnots Configuration" + bl_idname = "KNOT_PT_panel" + bl_space_type = 'VIEW_3D' + bl_region_type = 'UI' + bl_category = 'AnimKnots' + + def draw(self, context): + layout = self.layout + scene = context.scene + + # ── Playlist ──────────────────────────────────────────────────────── + row = layout.row() + row.template_list("KNOT_UL_List", "", scene, "knot_list", + scene, "knot_list_index") + col = row.column(align=True) + col.operator("knot.add_item", text="", icon='ADD') + col.operator("knot.remove_item",text="", icon='REMOVE') + col.separator() + col.operator("knot.move_item", text="", icon='TRIA_UP').direction = 'UP' + col.operator("knot.move_item", text="", icon='TRIA_DOWN').direction = 'DOWN' + + layout.operator("knot.populate", icon='FILE_REFRESH') + + if 0 <= scene.knot_list_index < len(scene.knot_list): + item = scene.knot_list[scene.knot_list_index] + box = layout.box() + box.prop(item, "name") + draw_knot_properties(box, item) + layout.operator("knot.bake_preview", icon='FILE_TICK') + + # ── Global Settings ───────────────────────────────────────────────── + layout.separator() + gb = layout.box() + gb.label(text="Global Settings", icon='WORLD') + glob = scene.knot_globals + gb.prop(glob, "frames_per_knot") + gb.prop(glob, "resolution") + gb.prop(glob, "bevel_resolution") + gb.prop(glob, "knot_scale") + + n_knots = len(scene.knot_list) + if n_knots > 0: + total_frames = compute_playlist_duration(scene) + fps = max(1, scene.render.fps) + secs = total_frames / fps + dur_row = gb.row(align=True) + dur_row.label( + text=f"Playlist: {total_frames} fr / {secs:.1f}s ({n_knots} knots)", + icon='TIME') + fit_row = gb.row(align=True) + fit_row.operator("knot.fit_timeline", icon='PREVIEW_RANGE', text="Fit Timeline") + fit_row.operator("knot.fit_playlist", icon='NLA_PUSHDOWN', text="Fit Playlist") + + gb.separator() + gb.label(text="Playback Control", icon='PLAY') + gb.prop(glob, "global_speed") + gb.prop(glob, "animation_phase") + gb.prop(glob, "reactivity_factor", slider=True) + gb.label(text="↑ Keyframe or drive for reactivity", icon='INFO') + + row = gb.row(align=True) + row.prop(scene, "frame_end", text="Total Frames") + row.prop(scene.render, "fps", text="FPS") + + # ── Random Generator ──────────────────────────────────────────────── + layout.separator() + gen_box = layout.box() + gen_box.label(text="Random Knot Generator", icon='GROUP') + gen = scene.knot_generator + + gen_box.prop(gen, "num_knots") + + base_box = gen_box.box() + base_box.label(text="Base Defaults", icon='PRESET') + draw_knot_properties(base_box, gen.base_knot) + + rand_box = gen_box.box() + rand_box.label(text="Randomize Toggles", icon='MODIFIER') + + def draw_rand(prop): + row = rand_box.row(align=True) + row.prop(gen, f"r_{prop}") + if getattr(gen, f"r_{prop}"): + row.prop(gen, f"min_{prop}") + row.prop(gen, f"max_{prop}") + + def draw_rand_bool(prop): + row = rand_box.row(align=True) + row.prop(gen, f"r_{prop}") + if getattr(gen, f"r_{prop}") and hasattr(gen, f"prob_{prop}"): + row.prop(gen, f"prob_{prop}") + + rand_box.prop(gen, "r_shape_type") + draw_rand("torus_p"); draw_rand("torus_q") + draw_rand_bool("flip_p"); draw_rand_bool("flip_q") + draw_rand_bool("mode") + draw_rand("torus_R"); draw_rand("torus_r") + draw_rand("torus_eR"); draw_rand("torus_iR") + draw_rand("mobius_twists"); draw_rand("mobius_width") + draw_rand("liss_kx"); draw_rand("liss_ky"); draw_rand("liss_kz"); draw_rand("liss_amp") + draw_rand("spiral_turns"); draw_rand("spiral_R") + draw_rand("geo_extrude"); draw_rand("geo_offset"); draw_rand("geo_bDepth") + draw_rand("torus_h") + draw_rand_bool("multiple_links") + draw_rand("torus_u"); draw_rand("torus_v") + draw_rand("torus_rP"); draw_rand("torus_sP") + draw_rand("spin_phase_rate") + draw_rand_bool("use_colors"); draw_rand_bool("colorSet"); draw_rand_bool("random_colors") + draw_rand("transition_frames"); draw_rand_bool("transition_easing") + + rand_box.separator() + rand_box.label(text="Animation Rates", icon='ANIM') + draw_rand("cycle_rate"); draw_rand("spin_phase_rate") + draw_rand("rev_phase_rate"); draw_rand("height_rate") + draw_rand("scale_rate"); draw_rand("scale_amplitude") + + row = rand_box.row(align=True) + row.prop(gen, "r_material") + if gen.r_material: + row.prop(gen, "r_preset_params", text="Random Parameters") + + filter_box = rand_box.box() + filter_box.label(text="Allowed Materials & Presets:") + + row_sync = filter_box.row() + row_sync.template_list( + "KNOT_UL_AllowedMaterialsList", "", + gen, "allowed_materials", + gen, "allowed_materials_index", + rows=5) + col_btn = row_sync.column(align=True) + col_btn.operator("knot.sync_generator_materials", icon='FILE_REFRESH', text="") + + if len(gen.allowed_materials) == 0: + filter_box.label(text="Click Sync to load project materials & presets", icon='INFO') + + gen_box.operator("knot.generate_random", icon='PLAY')