rename knot_animation folder
This commit is contained in:
@@ -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.")
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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'}
|
||||
@@ -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")
|
||||
@@ -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
@@ -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
@@ -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')
|
||||
Reference in New Issue
Block a user