Switch to Blender addon

This commit is contained in:
Stefan Cepko
2026-05-31 17:35:06 -04:00
parent 88811218fc
commit cb79e028be
11 changed files with 2693 additions and 0 deletions
+205
View File
@@ -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.")
+64
View File
@@ -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
+53
View File
@@ -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
+260
View File
@@ -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)
+225
View File
@@ -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
+729
View File
@@ -0,0 +1,729 @@
"""
materials.py
------------
Shader catalogue and material management for knot_animation.
Shader builders
---------------
Each `_inner_<SHADER_ID>` 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_<SHADER_ID>(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)
+324
View File
@@ -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'}
+381
View File
@@ -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")
+74
View File
@@ -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.")
+95
View File
@@ -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
+283
View File
@@ -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')