rename knot_animation folder

This commit is contained in:
Stefan Cepko
2026-06-04 06:04:45 -04:00
parent d6d6348ce6
commit b6d0eeffbb
12 changed files with 0 additions and 0 deletions
+214
View File
@@ -0,0 +1,214 @@
"""
knot_animation/__init__.py
--------------------------
Blender add-on entry-point for the Pr3tz procedural animation system.
What this add-on does
---------------------
1. Registers a VIEW_3D N-panel ("Pr3tz") 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
----------
Requires Blender 5.0 or later.
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
"Pr3tz" 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)
# bake_export may not exist from a prior session — import if needed
if "bake_export" in locals():
importlib.reload(bake_export)
else:
from . import bake_export
else:
import bpy
from . import compat, constants, types, materials, geometry, handler, properties, operators, ui, scene_setup, bake_export
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 .bake_export import KNOT_OT_BakeExport
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": "Pr3tz",
"author": "knot_animation project",
"version": (2, 0, 0),
"blender": (5, 0, 0),
"location": "View3D > Sidebar > Pr3tz",
"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_OT_BakeExport,
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.")
+522
View File
@@ -0,0 +1,522 @@
"""
bake_export.py
--------------
Operator to bake the Pr3tz procedural animation into a fully
self-contained .blend file suitable for distributed render farms
(SheepIt, etc.).
The resulting file contains:
- One mesh object per unique frame, with hide_render / hide_viewport
keyframes so only the correct mesh is visible on each frame.
- All materials (preset, blend, project) referenced by the objects.
- Camera with baked delta_location keyframes (camera shake).
- Light, world, and render settings copied from the source scene.
- No addon dependencies — the file is fully standalone.
"""
from __future__ import annotations
import hashlib
import random
import struct
import bpy
from bpy_extras.io_utils import ExportHelper
from .constants import KNOT_OBJ_NAME
# ---------------------------------------------------------------------------
# Blender 5.0 — layered Action API (Action.fcurves removed)
# ---------------------------------------------------------------------------
def _get_action_fcurves(obj):
"""Return an iterable of FCurves for the given object's active action slot.
Blender 5.0 uses a layered animation system:
action → layers → strips → channelbag (per slot) → fcurves
"""
anim = obj.animation_data
if not anim or not anim.action:
return []
action = anim.action
slot = anim.action_slot
if slot is None:
return []
for layer in action.layers:
for strip in layer.strips:
cb = strip.channelbag(slot)
if cb is not None:
return cb.fcurves
return []
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _mesh_fingerprint(mesh: bpy.types.Mesh) -> str:
"""Compute a fast hash of a mesh's vertex positions + material index.
Used to detect consecutive identical frames so we can extend the
previous object's visibility window instead of creating a duplicate.
"""
h = hashlib.md5(usedforsecurity=False)
# Hash vertex positions
for v in mesh.vertices:
h.update(struct.pack('fff', *v.co))
# Hash which material slots are assigned
for slot_idx, slot in enumerate(mesh.materials):
name = slot.name if slot else ""
h.update(f"{slot_idx}:{name}".encode())
return h.hexdigest()
def _obj_transform_fingerprint(obj: bpy.types.Object) -> str:
"""Hash an object's location, rotation_euler, and scale."""
h = hashlib.md5(usedforsecurity=False)
h.update(struct.pack('fff', *obj.location))
h.update(struct.pack('fff', *obj.rotation_euler))
h.update(struct.pack('fff', *obj.scale))
return h.hexdigest()
def _keyframe_visibility(obj, frame: int, visible: bool) -> None:
"""Insert hide_render and hide_viewport keyframes for a single frame."""
obj.hide_render = not visible
obj.hide_viewport = not visible
obj.keyframe_insert(data_path="hide_render", frame=frame)
obj.keyframe_insert(data_path="hide_viewport", frame=frame)
def _set_constant_interpolation(obj) -> None:
"""Set all visibility F-curves to CONSTANT interpolation.
This prevents Blender from smoothly fading between True/False,
which would make objects partially visible on intermediate frames.
"""
for fc in _get_action_fcurves(obj):
if fc.data_path in ("hide_render", "hide_viewport"):
for kp in fc.keyframe_points:
kp.interpolation = 'CONSTANT'
# ---------------------------------------------------------------------------
# Main bake operator
# ---------------------------------------------------------------------------
class KNOT_OT_BakeExport(bpy.types.Operator, ExportHelper):
"""Bake the procedural animation into a standalone .blend file."""
bl_idname = "knot.bake_export"
bl_label = "Bake & Export for Render Farm"
bl_description = (
"Iterate every frame, convert the procedural knot to mesh, "
"and save a self-contained .blend with per-frame visibility keyframes. "
"The output file requires no addons and is render-farm ready"
)
bl_options = {'REGISTER'}
# ExportHelper provides the file browser
filename_ext = ".blend"
filter_glob: bpy.props.StringProperty(default="*.blend", options={'HIDDEN'})
# User-facing options shown in the file browser sidebar
use_render_resolution: bpy.props.BoolProperty(
name="Use Render Resolution",
description="Bake at the Render Curve / Bevel resolution (recommended for final output)",
default=True,
)
pack_textures: bpy.props.BoolProperty(
name="Pack Textures",
description="Pack all external textures into the .blend file",
default=True,
)
split_export: bpy.props.BoolProperty(
name="Split Export",
description="Split the baked animation into multiple .blend files",
default=False,
)
frames_per_file: bpy.props.IntProperty(
name="Frames Per File",
description="Number of frames to include in each split file",
default=500,
min=1,
)
def execute(self, context):
scene = context.scene
# Validate
if len(scene.knot_list) == 0:
self.report({'ERROR'}, "Playlist is empty — nothing to bake.")
return {'CANCELLED'}
knot_obj = bpy.data.objects.get(KNOT_OBJ_NAME)
if not knot_obj:
self.report({'ERROR'}, f"AnimKnot object '{KNOT_OBJ_NAME}' not found.")
return {'CANCELLED'}
filepath = self.filepath
if not filepath.lower().endswith(".blend"):
filepath += ".blend"
orig_frame_start = scene.frame_start
orig_frame_end = scene.frame_end
total_frames = orig_frame_end - orig_frame_start + 1
self.report({'INFO'}, f"Baking {total_frames} frames...")
chunk_size = self.frames_per_file if self.split_export else total_frames
# We will loop over chunks and bake each as a separate file
try:
import os
first_chunk = True
for chunk_start in range(orig_frame_start, orig_frame_end + 1, chunk_size):
chunk_end = min(chunk_start + chunk_size - 1, orig_frame_end)
scene.frame_start = chunk_start
scene.frame_end = chunk_end
chunk_filepath = filepath
if self.split_export:
base, ext = os.path.splitext(filepath)
chunk_filepath = f"{base}_{chunk_start}-{chunk_end}{ext}"
self.report({'INFO'}, f"Baking chunk {chunk_start}-{chunk_end} to {chunk_filepath}...")
# pack_and_clean is True only on the first chunk so that external
# textures are packed exactly once. Subsequent chunks already
# have the packed images in memory from the first run.
result = self._bake_range(
context, chunk_filepath,
pack_and_clean=first_chunk,
)
first_chunk = False
if result != {'FINISHED'}:
self.report({'ERROR'}, f"Failed baking chunk {chunk_start}-{chunk_end}")
return {'CANCELLED'}
finally:
scene.frame_start = orig_frame_start
scene.frame_end = orig_frame_end
self.report({'INFO'}, "Baking complete.")
return {'FINISHED'}
def _bake_range(self, context, filepath, *, pack_and_clean: bool = True):
scene = context.scene
glob = scene.knot_globals
frame_start = scene.frame_start
frame_end = scene.frame_end
total_frames = frame_end - frame_start + 1
# If "Use Render Resolution" is on, temporarily override the preview
# resolution to match render resolution. The handler reads
# preview_resolution for viewport and render_resolution for renders,
# but frame_set() triggers a viewport-mode handler call. By
# equalising them we ensure the baked mesh uses render quality.
orig_preview_res = glob.preview_resolution
orig_preview_bevel = glob.preview_bevel_resolution
if self.use_render_resolution:
glob.preview_resolution = glob.render_resolution
glob.preview_bevel_resolution = glob.render_bevel_resolution
# -------------------------------------------------------------------
# Phase 1: Bake per-frame geometry into mesh objects
# -------------------------------------------------------------------
# Collect all baked objects so we can keyframe them afterwards
baked_objects: list[tuple[bpy.types.Object, int, int]] = []
# Each entry: (object, first_visible_frame, last_visible_frame)
prev_fingerprint = None
prev_transform_fp = None
# Create a dedicated collection to hold baked frames
bake_coll = bpy.data.collections.new("BakedAnimation")
scene.collection.children.link(bake_coll)
# Store the original frame so we can restore it later
original_frame = scene.frame_current
for f in range(frame_start, frame_end + 1):
# Set the frame — this triggers knot_frame_handler via
# the registered frame_change_post handler
scene.frame_set(f)
# Get the knot object (may have been recreated by the handler)
knot_obj = bpy.data.objects.get(KNOT_OBJ_NAME)
if not knot_obj:
continue
# Evaluate the depsgraph to get the final geometry
depsgraph = context.evaluated_depsgraph_get()
eval_obj = knot_obj.evaluated_get(depsgraph)
# Convert to mesh
mesh = bpy.data.meshes.new_from_object(eval_obj, depsgraph=depsgraph)
mesh.name = f"BakedKnot_Mesh_F{f}"
# Compute fingerprints for duplicate detection
mesh_fp = _mesh_fingerprint(mesh)
transform_fp = _obj_transform_fingerprint(knot_obj)
if (mesh_fp == prev_fingerprint
and transform_fp == prev_transform_fp
and baked_objects):
# Identical to the previous frame — extend the previous
# object's visibility window and discard the duplicate mesh
bpy.data.meshes.remove(mesh)
_, first_f, _ = baked_objects[-1]
baked_objects[-1] = (baked_objects[-1][0], first_f, f)
continue
prev_fingerprint = mesh_fp
prev_transform_fp = transform_fp
# Create a new object for this frame
obj = bpy.data.objects.new(f"BakedKnot_F{f}", mesh)
# Copy transform
obj.location = knot_obj.location.copy()
obj.rotation_euler = knot_obj.rotation_euler.copy()
obj.scale = knot_obj.scale.copy()
# Copy material slots
for src_slot in knot_obj.material_slots:
obj.data.materials.append(src_slot.material)
# Copy display settings
obj.display_type = knot_obj.display_type
bake_coll.objects.link(obj)
baked_objects.append((obj, f, f))
# Restore preview resolution if we overrode it
if self.use_render_resolution:
glob.preview_resolution = orig_preview_res
glob.preview_bevel_resolution = orig_preview_bevel
# Restore original frame
scene.frame_set(original_frame)
if not baked_objects:
self.report({'ERROR'}, "No frames were baked — the handler may not be producing geometry.")
# Clean up the empty collection
bpy.data.collections.remove(bake_coll)
return {'CANCELLED'}
# -------------------------------------------------------------------
# Phase 2: Set up visibility keyframes
# -------------------------------------------------------------------
for obj, first_f, last_f in baked_objects:
# Default: hidden on all frames
# We keyframe "hidden" before and after the active window,
# and "visible" for the active range.
# Hidden before the active range
if first_f > frame_start:
_keyframe_visibility(obj, frame_start, False)
if first_f > frame_start + 1:
_keyframe_visibility(obj, first_f - 1, False)
# Visible during the active range
_keyframe_visibility(obj, first_f, True)
if last_f != first_f:
_keyframe_visibility(obj, last_f, True)
# Hidden after the active range
if last_f < frame_end:
_keyframe_visibility(obj, last_f + 1, False)
if last_f < frame_end - 1:
_keyframe_visibility(obj, frame_end, False)
_set_constant_interpolation(obj)
# -------------------------------------------------------------------
# Phase 3: Bake camera shake to keyframes
# -------------------------------------------------------------------
cam = scene.camera
if cam and glob.camera_shake_amplitude > 0.0:
shake = glob.camera_shake_amplitude
for f in range(frame_start, frame_end + 1):
import random
random.seed(int(f * 1000))
cam.delta_location = (
random.uniform(-shake, shake),
random.uniform(-shake, shake),
random.uniform(-shake, shake),
)
cam.keyframe_insert(data_path="delta_location", frame=f)
# Set interpolation to LINEAR for smooth shake
for fc in _get_action_fcurves(cam):
if fc.data_path == "delta_location":
for kp in fc.keyframe_points:
kp.interpolation = 'LINEAR'
# If no shake, ensure delta_location is zeroed
elif cam:
cam.delta_location = (0.0, 0.0, 0.0)
# -------------------------------------------------------------------
# Phase 4: Clean up the procedural AnimKnot object
# -------------------------------------------------------------------
# Remove the original NURBS curve object — we've replaced it with
# baked mesh objects
knot_obj = bpy.data.objects.get(KNOT_OBJ_NAME)
if knot_obj:
curve_data = knot_obj.data
bpy.data.objects.remove(knot_obj, do_unlink=True)
if curve_data and curve_data.users == 0:
bpy.data.curves.remove(curve_data)
# Remove the frame_change_post handler so the baked file is
# completely standalone
from .handler import knot_frame_handler
handlers = bpy.app.handlers.frame_change_post
for h in list(handlers):
if getattr(h, "__name__", "") == knot_frame_handler.__name__:
handlers.remove(h)
# -------------------------------------------------------------------
# Phase 5: Pack data and ensure render-farm compatibility
# -------------------------------------------------------------------
# Set output format to PNG image sequence (required by SheepIt)
orig_file_format = scene.render.image_settings.file_format
orig_color_mode = scene.render.image_settings.color_mode
scene.render.image_settings.file_format = 'PNG'
scene.render.image_settings.color_mode = 'RGBA'
if pack_and_clean:
# Make all paths relative — only needed once; subsequent chunks
# operate on the same in-memory data which is already relative.
try:
bpy.ops.file.make_paths_relative()
except RuntimeError:
pass # May fail if file has never been saved
# Pack external data if requested — run once so that all packed
# images are available in memory for every subsequent chunk save.
if self.pack_textures:
try:
bpy.ops.file.pack_all()
except RuntimeError:
self.report({'WARNING'}, "Some external files could not be packed.")
# Remove unused data blocks to reduce file size. Only safe to
# do on the first chunk; later chunks share the same in-memory
# materials/images and we do not want to inadvertently purge data
# that is still referenced by a subsequent chunk's bake.
for mat in list(bpy.data.materials):
if mat.users == 0:
bpy.data.materials.remove(mat)
for m in list(bpy.data.meshes):
if m.users == 0:
bpy.data.meshes.remove(m)
for c in list(bpy.data.curves):
if c.users == 0:
bpy.data.curves.remove(c)
for ng in list(bpy.data.node_groups):
if ng.users == 0:
bpy.data.node_groups.remove(ng)
for img in list(bpy.data.images):
if img.users == 0 and not img.use_fake_user:
bpy.data.images.remove(img)
# -------------------------------------------------------------------
# Phase 6: Save as a COPY — the original session is untouched
# -------------------------------------------------------------------
bpy.ops.wm.save_as_mainfile(filepath=filepath, copy=True, compress=True)
# -------------------------------------------------------------------
# Phase 7: Restore the original session state
# -------------------------------------------------------------------
# We just saved a copy, but the in-memory state has been modified
# (baked objects added, AnimKnot removed, handler removed).
# Restore everything so the user can keep working.
# Restore render settings
scene.render.image_settings.file_format = orig_file_format
scene.render.image_settings.color_mode = orig_color_mode
# Remove all baked objects and their meshes from the current session
for obj, _, _ in baked_objects:
if obj and obj.name in bpy.data.objects:
mesh_data = obj.data
bpy.data.objects.remove(obj, do_unlink=True)
if mesh_data and mesh_data.users == 0:
bpy.data.meshes.remove(mesh_data)
# Remove the baked collection
bake_coll_check = bpy.data.collections.get("BakedAnimation")
if bake_coll_check:
bpy.data.collections.remove(bake_coll_check)
# Remove baked camera shake keyframes (restore static camera)
if cam:
cam_fcurves = _get_action_fcurves(cam)
# cam_fcurves may be a ChannelBag or ActionFCurves — both support .remove()
fcurves_to_remove = [
fc for fc in cam_fcurves
if fc.data_path == "delta_location"
]
for fc in fcurves_to_remove:
try:
cam_fcurves.remove(fc)
except (TypeError, RuntimeError):
pass # Some API versions don't support removal — keyframes will persist
cam.delta_location = (0.0, 0.0, 0.0)
# Re-create the AnimKnot object and re-register the handler
from .geometry import _make_torus_knot
from .materials import prewarm_materials_and_blends
# We need to prewarm materials and blends because removing unused materials might have deleted them
prewarm_materials_and_blends(scene)
# Re-register the handler
handlers = bpy.app.handlers.frame_change_post
already_registered = any(
getattr(h, "__name__", "") == knot_frame_handler.__name__
for h in handlers
)
if not already_registered:
handlers.append(knot_frame_handler)
# Trigger the handler once to rebuild the AnimKnot object
knot_frame_handler(scene)
# Restore the frame
scene.frame_set(original_frame)
return {'FINISHED'}
def draw(self, context):
"""Draw options in the file browser sidebar."""
layout = self.layout
layout.label(text="Bake Options:", icon='SETTINGS')
layout.prop(self, "use_render_resolution")
layout.prop(self, "pack_textures")
layout.separator()
layout.prop(self, "split_export")
if self.split_export:
layout.prop(self, "frames_per_file")
scene = context.scene
total = scene.frame_end - scene.frame_start + 1
layout.separator()
layout.label(text=f"Frames: {scene.frame_start} {scene.frame_end} ({total} total)")
layout.label(text=f"Playlist: {len(scene.knot_list)} knots")
+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
+274
View File
@@ -0,0 +1,274 @@
"""
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,
turbulence: float = 0.0,
smooth_shading: bool = True,
) -> 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')
rP = config.get('torus_rP', 0.0) * math.pi * 2.0
sP = config.get('torus_sP', 0.0) * math.pi * 2.0
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)
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
t_param_rot = t_param + rP
twist_angle = twists * t_param_rot / 2.0 + sP
x = (R + w_eff * math.cos(twist_angle)) * math.cos(t_param_rot)
y = (R + w_eff * math.cos(twist_angle)) * math.sin(t_param_rot)
z = w_eff * math.sin(twist_angle)
elif shape_type == 'LISSAJOUS':
t_param = (i / steps) * TAU
x = amp * math.sin(kx * t_param + rP)
y = amp * math.sin(ky * t_param + (TAU / 4.0) + sP)
z = amp * math.sin(kz * t_param + rP + sP)
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 + rP + sP
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
if turbulence > 0.0:
nx = math.sin(i * 1.345 + rP) * turbulence
ny = math.cos(i * 0.932 - sP) * turbulence
nz = math.sin(i * 1.777 + rP + sP) * turbulence
x += nx
y += ny
z += nz
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)
+304
View File
@@ -0,0 +1,304 @@
"""
handler.py
----------
Frame-change handler and timing utilities.
`knot_frame_handler` is the heart of the animation system. It fires every
frame (registered to `bpy.app.handlers.frame_change_post`) and:
1. Determines which KnotItem is active based on `frames_per_knot * cycle_rate`.
2. Optionally interpolates geometry and material into the next knot during a
transition window.
3. Calls `geometry._make_torus_knot(config, ...)` with explicit resolution /
bevel_resolution / knot_scale values (no bpy.context reads inside the
geometry function).
4. Drives the `_KnotBlendMix.Fac` node on the pre-built blend material to
cross-fade between two shaders during transitions.
"""
from __future__ import annotations
import math
import bpy
from bpy.app.handlers import persistent
from .constants import KNOT_OBJ_NAME
from .geometry import _make_torus_knot
from .materials import get_effective_material
# ---------------------------------------------------------------------------
# Playlist duration helper (also used by operators and UI)
# ---------------------------------------------------------------------------
def compute_playlist_duration(scene) -> int:
"""Return the total number of frames for one full pass through the playlist.
Sums each knot's effective duration (``frames_per_knot * cycle_rate``,
minimum 1 frame per knot). Returns 0 if the playlist is empty.
"""
glob = scene.knot_globals
return sum(
max(1, int(glob.frames_per_knot * scene.knot_list[k].cycle_rate))
for k in range(len(scene.knot_list))
)
# ---------------------------------------------------------------------------
# Easing functions
# ---------------------------------------------------------------------------
def _apply_easing(t: float, mode: str) -> float:
if mode == 'LINEAR':
return t
if mode == 'QUAD_IN_OUT':
return 2.0 * t * t if t < 0.5 else -1.0 + (4.0 - 2.0 * t) * t
if mode == 'SMOOTHSTEP':
return t * t * (3.0 - 2.0 * t)
return t
# ---------------------------------------------------------------------------
# Frame-change handler
# ---------------------------------------------------------------------------
@persistent
def knot_frame_handler(scene, depsgraph=None) -> None:
"""Called after every frame change; rebuilds the knot for the new frame."""
if not hasattr(scene, "knot_list") or len(scene.knot_list) == 0:
return
glob = scene.knot_globals
f = scene.frame_current
# Apply global speed and phase offset to get an effective frame counter.
effective_f = f * glob.global_speed + glob.animation_phase
# Reactivity scales all per-knot animation rate properties.
reactivity = glob.reactivity_factor
# Determine which knot slot is active based on per-knot cycle rate.
total_knots = len(scene.knot_list)
loop_len = compute_playlist_duration(scene)
if loop_len < 1:
loop_len = 1
ef_int = int(effective_f) % loop_len
idx = 0
frames_in = 0
effective_fpk = glob.frames_per_knot
accumulated = 0
for k in range(total_knots):
fpk = max(1, int(glob.frames_per_knot * scene.knot_list[k].cycle_rate))
if ef_int < accumulated + fpk:
idx = k
frames_in = ef_int - accumulated
effective_fpk = fpk
break
accumulated += fpk
else:
# Fallback: clamp to last slot (should never happen after modulo wrap)
idx = total_knots - 1
effective_fpk = max(1, int(glob.frames_per_knot * scene.knot_list[idx].cycle_rate))
frames_in = 0
item = scene.knot_list[idx]
frames_left = effective_fpk - frames_in
# ── Transition factor ────────────────────────────────────────────────────
transition_active = False
t = 0.0
if item.transition_frames > 0 and frames_left <= item.transition_frames:
transition_active = True
t_raw = 1.0 - (frames_left / float(item.transition_frames))
t = _apply_easing(max(0.0, min(1.0, t_raw)), item.transition_easing)
config = item.to_dict()
next_idx = (idx + 1) % total_knots
next_item = scene.knot_list[next_idx]
if transition_active:
next_config = next_item.to_dict()
# Keys managed explicitly below — exclude from generic interpolation.
_ANIMATED_KEYS = {
"torus_sP", "torus_rP", "torus_h", "_scale_override",
"spin_phase_rate", "rev_phase_rate", "height_rate",
"scale_rate", "scale_amplitude",
}
# Interpolate all standard float properties between current and next knot.
for key in list(config):
if key in _ANIMATED_KEYS:
continue
val_a = config[key]
val_b = next_config.get(key, val_a)
if isinstance(val_a, float) and isinstance(val_b, float):
config[key] = val_a + (val_b - val_a) * t
elif t >= 0.5:
config[key] = val_b
# Animated spin phase
sP_a = item.torus_sP + effective_f * item.spin_phase_rate
sP_b = next_item.torus_sP + effective_f * next_item.spin_phase_rate
config["torus_sP"] = sP_a + (sP_b - sP_a) * t
# Animated revolution phase
rP_a = item.torus_rP + effective_f * item.rev_phase_rate
rP_b = next_item.torus_rP + effective_f * next_item.rev_phase_rate
config["torus_rP"] = rP_a + (rP_b - rP_a) * t
# Animated height pulse
h_a = item.torus_h + (
math.sin(effective_f * item.height_rate) * abs(item.height_rate) * 5.0 * reactivity
if item.height_rate != 0.0 else 0.0
)
h_b = next_item.torus_h + (
math.sin(effective_f * next_item.height_rate) * abs(next_item.height_rate) * 5.0 * reactivity
if next_item.height_rate != 0.0 else 0.0
)
config["torus_h"] = h_a + (h_b - h_a) * t
# Animated scale oscillation
sc_a = (
1.0 + item.scale_amplitude * math.sin(effective_f * item.scale_rate) * reactivity
if item.scale_amplitude > 0.0 and item.scale_rate != 0.0 else None
)
sc_b = (
1.0 + next_item.scale_amplitude * math.sin(effective_f * next_item.scale_rate) * reactivity
if next_item.scale_amplitude > 0.0 and next_item.scale_rate != 0.0 else None
)
if sc_a is not None or sc_b is not None:
sa = sc_a if sc_a is not None else 1.0
sb = sc_b if sc_b is not None else 1.0
config["_scale_override"] = sa + (sb - sa) * t
else:
# Apply animated rates with reactivity (no transition)
if item.spin_phase_rate != 0.0:
config["torus_sP"] += effective_f * item.spin_phase_rate
if item.rev_phase_rate != 0.0:
config["torus_rP"] += effective_f * item.rev_phase_rate
if item.height_rate != 0.0:
config["torus_h"] += (
math.sin(effective_f * item.height_rate) * abs(item.height_rate) * 5.0 * reactivity
)
if item.scale_amplitude > 0.0 and item.scale_rate != 0.0:
config["_scale_override"] = (
1.0 + item.scale_amplitude * math.sin(effective_f * item.scale_rate) * reactivity
)
# ── Apply Attenuverter Modulations ───────────────────────────────────────
# Float mods
for key in [
"torus_R", "torus_r", "torus_eR", "torus_iR", "torus_h",
"mobius_width", "liss_amp", "spiral_R",
"geo_extrude", "geo_offset", "geo_bDepth"
]:
if f"mod_{key}" in config and config[f"mod_{key}"] != 0.0:
config[key] += config[f"mod_{key}"] * reactivity
# Integer mods (clamp to minimum 1 for topology safety)
for key in [
"torus_p", "torus_q", "mobius_twists",
"liss_kx", "liss_ky", "liss_kz", "spiral_turns"
]:
if f"mod_{key}" in config and config[f"mod_{key}"] != 0.0:
config[key] = max(1, config[key] + int(config[f"mod_{key}"] * reactivity))
# ── Cross-material blend ─────────────────────────────────────────────────
cross_material = False
if transition_active:
mat_a = get_effective_material(item)
mat_b = get_effective_material(next_item)
cross_material = mat_a != mat_b
if cross_material:
config["_skip_material"] = True
# ── Apply Global Settings & Overrides ────────────────────────────────────
is_render = False
if depsgraph is not None and getattr(depsgraph, "mode", 'VIEWPORT') == 'RENDER':
is_render = True
res = glob.render_resolution if is_render else glob.preview_resolution
bevel_res = glob.render_bevel_resolution if is_render else glob.preview_bevel_resolution
if "geo_bDepth" in config:
config["geo_bDepth"] *= glob.global_master_thickness
if "preset_emission_strength" in config:
config["preset_emission_strength"] *= glob.global_emission_multiplier
if "preset_color" in config and glob.global_hue_shift != 0.0:
import colorsys
color_val = config["preset_color"]
if len(color_val) == 4:
r, g, b, a = color_val
else:
r, g, b = color_val
a = 1.0
h_hsv, s, v = colorsys.rgb_to_hsv(r, g, b)
h_hsv = (h_hsv + glob.global_hue_shift) % 1.0
nr, ng, nb = colorsys.hsv_to_rgb(h_hsv, s, v)
if len(color_val) == 4:
config["preset_color"] = (nr, ng, nb, a)
else:
config["preset_color"] = (nr, ng, nb)
# Resolve globals once and pass explicitly — no bpy.context inside geometry
_make_torus_knot(
config,
resolution=res,
bevel_resolution=bevel_res,
knot_scale=glob.knot_scale,
scene=scene,
turbulence=glob.global_turbulence,
smooth_shading=glob.smooth_shading,
)
if cross_material:
blend_name = f"KnotBlend_Item_{idx}"
blend_mat = bpy.data.materials.get(blend_name)
blend_obj = bpy.data.objects.get(KNOT_OBJ_NAME)
if blend_obj:
if blend_mat:
mix_node = blend_mat.node_tree.nodes.get("_KnotBlendMix")
if mix_node:
mix_node.inputs["Fac"].default_value = float(t)
if len(blend_obj.data.materials) == 0:
blend_obj.data.materials.append(blend_mat)
else:
blend_obj.data.materials[0] = blend_mat
elif mat_a:
# Fallback if blend material wasn't generated
if len(blend_obj.data.materials) == 0:
blend_obj.data.materials.append(mat_a)
else:
blend_obj.data.materials[0] = mat_a
# ── Post-Geometry Object Overrides ───────────────────────────────────────
knot_obj = bpy.data.objects.get(KNOT_OBJ_NAME)
if knot_obj:
knot_obj.display_type = 'WIRE' if glob.viewport_wireframe else 'TEXTURED'
knot_obj.rotation_euler[2] = effective_f * glob.auto_turntable_speed
# ── Camera Shake ─────────────────────────────────────────────────────────
cam = scene.camera
if cam:
shake = glob.camera_shake_amplitude
if shake > 0.0:
import random
# Deterministic noise based on frame for stable rendering
random.seed(int(f * 1000))
cam.delta_location = (
random.uniform(-shake, shake),
random.uniform(-shake, shake),
random.uniform(-shake, shake)
)
else:
cam.delta_location = (0.0, 0.0, 0.0)
+710
View File
@@ -0,0 +1,710 @@
"""
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
prin.inputs["Specular IOR Level"].default_value = 1.0
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
prin.inputs["Transmission Weight"].default_value = 1.0
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
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
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
prin.inputs["Transmission Weight"].default_value = 1.0
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
prin.inputs["Transmission Weight"].default_value = 1.0
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
prin.inputs["Coat Weight"].default_value = 1.0
prin.inputs["Coat 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)
+344
View File
@@ -0,0 +1,344 @@
"""
operators.py
------------
All KNOT_OT_* operator classes for knot_animation.
P3 fix — module-level randomisation helpers
--------------------------------------------
The four helpers that were previously defined as nested closures inside
``KNOT_OT_GenerateRandom.execute()`` are now module-level functions. They
take ``gen`` (the KnotGeneratorSettings) explicitly, making them independently
callable and testable.
"""
from __future__ import annotations
import random
import bpy
from .constants import KNOT_CONFIGS, SHADER_BLEND_MAT_NAME
from .materials import (
SHADER_IDS,
prebuild_playlist_blend_materials,
prewarm_materials_and_blends,
_ensure_item_preset_material,
)
from .geometry import _make_torus_knot
from .handler import compute_playlist_duration
# ---------------------------------------------------------------------------
# Module-level randomisation helpers (P3)
# ---------------------------------------------------------------------------
def _rand_apply_int(gen, item, prop: str) -> None:
"""Set item.{prop} to a random int in [gen.min_{prop}, gen.max_{prop}]
if gen.r_{prop} is True."""
if getattr(gen, f"r_{prop}"):
setattr(item, prop, random.randint(
getattr(gen, f"min_{prop}"),
getattr(gen, f"max_{prop}"),
))
def _rand_apply_float(gen, item, prop: str) -> None:
"""Set item.{prop} to a random float in [gen.min_{prop}, gen.max_{prop}]
if gen.r_{prop} is True."""
if getattr(gen, f"r_{prop}"):
setattr(item, prop, random.uniform(
getattr(gen, f"min_{prop}"),
getattr(gen, f"max_{prop}"),
))
def _rand_apply_bool(gen, item, prop: str) -> None:
"""Set item.{prop} to True/False with probability gen.prob_{prop}
if gen.r_{prop} is True."""
if getattr(gen, f"r_{prop}"):
prob = getattr(gen, f"prob_{prop}", 0.5)
setattr(item, prop, random.random() < prob)
def _rand_apply_choice(gen, item, prop: str, choices: list) -> None:
"""Set item.{prop} to a random element of choices if gen.r_{prop} is True."""
if getattr(gen, f"r_{prop}"):
setattr(item, prop, random.choice(choices))
# ---------------------------------------------------------------------------
# Operators
# ---------------------------------------------------------------------------
class KNOT_OT_Add(bpy.types.Operator):
bl_idname = "knot.add_item"
bl_label = "Add Knot"
bl_description = "Add a new knot entry to the end of the playlist"
def execute(self, context):
item = context.scene.knot_list.add()
item.uid = f"knot_{random.randint(100000, 999999)}"
item.name = f"Knot {len(context.scene.knot_list)}"
item.material_mode = 'PRESET'
context.scene.knot_list_index = len(context.scene.knot_list) - 1
prebuild_playlist_blend_materials(context.scene)
return {'FINISHED'}
class KNOT_OT_Remove(bpy.types.Operator):
bl_idname = "knot.remove_item"
bl_label = "Remove Knot"
bl_description = "Remove the selected knot entry from the playlist"
def execute(self, context):
idx = context.scene.knot_list_index
if 0 <= idx < len(context.scene.knot_list):
context.scene.knot_list.remove(idx)
if idx > 0:
context.scene.knot_list_index = idx - 1
prebuild_playlist_blend_materials(context.scene)
return {'FINISHED'}
class KNOT_OT_Move(bpy.types.Operator):
bl_idname = "knot.move_item"
bl_label = "Move Knot"
bl_description = "Move the selected knot up or down in the playlist order"
direction: bpy.props.EnumProperty(items=[('UP', 'Up', 'Move knot earlier in the playlist'), ('DOWN', 'Down', 'Move knot later in the playlist')])
def execute(self, context):
idx = context.scene.knot_list_index
new_idx = idx - 1 if self.direction == 'UP' else idx + 1
if 0 <= new_idx < len(context.scene.knot_list):
context.scene.knot_list.move(idx, new_idx)
context.scene.knot_list_index = new_idx
prebuild_playlist_blend_materials(context.scene)
return {'FINISHED'}
class KNOT_OT_Populate(bpy.types.Operator):
bl_idname = "knot.populate"
bl_label = "Populate Default List"
bl_description = "Clear the playlist and load the 10 built-in example knots (trefoil, cinquefoil, Möbius, Lissajous, and more)"
def execute(self, context):
context.scene.knot_list.clear()
for i, config in enumerate(KNOT_CONFIGS):
item = context.scene.knot_list.add()
item.uid = f"knot_{random.randint(100000, 999999)}"
item.name = config.get("name", f"Knot {i+1}")
item.material_mode = 'PRESET'
for k, v in config.items():
if k == "name": continue
if hasattr(item, k):
setattr(item, k, v)
context.scene.knot_list_index = 0
prewarm_materials_and_blends(context.scene)
return {'FINISHED'}
class KNOT_OT_BakePreview(bpy.types.Operator):
bl_idname = "knot.bake_preview"
bl_label = "Update Preview"
bl_description = "Immediately regenerate the viewport knot using the currently selected playlist entry's settings, without waiting for a frame change"
def execute(self, context):
idx = context.scene.knot_list_index
if 0 <= idx < len(context.scene.knot_list):
config = context.scene.knot_list[idx].to_dict()
glob = context.scene.knot_globals
_make_torus_knot(
config,
resolution=glob.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"
bl_description = "Clear the playlist and fill it with randomly configured knots using the ranges and toggles set in the Randomise Toggles section below"
def execute(self, context):
scene = context.scene
gen = scene.knot_generator
base = gen.base_knot
scene.knot_list.clear()
for i in range(gen.num_knots):
item = scene.knot_list.add()
item.name = f"Random Knot {i+1}"
# Copy base defaults
for k, v in base.to_dict().items():
if hasattr(item, k):
setattr(item, k, v)
# Apply randomisations using module-level helpers (P3)
if gen.r_shape_type:
_rand_apply_choice(gen, item, "shape_type", ['TORUS_KNOT', 'MOBIUS', 'LISSAJOUS', 'SPIRAL'])
_rand_apply_int(gen, item, "torus_p")
_rand_apply_int(gen, item, "torus_q")
_rand_apply_int(gen, item, "mobius_twists")
_rand_apply_int(gen, item, "liss_kx")
_rand_apply_int(gen, item, "liss_ky")
_rand_apply_int(gen, item, "liss_kz")
_rand_apply_int(gen, item, "spiral_turns")
_rand_apply_int(gen, item, "torus_u")
_rand_apply_int(gen, item, "torus_v")
_rand_apply_int(gen, item, "transition_frames")
_rand_apply_float(gen, item, "torus_R")
_rand_apply_float(gen, item, "torus_r")
_rand_apply_float(gen, item, "mobius_width")
_rand_apply_float(gen, item, "liss_amp")
_rand_apply_float(gen, item, "spiral_R")
_rand_apply_float(gen, item, "torus_eR")
_rand_apply_float(gen, item, "torus_iR")
_rand_apply_float(gen, item, "geo_extrude")
_rand_apply_float(gen, item, "geo_offset")
_rand_apply_float(gen, item, "geo_bDepth")
_rand_apply_float(gen, item, "torus_h")
_rand_apply_float(gen, item, "torus_rP")
_rand_apply_float(gen, item, "torus_sP")
_rand_apply_float(gen, item, "spin_phase_rate")
_rand_apply_float(gen, item, "rev_phase_rate")
_rand_apply_float(gen, item, "height_rate")
_rand_apply_float(gen, item, "scale_rate")
_rand_apply_float(gen, item, "scale_amplitude")
_rand_apply_float(gen, item, "cycle_rate")
if getattr(gen, "r_modulations", False):
import random
for k in [
"mod_torus_R", "mod_torus_r", "mod_torus_eR", "mod_torus_iR", "mod_torus_h",
"mod_mobius_width", "mod_liss_amp", "mod_spiral_R",
"mod_geo_extrude", "mod_geo_offset", "mod_geo_bDepth"
]:
setattr(item, k, random.uniform(-0.5, 0.5))
for k in [
"mod_torus_p", "mod_torus_q", "mod_mobius_twists",
"mod_liss_kx", "mod_liss_ky", "mod_liss_kz", "mod_spiral_turns"
]:
setattr(item, k, random.uniform(-2.0, 2.0))
_rand_apply_bool(gen, item, "multiple_links")
_rand_apply_bool(gen, item, "use_colors")
_rand_apply_bool(gen, item, "random_colors")
_rand_apply_bool(gen, item, "flip_p")
_rand_apply_bool(gen, item, "flip_q")
if gen.r_colorSet:
item.colorSet = '2' if random.random() < gen.prob_colorSet else '1'
if gen.r_mode:
item.mode = 'EXT_INT' if random.random() < gen.prob_mode else 'MAJOR_MINOR'
_rand_apply_choice(gen, item, "transition_easing",
['LINEAR', 'QUAD_IN_OUT', 'SMOOTHSTEP'])
item.uid = f"knot_{random.randint(100000, 999999)}"
if gen.r_material:
enabled_items = [am for am in gen.allowed_materials if am.enabled]
if enabled_items:
chosen = random.choice(enabled_items)
if chosen.is_preset:
item.material_mode = 'PRESET'
item.shader_id = chosen.preset_id
if getattr(gen, "r_preset_params", True):
item.preset_color = (random.random(), random.random(), random.random())
item.preset_roughness = random.uniform(0.0, 1.0)
item.preset_metallic = random.uniform(0.0, 1.0)
item.preset_emission_strength = random.uniform(0.5, 5.0)
else:
item.material_mode = 'PROJECT'
item.project_material = chosen.material
else:
item.material_mode = 'PRESET'
item.shader_id = 'GLOSS_BLUE'
scene.knot_list_index = 0
prewarm_materials_and_blends(scene)
return {'FINISHED'}
class KNOT_OT_FitTimeline(bpy.types.Operator):
"""Set frame_end so the full playlist fits exactly once in the timeline."""
bl_idname = "knot.fit_timeline"
bl_label = "Fit Timeline to Playlist"
bl_description = "Extend Total Frames so every knot plays through once"
def execute(self, context):
scene = context.scene
glob = scene.knot_globals
total = compute_playlist_duration(scene) or glob.frames_per_knot
scene.frame_end = max(scene.frame_start, total)
return {'FINISHED'}
class KNOT_OT_FitPlaylist(bpy.types.Operator):
"""Adjust frames_per_knot so the playlist fills the current frame_end exactly."""
bl_idname = "knot.fit_playlist"
bl_label = "Fit Playlist to Timeline"
bl_description = "Shrink/grow Frames per Knot so all knots fill the current Total Frames"
def execute(self, context):
scene = context.scene
glob = scene.knot_globals
n = len(scene.knot_list)
if n < 1:
self.report({'WARNING'}, "Playlist is empty")
return {'CANCELLED'}
avg_rate = sum(scene.knot_list[k].cycle_rate for k in range(n)) / n
avg_rate = max(0.01, avg_rate)
duration = scene.frame_end - scene.frame_start + 1
glob.frames_per_knot = max(1, int(duration / (n * avg_rate)))
return {'FINISHED'}
+557
View File
@@ -0,0 +1,557 @@
"""
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)}"
try:
scene = getattr(context, "scene", None)
if scene is None:
return
if self.material_mode == 'PRESET':
_ensure_item_preset_material(self)
# Rebuild blend materials to keep transitions up to date
prebuild_playlist_blend_materials(scene)
# Instantly update the active viewport knot's material
_update_viewport_knot_material(scene)
# Force redraw of 3-D viewport if screen exists
screen = getattr(context, "screen", None)
if screen:
for area in screen.areas:
if area.type == 'VIEW_3D':
area.tag_redraw()
except Exception:
# Safe fallback: if this callback fires during depsgraph evaluation
# (e.g., driven by an F-curve), context accesses will throw internal state bugs.
pass
# ---------------------------------------------------------------------------
# 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,
description="Base display duration (in frames) for each playlist entry. Each knot's effective duration is frames_per_knot × cycle_rate")
preview_resolution: bpy.props.IntProperty(
name="Preview Curve Res", default=64, min=3, max=1024,
description="NURBS subdivision used in the viewport. Higher values produce a smoother curve but update more slowly")
render_resolution: bpy.props.IntProperty(
name="Render Curve Res", default=128, min=3, max=2048,
description="NURBS subdivision used during renders and baking. Higher values produce higher quality output")
preview_bevel_resolution: bpy.props.IntProperty(
name="Preview Bevel Res", default=4, min=0, max=64,
description="Number of sides on the tube cross-section in the viewport")
render_bevel_resolution: bpy.props.IntProperty(
name="Render Bevel Res", default=8, min=0, max=64,
description="Number of sides on the tube cross-section during renders and baking")
knot_scale: bpy.props.FloatProperty(
name="Global Scale", default=1.0, min=0.01,
description="Uniform scale multiplier applied to all knots")
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.")
# New 8 Global Variables
global_turbulence: bpy.props.FloatProperty(
name="Turbulence (Noise)", default=0.0, min=0.0,
description="Injects 3D noise/displacement into the final curve vertices.")
global_emission_multiplier: bpy.props.FloatProperty(
name="Emission Multiplier", default=1.0, min=0.0,
description="Master scalar for the shader's emission strength.")
global_master_thickness: bpy.props.FloatProperty(
name="Master Thickness", default=1.0, min=0.0,
description="Multiplier for the knot's bevel depth.")
global_hue_shift: bpy.props.FloatProperty(
name="Hue Shift", default=0.0,
description="Offset applied to the material's color hue.")
camera_shake_amplitude: bpy.props.FloatProperty(
name="Camera Shake Amp", default=0.0, min=0.0,
description="Injects X/Y/Z jitter into the active camera's location.")
auto_turntable_speed: bpy.props.FloatProperty(
name="Turntable Speed", default=0.0,
description="Constant Z-axis rotation speed applied to the master knot object.")
smooth_shading: bpy.props.BoolProperty(
name="Smooth Shading", default=True,
description="Enforce Smooth shading on the generated mesh.")
viewport_wireframe: bpy.props.BoolProperty(
name="Viewport Wireframe", default=False,
description="Force the object to display as wireframe in the 3D viewport.")
# UI expand state
ui_show_global: bpy.props.BoolProperty(name="Global Settings", default=True)
ui_show_playback: bpy.props.BoolProperty(name="Playback", default=True)
ui_show_playlist: bpy.props.BoolProperty(name="Playlist", default=True)
ui_show_knot_edit: bpy.props.BoolProperty(name="Selected Knot", default=True)
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",
description="Parametric curve type used to generate this knot",
items=[
('TORUS_KNOT', "Torus Knot", "Classic (p,q) torus knot wound around a torus surface"),
('MOBIUS', "Mobius Strip", "One-sided surface with a half-twist — non-orientable loop"),
('LISSAJOUS', "Lissajous 3D", "3-axis frequency-ratio figure trace"),
('SPIRAL', "Spherical Spiral", "Spherical spiral (loxodrome) with configurable turns and radius"),
],
default='TORUS_KNOT'
)
# Topology (Torus Knot)
torus_p: bpy.props.IntProperty(
name="Revolutions (p)", default=2, min=1,
description="Number of revolutions around the torus axis. Must be coprime with q for a true knot")
mod_torus_p: bpy.props.FloatProperty(
name="Mod p", default=0.0,
description="Per-frame attenuverter offset applied to p before rendering")
torus_q: bpy.props.IntProperty(
name="Spins (q)", default=3, min=1,
description="Number of spins around the torus tube. Must be coprime with p for a true knot")
mod_torus_q: bpy.props.FloatProperty(
name="Mod q", default=0.0,
description="Per-frame attenuverter offset applied to q before rendering")
# Topology (Mobius)
mobius_twists: bpy.props.IntProperty(name="Half Twists", default=1, min=1)
mod_mobius_twists: bpy.props.FloatProperty(name="Mod Twists", default=0.0)
mobius_width: bpy.props.FloatProperty(name="Width", default=1.0, min=0.1)
mod_mobius_width: bpy.props.FloatProperty(name="Mod Width", default=0.0)
# Topology (Lissajous 3D)
liss_kx: bpy.props.IntProperty(name="kx (Freq X)", default=3, min=1)
mod_liss_kx: bpy.props.FloatProperty(name="Mod kx", default=0.0)
liss_ky: bpy.props.IntProperty(name="ky (Freq Y)", default=2, min=1)
mod_liss_ky: bpy.props.FloatProperty(name="Mod ky", default=0.0)
liss_kz: bpy.props.IntProperty(name="kz (Freq Z)", default=4, min=1)
mod_liss_kz: bpy.props.FloatProperty(name="Mod kz", default=0.0)
liss_amp: bpy.props.FloatProperty(name="Amplitude", default=2.0, min=0.1)
mod_liss_amp: bpy.props.FloatProperty(name="Mod Amp", default=0.0)
# Topology (Spherical Spiral)
spiral_turns: bpy.props.IntProperty(name="Turns", default=10, min=1)
mod_spiral_turns: bpy.props.FloatProperty(name="Mod Turns", default=0.0)
spiral_R: bpy.props.FloatProperty(name="Radius", default=2.0, min=0.1)
mod_spiral_R: bpy.props.FloatProperty(name="Mod Radius", default=0.0)
# Radii (Torus Knot)
torus_R: bpy.props.FloatProperty(
name="Major", default=2.0, min=0.0,
description="Distance from the centre of the torus to the centre of the tube")
mod_torus_R: bpy.props.FloatProperty(
name="Mod Major", default=0.0,
description="Per-frame attenuverter offset applied to the major radius")
torus_r: bpy.props.FloatProperty(
name="Minor", default=1.0, min=0.0,
description="Radius of the tube itself")
mod_torus_r: bpy.props.FloatProperty(
name="Mod Minor", default=0.0,
description="Per-frame attenuverter offset applied to the minor radius")
# Colors (legacy TKP path)
multiple_links: bpy.props.BoolProperty(
name="Multiple Links", default=False,
description="Render all gcd(p,q) link components as separate curves")
use_colors: bpy.props.BoolProperty(
name="Use Colors", default=False,
description="Apply per-link vertex colors to the curve (legacy TKP color mode)")
colorSet: bpy.props.EnumProperty(
name="Color Set",
description="Which built-in vertex color palette to use",
items=[('1', "RGBish", "Red/Green/Blue-based palette"), ('2', "Rainbow", "Full-spectrum rainbow palette")],
default='1')
random_colors: bpy.props.BoolProperty(
name="Randomize Colors", default=False,
description="Shuffle the vertex color assignment randomly each frame")
# Multipliers & phases
torus_u: bpy.props.IntProperty(
name="Rev. Multiplier", default=1, min=1,
description="Repeats the revolution pattern — creates multiple overlapping copies of the knot")
torus_v: bpy.props.IntProperty(
name="Spin Multiplier", default=1, min=1,
description="Repeats the spin pattern around the tube")
torus_rP: bpy.props.FloatProperty(
name="Rev. Phase", default=0.0,
description="Revolution phase offset in radians (orbit rotation)")
torus_sP: bpy.props.FloatProperty(
name="Spin Phase", default=0.0,
description="Spin phase offset in radians (tube rotation)")
# 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)",
update=_update_knot_material_cb)
transition_easing: bpy.props.EnumProperty(
name="Easing",
description="Interpolation curve applied during the morph window",
items=[
('LINEAR', "Linear", "Constant-rate blend between the two knots"),
('QUAD_IN_OUT', "Quadratic In/Out", "Slow start and end, faster in the middle"),
('SMOOTHSTEP', "Smoothstep", "S-curve blend — smooth at both endpoints"),
],
default='QUAD_IN_OUT')
# Geometry
geo_extrude: bpy.props.FloatProperty(
name="Extrude", default=0.0, min=0.0,
description="Extrude the curve profile outward — creates a ribbon effect when combined with Offset")
mod_geo_extrude: bpy.props.FloatProperty(
name="Mod Extrude", default=0.0,
description="Per-frame attenuverter offset applied to Extrude")
geo_offset: bpy.props.FloatProperty(
name="Offset", default=0.0,
description="Offset the extruded profile from the curve centreline")
mod_geo_offset: bpy.props.FloatProperty(
name="Mod Offset", default=0.0,
description="Per-frame attenuverter offset applied to Offset")
geo_bDepth: bpy.props.FloatProperty(
name="Bevel Depth", default=0.04, min=0.0,
description="Tube thickness — controls how wide the knot strand is")
mod_geo_bDepth: bpy.props.FloatProperty(
name="Mod BDepth", default=0.0,
description="Per-frame attenuverter offset applied to Bevel Depth")
# Dimensions mode
mode: bpy.props.EnumProperty(
name="Dimensions Mode",
description="Choose how to specify the torus radii",
items=[
('MAJOR_MINOR', "Major/Minor", "Specify inner and outer radius as Major and Minor"),
('EXT_INT', "Exterior/Interior", "Specify the outer edge and the hole as Exterior and Interior radii"),
],
default='MAJOR_MINOR')
torus_eR: bpy.props.FloatProperty(
name="Exterior", default=3.0, min=0.0,
description="Outer edge radius of the torus (Exterior/Interior mode)")
mod_torus_eR: bpy.props.FloatProperty(
name="Mod Ext", default=0.0,
description="Per-frame attenuverter offset applied to Exterior radius")
torus_iR: bpy.props.FloatProperty(
name="Interior", default=1.0, min=0.0,
description="Inner hole radius of the torus (Exterior/Interior mode)")
mod_torus_iR: bpy.props.FloatProperty(
name="Mod Int", default=0.0,
description="Per-frame attenuverter offset applied to Interior radius")
torus_h: bpy.props.FloatProperty(
name="Height", default=1.0, min=0.0,
description="Vertical stretch of the torus — values above 1.0 elongate the knot")
mod_torus_h: bpy.props.FloatProperty(
name="Mod Height", default=0.0,
description="Per-frame attenuverter offset applied to Height")
# Direction
flip_p: bpy.props.BoolProperty(
name="Flip p", default=False,
description="Reverse the direction of the p (revolution) winding")
flip_q: bpy.props.BoolProperty(
name="Flip q", default=False,
description="Reverse the direction of the q (spin) winding")
# UI expand state (display-only, not serialised to animation)
ui_show_shape: bpy.props.BoolProperty(name="Shape", default=True)
ui_show_geometry: bpy.props.BoolProperty(name="Geometry", default=False)
ui_show_links: bpy.props.BoolProperty(name="Links & Phases", default=False)
ui_show_anim: bpy.props.BoolProperty(name="Animation Rates", default=True)
ui_show_material: bpy.props.BoolProperty(name="Material", default=True)
ui_show_colors: bpy.props.BoolProperty(name="Colors", default=False)
ui_show_trans: bpy.props.BoolProperty(name="Transition", default=False)
# Material
material_mode: bpy.props.EnumProperty(
name="Material Mode",
description="Choose between a built-in shader preset or an existing material from the project",
items=[
('PRESET', "Shader Preset", "Use one of the 20 built-in procedural shader presets"),
('PROJECT', "Project Material", "Use any material already in this .blend file"),
],
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,
description="Base colour tint passed into the selected shader preset",
update=_update_knot_material_cb)
preset_roughness: bpy.props.FloatProperty(
name="Preset Roughness", default=0.1, min=0.0, max=1.0,
description="Roughness override for the active preset (0 = mirror-smooth, 1 = fully diffuse)",
update=_update_knot_material_cb)
preset_metallic: bpy.props.FloatProperty(
name="Preset Metallic", default=0.0, min=0.0, max=1.0,
description="Metallic factor override for the active preset (0 = dielectric, 1 = metal)",
update=_update_knot_material_cb)
preset_emission_strength: bpy.props.FloatProperty(
name="Preset Emission Strength", default=1.0, min=0.0, max=100.0,
description="Emission strength override for presets that emit light",
update=_update_knot_material_cb)
def to_dict(self) -> dict:
return {
"shape_type": self.shape_type,
"torus_p": self.torus_p,
"mod_torus_p": self.mod_torus_p,
"torus_q": self.torus_q,
"mod_torus_q": self.mod_torus_q,
"mobius_twists": self.mobius_twists,
"mod_mobius_twists": self.mod_mobius_twists,
"mobius_width": self.mobius_width,
"mod_mobius_width": self.mod_mobius_width,
"liss_kx": self.liss_kx,
"mod_liss_kx": self.mod_liss_kx,
"liss_ky": self.liss_ky,
"mod_liss_ky": self.mod_liss_ky,
"liss_kz": self.liss_kz,
"mod_liss_kz": self.mod_liss_kz,
"liss_amp": self.liss_amp,
"mod_liss_amp": self.mod_liss_amp,
"spiral_turns": self.spiral_turns,
"mod_spiral_turns": self.mod_spiral_turns,
"spiral_R": self.spiral_R,
"mod_spiral_R": self.mod_spiral_R,
"torus_R": self.torus_R,
"mod_torus_R": self.mod_torus_R,
"torus_r": self.torus_r,
"mod_torus_r": self.mod_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_modulations: bpy.props.BoolProperty(name="Randomize Mods", default=True, description="Add random attenuverter modulations to the generated knots")
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)
# UI expand state for generator rand sub-boxes
ui_show_rand_shape: bpy.props.BoolProperty(name="Shape", default=True)
ui_show_rand_geo: bpy.props.BoolProperty(name="Geometry", default=False)
ui_show_rand_anim: bpy.props.BoolProperty(name="Animation", default=True)
ui_show_rand_mat: bpy.props.BoolProperty(name="Material", default=True)
ui_show_base: bpy.props.BoolProperty(name="Base Knot Defaults", default=False)
ui_show_generator: bpy.props.BoolProperty(name="Random Generator", default=True)
ui_show_rand_toggles: bpy.props.BoolProperty(name="Randomise Toggles", default=True)
# ── 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.")
+113
View File
@@ -0,0 +1,113 @@
"""
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
mod_torus_p: float
torus_q: int
mod_torus_q: float
flip_p: bool
flip_q: bool
multiple_links: bool
# ── Topology (Mobius) ────────────────────────────────────────────────────
mobius_twists: int
mod_mobius_twists: float
mobius_width: float
mod_mobius_width: float
# ── Topology (Lissajous 3D) ──────────────────────────────────────────────
liss_kx: int
mod_liss_kx: float
liss_ky: int
mod_liss_ky: float
liss_kz: int
mod_liss_kz: float
liss_amp: float
mod_liss_amp: float
# ── Topology (Spherical Spiral) ──────────────────────────────────────────
spiral_turns: int
mod_spiral_turns: float
spiral_R: float
mod_spiral_R: float
# ── Radii (Major/Minor mode) ──────────────────────────────────────────────
torus_R: float
mod_torus_R: float
torus_r: float
mod_torus_r: float
# ── Radii (Ext/Int mode) ─────────────────────────────────────────────────
mode: str # 'MAJOR_MINOR' | 'EXT_INT'
torus_eR: float
mod_torus_eR: float
torus_iR: float
mod_torus_iR: float
# ── Multipliers & phases ──────────────────────────────────────────────────
torus_u: int
torus_v: int
torus_rP: float
torus_sP: float
torus_h: float
mod_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
mod_geo_extrude: float
geo_offset: float
mod_geo_offset: float
geo_bDepth: float
mod_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
+424
View File
@@ -0,0 +1,424 @@
"""
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 (collapsible sections)
KNOT_PT_Panel — main N-panel (VIEW_3D > UI > Pr3tz)
Every major block is collapsible via per-item or per-settings ui_show_* flags.
KnotItem flags are per-item so each playlist entry remembers its own state.
"""
from __future__ import annotations
import bpy
from .handler import compute_playlist_duration
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _section(layout, item, flag: str, label: str, icon: str = 'NONE'):
"""Draw a collapsible box section. Returns (box, is_open)."""
box = layout.box()
row = box.row()
row.prop(item, flag,
icon='TRIA_DOWN' if getattr(item, flag) else 'TRIA_RIGHT',
icon_only=True, emboss=False)
row.label(text=label, icon=icon)
return box, getattr(item, flag)
def _subsection(layout, item, flag: str, label: str, icon: str = 'NONE'):
"""Like _section but without an outer box — for nesting inside existing boxes."""
row = layout.row()
row.prop(item, flag,
icon='TRIA_DOWN' if getattr(item, flag) else 'TRIA_RIGHT',
icon_only=True, emboss=False)
row.label(text=label, icon=icon)
return getattr(item, flag)
def draw_prop_with_mod(layout, item, prop: str, mod_prop: str):
"""Draw a split row with the base property and its attenuverter (Mod)."""
split = layout.split(factor=0.6)
split.prop(item, prop)
split.prop(item, mod_prop)
# ---------------------------------------------------------------------------
# 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 using collapsible per-item sections."""
# ── Shape ──────────────────────────────────────────────────────────────
box, open_ = _section(layout, item, "ui_show_shape", "Shape", 'CURVE_PATH')
if open_:
box.prop(item, "shape_type")
if item.shape_type == 'TORUS_KNOT':
draw_prop_with_mod(box, item, "torus_p", "mod_torus_p")
draw_prop_with_mod(box, item, "torus_q", "mod_torus_q")
row = box.row(align=True)
row.prop(item, "flip_p", toggle=True, icon='ARROW_LEFTRIGHT')
row.prop(item, "flip_q", toggle=True, icon='ARROW_LEFTRIGHT')
box.prop(item, "mode")
if item.mode == 'MAJOR_MINOR':
draw_prop_with_mod(box, item, "torus_R", "mod_torus_R")
draw_prop_with_mod(box, item, "torus_r", "mod_torus_r")
else:
draw_prop_with_mod(box, item, "torus_eR", "mod_torus_eR")
draw_prop_with_mod(box, item, "torus_iR", "mod_torus_iR")
elif item.shape_type == 'MOBIUS':
draw_prop_with_mod(box, item, "mobius_twists", "mod_mobius_twists")
draw_prop_with_mod(box, item, "mobius_width", "mod_mobius_width")
elif item.shape_type == 'LISSAJOUS':
draw_prop_with_mod(box, item, "liss_kx", "mod_liss_kx")
draw_prop_with_mod(box, item, "liss_ky", "mod_liss_ky")
draw_prop_with_mod(box, item, "liss_kz", "mod_liss_kz")
draw_prop_with_mod(box, item, "liss_amp", "mod_liss_amp")
elif item.shape_type == 'SPIRAL':
draw_prop_with_mod(box, item, "spiral_turns", "mod_spiral_turns")
draw_prop_with_mod(box, item, "spiral_R", "mod_spiral_R")
# ── Geometry ────────────────────────────────────────────────────────────
box, open_ = _section(layout, item, "ui_show_geometry", "Geometry", 'MESH_DATA')
if open_:
draw_prop_with_mod(box, item, "geo_extrude", "mod_geo_extrude")
draw_prop_with_mod(box, item, "geo_offset", "mod_geo_offset")
draw_prop_with_mod(box, item, "geo_bDepth", "mod_geo_bDepth")
draw_prop_with_mod(box, item, "torus_h", "mod_torus_h")
# ── Links & Phases ───────────────────────────────────────────────────────
box, open_ = _section(layout, item, "ui_show_links", "Links & Phases", 'LINKED')
if open_:
box.prop(item, "multiple_links")
col = box.column(align=True)
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")
# ── Animation Rates ──────────────────────────────────────────────────────
box, open_ = _section(layout, item, "ui_show_anim", "Animation Rates", 'ANIM')
if open_:
box.prop(item, "cycle_rate")
col = box.column(align=True)
row = col.row(align=True)
row.prop(item, "spin_phase_rate")
row.prop(item, "rev_phase_rate")
row = col.row(align=True)
row.prop(item, "height_rate")
row = col.row(align=True)
row.prop(item, "scale_rate")
row.prop(item, "scale_amplitude")
# ── Material ─────────────────────────────────────────────────────────────
box, open_ = _section(layout, item, "ui_show_material", "Material", 'MATERIAL')
if open_:
box.prop(item, "material_mode", expand=True)
if item.material_mode == 'PRESET':
box.prop(item, "shader_id")
col = box.column(align=True)
col.prop(item, "preset_color")
row = col.row(align=True)
row.prop(item, "preset_roughness", slider=True)
row.prop(item, "preset_metallic", slider=True)
col.prop(item, "preset_emission_strength")
else:
box.prop(item, "project_material", text="Material")
# ── Colors ───────────────────────────────────────────────────────────────
box, open_ = _section(layout, item, "ui_show_colors", "Colors", 'COLOR')
if open_:
box.prop(item, "use_colors")
if item.use_colors:
row = box.row(align=True)
row.prop(item, "colorSet")
row.prop(item, "random_colors")
# ── Transition ───────────────────────────────────────────────────────────
box, open_ = _section(layout, item, "ui_show_trans", "Transition", 'MOD_TIME')
if open_:
box.prop(item, "transition_frames")
if item.transition_frames > 0:
box.prop(item, "transition_easing")
# ---------------------------------------------------------------------------
# Main panel
# ---------------------------------------------------------------------------
class KNOT_PT_Panel(bpy.types.Panel):
bl_label = "Pr3tz"
bl_idname = "KNOT_PT_panel"
bl_space_type = 'VIEW_3D'
bl_region_type = 'UI'
bl_category = 'Pr3tz'
def draw(self, context):
layout = self.layout
scene = context.scene
glob = scene.knot_globals
gen = scene.knot_generator
# ── Playlist ─────────────────────────────────────────────────────────
pl_box = layout.box()
pl_row = pl_box.row()
pl_row.prop(glob, "ui_show_playlist",
icon='TRIA_DOWN' if glob.ui_show_playlist else 'TRIA_RIGHT',
icon_only=True, emboss=False)
pl_row.label(text="Playlist", icon='NLA')
if glob.ui_show_playlist:
row = pl_box.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'
pl_box.operator("knot.populate", icon='FILE_REFRESH')
# ── Selected Knot ─────────────────────────────────────────────────────
if 0 <= scene.knot_list_index < len(scene.knot_list):
item = scene.knot_list[scene.knot_list_index]
ke_box = layout.box()
ke_row = ke_box.row()
ke_row.prop(glob, "ui_show_knot_edit",
icon='TRIA_DOWN' if glob.ui_show_knot_edit else 'TRIA_RIGHT',
icon_only=True, emboss=False)
ke_row.label(text=f"Knot: {item.name}", icon='CURVE_PATH')
if glob.ui_show_knot_edit:
ke_box.prop(item, "name", text="",)
draw_knot_properties(ke_box, item)
ke_box.operator("knot.bake_preview", icon='FILE_TICK')
# ── Global Settings ───────────────────────────────────────────────────
gb = layout.box()
gb_row = gb.row()
gb_row.prop(glob, "ui_show_global",
icon='TRIA_DOWN' if glob.ui_show_global else 'TRIA_RIGHT',
icon_only=True, emboss=False)
gb_row.label(text="Global Settings", icon='WORLD')
if glob.ui_show_global:
gb.prop(glob, "knot_scale")
gb.prop(glob, "frames_per_knot")
# Resolutions
res_box = gb.box()
res_box.label(text="Resolutions (Preview / Render)", icon='RESTRICT_VIEW_OFF')
row = res_box.row(align=True)
row.prop(glob, "preview_resolution", text="Curve")
row.prop(glob, "render_resolution", text="")
row = res_box.row(align=True)
row.prop(glob, "preview_bevel_resolution", text="Bevel")
row.prop(glob, "render_bevel_resolution", text="")
# Visual Effects & Mods
fx_box = gb.box()
fx_box.label(text="Visual Effects & Tweaks", icon='MODIFIER')
fx_box.prop(glob, "global_master_thickness", slider=True)
fx_box.prop(glob, "global_emission_multiplier", slider=True)
fx_box.prop(glob, "global_hue_shift", slider=True)
fx_box.prop(glob, "global_turbulence", slider=True)
fx_box.prop(glob, "camera_shake_amplitude", slider=True)
fx_box.prop(glob, "auto_turntable_speed")
row = fx_box.row(align=True)
row.prop(glob, "smooth_shading", toggle=True, icon='SHADING_RENDERED')
row.prop(glob, "viewport_wireframe", toggle=True, icon='SHADING_WIRE')
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
gb.label(
text=f"Playlist: {total_frames} fr / {secs:.1f}s ({n_knots} knots)",
icon='TIME')
row = gb.row(align=True)
row.operator("knot.fit_timeline", icon='PREVIEW_RANGE', text="Fit Timeline")
row.operator("knot.fit_playlist", icon='NLA_PUSHDOWN', text="Fit Playlist")
# · Playback sub-section ·
gb.separator()
pb_open = _subsection(gb, glob, "ui_show_playback", "Playback", 'PLAY')
if pb_open:
pb = gb.column(align=True)
pb.prop(glob, "global_speed")
pb.prop(glob, "animation_phase")
pb.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 ──────────────────────────────────────────────────
gen_box = layout.box()
gen_hdr = gen_box.row()
gen_hdr.prop(gen, "ui_show_generator",
icon='TRIA_DOWN' if gen.ui_show_generator else 'TRIA_RIGHT',
icon_only=True, emboss=False)
gen_hdr.label(text="Random Generator", icon='GROUP')
if gen.ui_show_generator:
gen_box.prop(gen, "num_knots")
# · Base Knot Defaults ·
base_box = gen_box.box()
base_row = base_box.row()
base_row.prop(gen, "ui_show_base",
icon='TRIA_DOWN' if gen.ui_show_base else 'TRIA_RIGHT',
icon_only=True, emboss=False)
base_row.label(text="Base Defaults", icon='PRESET')
if gen.ui_show_base:
draw_knot_properties(base_box, gen.base_knot)
# · Randomise Toggles (collapsible container) ·
rand_box = gen_box.box()
rand_hdr = rand_box.row()
rand_hdr.prop(gen, "ui_show_rand_toggles",
icon='TRIA_DOWN' if gen.ui_show_rand_toggles else 'TRIA_RIGHT',
icon_only=True, emboss=False)
rand_hdr.label(text="Randomise Toggles", icon='MODIFIER')
if gen.ui_show_rand_toggles:
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}")
# ·· Shape ··
s_box = rand_box.box()
s_row = s_box.row()
s_row.prop(gen, "ui_show_rand_shape",
icon='TRIA_DOWN' if gen.ui_show_rand_shape else 'TRIA_RIGHT',
icon_only=True, emboss=False)
s_row.label(text="Shape", icon='CURVE_PATH')
if gen.ui_show_rand_shape:
s_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")
# ·· Geometry & Links ··
g_box = rand_box.box()
g_row = g_box.row()
g_row.prop(gen, "ui_show_rand_geo",
icon='TRIA_DOWN' if gen.ui_show_rand_geo else 'TRIA_RIGHT',
icon_only=True, emboss=False)
g_row.label(text="Geometry & Links", icon='MESH_DATA')
if gen.ui_show_rand_geo:
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("transition_frames")
draw_rand_bool("transition_easing")
# ·· Animation Rates ··
a_box = rand_box.box()
a_row = a_box.row()
a_row.prop(gen, "ui_show_rand_anim",
icon='TRIA_DOWN' if gen.ui_show_rand_anim else 'TRIA_RIGHT',
icon_only=True, emboss=False)
a_row.label(text="Animation Rates", icon='ANIM')
if gen.ui_show_rand_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")
# ·· Material ··
m_box = rand_box.box()
m_row = m_box.row()
m_row.prop(gen, "ui_show_rand_mat",
icon='TRIA_DOWN' if gen.ui_show_rand_mat else 'TRIA_RIGHT',
icon_only=True, emboss=False)
m_row.label(text="Material", icon='MATERIAL')
if gen.ui_show_rand_mat:
row = m_box.row(align=True)
row.prop(gen, "r_material")
if gen.r_material:
row.prop(gen, "r_preset_params", text="Rnd Params")
filter_box = m_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)
sync_col = row_sync.column(align=True)
sync_col.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')
# ── Export ────────────────────────────────────────────────────────────
ex_box = layout.box()
ex_box.label(text="Export", icon='EXPORT')
ex_box.label(text="Bake all frames to a standalone .blend file",
icon='INFO')
row = ex_box.row()
row.scale_y = 1.4
row.operator("knot.bake_export", icon='RENDER_ANIMATION')