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