Getting ready for deploy
This commit is contained in:
@@ -1,11 +1,11 @@
|
||||
"""
|
||||
knot_animation/__init__.py
|
||||
--------------------------
|
||||
Blender add-on entry-point for the AnimKnots procedural animation system.
|
||||
Blender add-on entry-point for the Pr3tz procedural animation system.
|
||||
|
||||
What this add-on does
|
||||
---------------------
|
||||
1. Registers a VIEW_3D N-panel ("AnimKnots") with:
|
||||
1. Registers a VIEW_3D N-panel ("Pr3tz") with:
|
||||
- A per-scene playlist of KnotItem entries, each specifying topology,
|
||||
geometry, animation rates, transition settings, and a material preset.
|
||||
- Operators for adding, removing, reordering, and randomly generating
|
||||
@@ -40,6 +40,8 @@ scene_setup.py — setup_scene() (camera, light, world, render settings)
|
||||
|
||||
How to use
|
||||
----------
|
||||
Requires Blender 5.0 or later.
|
||||
|
||||
Option A – Blender Text Editor:
|
||||
Open knot_animation/__init__.py, click Run Script (Alt+P).
|
||||
|
||||
@@ -49,7 +51,7 @@ Option B – Command line:
|
||||
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.
|
||||
"Pr3tz" tab in the 3-D viewport N-panel.
|
||||
"""
|
||||
|
||||
if "bpy" in locals():
|
||||
@@ -107,11 +109,11 @@ from .constants import KNOT_MAT_NAME, SHADER_BLEND_MAT_NAME
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
bl_info = {
|
||||
"name": "AnimKnots",
|
||||
"name": "Pr3tz",
|
||||
"author": "knot_animation project",
|
||||
"version": (2, 0, 0),
|
||||
"blender": (4, 0, 0),
|
||||
"location": "View3D > Sidebar > AnimKnots",
|
||||
"blender": (5, 0, 0),
|
||||
"location": "View3D > Sidebar > Pr3tz",
|
||||
"description": "Procedural torus-knot animation with playlist, transitions, and 20 shader presets",
|
||||
"category": "Animation",
|
||||
}
|
||||
|
||||
+347
-300
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
bake_export.py
|
||||
--------------
|
||||
Operator to bake the AnimKnots procedural animation into a fully
|
||||
Operator to bake the Pr3tz procedural animation into a fully
|
||||
self-contained .blend file suitable for distributed render farms
|
||||
(SheepIt, etc.).
|
||||
|
||||
@@ -26,42 +26,31 @@ from .constants import KNOT_OBJ_NAME
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Blender 5.0 compat — Action.fcurves was replaced with layered actions
|
||||
# Blender 5.0 — layered Action API (Action.fcurves removed)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _get_action_fcurves(obj):
|
||||
"""Return an iterable of FCurves for the given object's animation.
|
||||
"""Return an iterable of FCurves for the given object's active action slot.
|
||||
|
||||
Blender 5.0 replaced ``action.fcurves`` with a layered animation
|
||||
system (slots → layers → strips → channelbags → fcurves). This
|
||||
helper abstracts both APIs so the rest of the module doesn't care.
|
||||
Blender 5.0 uses a layered animation system:
|
||||
action → layers → strips → channelbag (per slot) → fcurves
|
||||
"""
|
||||
anim = obj.animation_data
|
||||
if not anim or not anim.action:
|
||||
return []
|
||||
|
||||
action = anim.action
|
||||
|
||||
# ── Blender 5.0+ (layered actions) ────────────────────────────────────
|
||||
# Try the convenience helper first
|
||||
try:
|
||||
from bpy_extras.anim_utils import action_get_channelbag_for_slot # noqa: F401
|
||||
slot = anim.action_slot
|
||||
if slot is not None:
|
||||
for layer in action.layers:
|
||||
for strip in layer.strips:
|
||||
cb = strip.channelbag(slot)
|
||||
if cb is not None:
|
||||
return cb.fcurves
|
||||
slot = anim.action_slot
|
||||
if slot is None:
|
||||
return []
|
||||
except (ImportError, AttributeError):
|
||||
pass
|
||||
|
||||
# ── Blender 4.x (legacy) ──────────────────────────────────────────────
|
||||
try:
|
||||
return action.fcurves
|
||||
except AttributeError:
|
||||
return []
|
||||
for layer in action.layers:
|
||||
for strip in layer.strips:
|
||||
cb = strip.channelbag(slot)
|
||||
if cb is not None:
|
||||
return cb.fcurves
|
||||
return []
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -147,240 +136,20 @@ class KNOT_OT_BakeExport(bpy.types.Operator, ExportHelper):
|
||||
description="Pack all external textures into the .blend file",
|
||||
default=True,
|
||||
)
|
||||
chunk_frames: bpy.props.IntProperty(
|
||||
name="Frames per File",
|
||||
description=(
|
||||
"Split the export into multiple .blend files, each covering this many frames. "
|
||||
"Set to 0 for a single file (no splitting)"
|
||||
),
|
||||
default=0,
|
||||
min=0,
|
||||
max=10000,
|
||||
split_export: bpy.props.BoolProperty(
|
||||
name="Split Export",
|
||||
description="Split the baked animation into multiple .blend files",
|
||||
default=False,
|
||||
)
|
||||
frames_per_file: bpy.props.IntProperty(
|
||||
name="Frames Per File",
|
||||
description="Number of frames to include in each split file",
|
||||
default=500,
|
||||
min=1,
|
||||
)
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Chunk bake helper
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
def _bake_chunk(self, context, chunk_start, chunk_end, filepath):
|
||||
"""Bake a single frame range and save to *filepath*.
|
||||
|
||||
Creates mesh objects, sets up visibility keyframes, bakes camera
|
||||
shake, removes the AnimKnot, packs data, saves a copy, then
|
||||
cleans up all baked objects from the session so the next chunk
|
||||
starts fresh.
|
||||
|
||||
Returns the list of baked_objects so the caller can report stats.
|
||||
"""
|
||||
scene = context.scene
|
||||
glob = scene.knot_globals
|
||||
|
||||
# -----------------------------------------------------------
|
||||
# Phase 1: Bake per-frame geometry
|
||||
# -----------------------------------------------------------
|
||||
baked_objects: list[tuple[bpy.types.Object, int, int]] = []
|
||||
prev_fingerprint = None
|
||||
prev_transform_fp = None
|
||||
|
||||
bake_coll = bpy.data.collections.new("BakedAnimation")
|
||||
scene.collection.children.link(bake_coll)
|
||||
|
||||
for f in range(chunk_start, chunk_end + 1):
|
||||
scene.frame_set(f)
|
||||
|
||||
knot_obj = bpy.data.objects.get(KNOT_OBJ_NAME)
|
||||
if not knot_obj:
|
||||
continue
|
||||
|
||||
depsgraph = context.evaluated_depsgraph_get()
|
||||
eval_obj = knot_obj.evaluated_get(depsgraph)
|
||||
|
||||
mesh = bpy.data.meshes.new_from_object(eval_obj, depsgraph=depsgraph)
|
||||
mesh.name = f"BakedKnot_Mesh_F{f}"
|
||||
|
||||
mesh_fp = _mesh_fingerprint(mesh)
|
||||
transform_fp = _obj_transform_fingerprint(knot_obj)
|
||||
|
||||
if (mesh_fp == prev_fingerprint
|
||||
and transform_fp == prev_transform_fp
|
||||
and baked_objects):
|
||||
bpy.data.meshes.remove(mesh)
|
||||
_, first_f, _ = baked_objects[-1]
|
||||
baked_objects[-1] = (baked_objects[-1][0], first_f, f)
|
||||
continue
|
||||
|
||||
prev_fingerprint = mesh_fp
|
||||
prev_transform_fp = transform_fp
|
||||
|
||||
obj = bpy.data.objects.new(f"BakedKnot_F{f}", mesh)
|
||||
obj.location = knot_obj.location.copy()
|
||||
obj.rotation_euler = knot_obj.rotation_euler.copy()
|
||||
obj.scale = knot_obj.scale.copy()
|
||||
|
||||
for src_slot in knot_obj.material_slots:
|
||||
obj.data.materials.append(src_slot.material)
|
||||
obj.display_type = knot_obj.display_type
|
||||
|
||||
bake_coll.objects.link(obj)
|
||||
baked_objects.append((obj, f, f))
|
||||
|
||||
if not baked_objects:
|
||||
bake_coll_check = bpy.data.collections.get("BakedAnimation")
|
||||
if bake_coll_check:
|
||||
bpy.data.collections.remove(bake_coll_check)
|
||||
return []
|
||||
|
||||
# -----------------------------------------------------------
|
||||
# Phase 2: Visibility keyframes
|
||||
# -----------------------------------------------------------
|
||||
for obj, first_f, last_f in baked_objects:
|
||||
if first_f > chunk_start:
|
||||
_keyframe_visibility(obj, chunk_start, False)
|
||||
if first_f > chunk_start + 1:
|
||||
_keyframe_visibility(obj, first_f - 1, False)
|
||||
|
||||
_keyframe_visibility(obj, first_f, True)
|
||||
if last_f != first_f:
|
||||
_keyframe_visibility(obj, last_f, True)
|
||||
|
||||
if last_f < chunk_end:
|
||||
_keyframe_visibility(obj, last_f + 1, False)
|
||||
if last_f < chunk_end - 1:
|
||||
_keyframe_visibility(obj, chunk_end, False)
|
||||
|
||||
_set_constant_interpolation(obj)
|
||||
|
||||
# -----------------------------------------------------------
|
||||
# Phase 3: Camera shake
|
||||
# -----------------------------------------------------------
|
||||
cam = scene.camera
|
||||
if cam and glob.camera_shake_amplitude > 0.0:
|
||||
shake = glob.camera_shake_amplitude
|
||||
for f in range(chunk_start, chunk_end + 1):
|
||||
random.seed(int(f * 1000))
|
||||
cam.delta_location = (
|
||||
random.uniform(-shake, shake),
|
||||
random.uniform(-shake, shake),
|
||||
random.uniform(-shake, shake),
|
||||
)
|
||||
cam.keyframe_insert(data_path="delta_location", frame=f)
|
||||
|
||||
for fc in _get_action_fcurves(cam):
|
||||
if fc.data_path == "delta_location":
|
||||
for kp in fc.keyframe_points:
|
||||
kp.interpolation = 'LINEAR'
|
||||
elif cam:
|
||||
cam.delta_location = (0.0, 0.0, 0.0)
|
||||
|
||||
# -----------------------------------------------------------
|
||||
# Phase 4: Remove AnimKnot + handler for clean save
|
||||
# -----------------------------------------------------------
|
||||
knot_obj = bpy.data.objects.get(KNOT_OBJ_NAME)
|
||||
if knot_obj:
|
||||
curve_data = knot_obj.data
|
||||
bpy.data.objects.remove(knot_obj, do_unlink=True)
|
||||
if curve_data and curve_data.users == 0:
|
||||
bpy.data.curves.remove(curve_data)
|
||||
|
||||
from .handler import knot_frame_handler
|
||||
handlers = bpy.app.handlers.frame_change_post
|
||||
for h in list(handlers):
|
||||
if getattr(h, "__name__", "") == knot_frame_handler.__name__:
|
||||
handlers.remove(h)
|
||||
|
||||
# -----------------------------------------------------------
|
||||
# Phase 5: Set chunk frame range, pack, clean orphans
|
||||
# -----------------------------------------------------------
|
||||
orig_frame_start = scene.frame_start
|
||||
orig_frame_end = scene.frame_end
|
||||
orig_file_format = scene.render.image_settings.file_format
|
||||
orig_color_mode = scene.render.image_settings.color_mode
|
||||
|
||||
scene.frame_start = chunk_start
|
||||
scene.frame_end = chunk_end
|
||||
scene.render.image_settings.file_format = 'PNG'
|
||||
scene.render.image_settings.color_mode = 'RGBA'
|
||||
|
||||
try:
|
||||
bpy.ops.file.make_paths_relative()
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
if self.pack_textures:
|
||||
try:
|
||||
bpy.ops.file.pack_all()
|
||||
except RuntimeError:
|
||||
self.report({'WARNING'}, "Some external files could not be packed.")
|
||||
|
||||
for mat in list(bpy.data.materials):
|
||||
if mat.users == 0:
|
||||
bpy.data.materials.remove(mat)
|
||||
for m in list(bpy.data.meshes):
|
||||
if m.users == 0:
|
||||
bpy.data.meshes.remove(m)
|
||||
for c in list(bpy.data.curves):
|
||||
if c.users == 0:
|
||||
bpy.data.curves.remove(c)
|
||||
|
||||
# -----------------------------------------------------------
|
||||
# Phase 6: Save as copy
|
||||
# -----------------------------------------------------------
|
||||
bpy.ops.wm.save_as_mainfile(filepath=filepath, copy=True)
|
||||
|
||||
# -----------------------------------------------------------
|
||||
# Phase 7: Clean up — restore session for next chunk
|
||||
# -----------------------------------------------------------
|
||||
scene.frame_start = orig_frame_start
|
||||
scene.frame_end = orig_frame_end
|
||||
scene.render.image_settings.file_format = orig_file_format
|
||||
scene.render.image_settings.color_mode = orig_color_mode
|
||||
|
||||
# Remove baked objects
|
||||
for obj, _, _ in baked_objects:
|
||||
if obj and obj.name in bpy.data.objects:
|
||||
mesh_data = obj.data
|
||||
bpy.data.objects.remove(obj, do_unlink=True)
|
||||
if mesh_data and mesh_data.users == 0:
|
||||
bpy.data.meshes.remove(mesh_data)
|
||||
|
||||
bake_coll_check = bpy.data.collections.get("BakedAnimation")
|
||||
if bake_coll_check:
|
||||
bpy.data.collections.remove(bake_coll_check)
|
||||
|
||||
# Remove camera shake keyframes
|
||||
if cam:
|
||||
cam_fcurves = _get_action_fcurves(cam)
|
||||
fcurves_to_remove = [
|
||||
fc for fc in cam_fcurves
|
||||
if fc.data_path == "delta_location"
|
||||
]
|
||||
for fc in fcurves_to_remove:
|
||||
try:
|
||||
cam_fcurves.remove(fc)
|
||||
except (TypeError, RuntimeError):
|
||||
pass
|
||||
cam.delta_location = (0.0, 0.0, 0.0)
|
||||
|
||||
# Re-register handler and rebuild the AnimKnot
|
||||
handlers = bpy.app.handlers.frame_change_post
|
||||
already_registered = any(
|
||||
getattr(h, "__name__", "") == knot_frame_handler.__name__
|
||||
for h in handlers
|
||||
)
|
||||
if not already_registered:
|
||||
handlers.append(knot_frame_handler)
|
||||
|
||||
knot_frame_handler(scene)
|
||||
|
||||
return baked_objects
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# execute
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
def execute(self, context):
|
||||
scene = context.scene
|
||||
glob = scene.knot_globals
|
||||
|
||||
# Validate
|
||||
if len(scene.knot_list) == 0:
|
||||
@@ -396,58 +165,341 @@ class KNOT_OT_BakeExport(bpy.types.Operator, ExportHelper):
|
||||
if not filepath.lower().endswith(".blend"):
|
||||
filepath += ".blend"
|
||||
|
||||
frame_start = scene.frame_start
|
||||
frame_end = scene.frame_end
|
||||
total_frames = frame_end - frame_start + 1
|
||||
original_frame = scene.frame_current
|
||||
orig_frame_start = scene.frame_start
|
||||
orig_frame_end = scene.frame_end
|
||||
total_frames = orig_frame_end - orig_frame_start + 1
|
||||
|
||||
# Temporarily override preview resolution to render quality
|
||||
self.report({'INFO'}, f"Baking {total_frames} frames...")
|
||||
|
||||
chunk_size = self.frames_per_file if self.split_export else total_frames
|
||||
|
||||
# We will loop over chunks and bake each as a separate file
|
||||
try:
|
||||
import os
|
||||
first_chunk = True
|
||||
for chunk_start in range(orig_frame_start, orig_frame_end + 1, chunk_size):
|
||||
chunk_end = min(chunk_start + chunk_size - 1, orig_frame_end)
|
||||
|
||||
scene.frame_start = chunk_start
|
||||
scene.frame_end = chunk_end
|
||||
|
||||
chunk_filepath = filepath
|
||||
if self.split_export:
|
||||
base, ext = os.path.splitext(filepath)
|
||||
chunk_filepath = f"{base}_{chunk_start}-{chunk_end}{ext}"
|
||||
|
||||
self.report({'INFO'}, f"Baking chunk {chunk_start}-{chunk_end} to {chunk_filepath}...")
|
||||
# pack_and_clean is True only on the first chunk so that external
|
||||
# textures are packed exactly once. Subsequent chunks already
|
||||
# have the packed images in memory from the first run.
|
||||
result = self._bake_range(
|
||||
context, chunk_filepath,
|
||||
pack_and_clean=first_chunk,
|
||||
)
|
||||
first_chunk = False
|
||||
if result != {'FINISHED'}:
|
||||
self.report({'ERROR'}, f"Failed baking chunk {chunk_start}-{chunk_end}")
|
||||
return {'CANCELLED'}
|
||||
finally:
|
||||
scene.frame_start = orig_frame_start
|
||||
scene.frame_end = orig_frame_end
|
||||
|
||||
self.report({'INFO'}, "Baking complete.")
|
||||
return {'FINISHED'}
|
||||
|
||||
def _bake_range(self, context, filepath, *, pack_and_clean: bool = True):
|
||||
scene = context.scene
|
||||
glob = scene.knot_globals
|
||||
|
||||
frame_start = scene.frame_start
|
||||
frame_end = scene.frame_end
|
||||
total_frames = frame_end - frame_start + 1
|
||||
|
||||
# If "Use Render Resolution" is on, temporarily override the preview
|
||||
# resolution to match render resolution. The handler reads
|
||||
# preview_resolution for viewport and render_resolution for renders,
|
||||
# but frame_set() triggers a viewport-mode handler call. By
|
||||
# equalising them we ensure the baked mesh uses render quality.
|
||||
orig_preview_res = glob.preview_resolution
|
||||
orig_preview_bevel = glob.preview_bevel_resolution
|
||||
if self.use_render_resolution:
|
||||
glob.preview_resolution = glob.render_resolution
|
||||
glob.preview_bevel_resolution = glob.render_bevel_resolution
|
||||
|
||||
# Build the list of (chunk_start, chunk_end, output_path) tuples
|
||||
if self.chunk_frames > 0:
|
||||
base = filepath[:-6] # strip ".blend"
|
||||
chunks = []
|
||||
idx = 1
|
||||
cs = frame_start
|
||||
while cs <= frame_end:
|
||||
ce = min(cs + self.chunk_frames - 1, frame_end)
|
||||
chunks.append((cs, ce, f"{base}_{idx:03d}.blend"))
|
||||
cs = ce + 1
|
||||
idx += 1
|
||||
else:
|
||||
chunks = [(frame_start, frame_end, filepath)]
|
||||
# -------------------------------------------------------------------
|
||||
# Phase 1: Bake per-frame geometry into mesh objects
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
self.report({'INFO'},
|
||||
f"Baking {total_frames} frames into {len(chunks)} file(s)...")
|
||||
# Collect all baked objects so we can keyframe them afterwards
|
||||
baked_objects: list[tuple[bpy.types.Object, int, int]] = []
|
||||
# Each entry: (object, first_visible_frame, last_visible_frame)
|
||||
|
||||
total_unique = 0
|
||||
for ci, (cs, ce, out_path) in enumerate(chunks):
|
||||
self.report({'INFO'},
|
||||
f"Chunk {ci+1}/{len(chunks)}: frames {cs}–{ce} → {out_path}")
|
||||
baked = self._bake_chunk(context, cs, ce, out_path)
|
||||
total_unique += len(baked)
|
||||
prev_fingerprint = None
|
||||
prev_transform_fp = None
|
||||
|
||||
# Restore resolution
|
||||
# Create a dedicated collection to hold baked frames
|
||||
bake_coll = bpy.data.collections.new("BakedAnimation")
|
||||
scene.collection.children.link(bake_coll)
|
||||
|
||||
# Store the original frame so we can restore it later
|
||||
original_frame = scene.frame_current
|
||||
|
||||
for f in range(frame_start, frame_end + 1):
|
||||
# Set the frame — this triggers knot_frame_handler via
|
||||
# the registered frame_change_post handler
|
||||
scene.frame_set(f)
|
||||
|
||||
# Get the knot object (may have been recreated by the handler)
|
||||
knot_obj = bpy.data.objects.get(KNOT_OBJ_NAME)
|
||||
if not knot_obj:
|
||||
continue
|
||||
|
||||
# Evaluate the depsgraph to get the final geometry
|
||||
depsgraph = context.evaluated_depsgraph_get()
|
||||
eval_obj = knot_obj.evaluated_get(depsgraph)
|
||||
|
||||
# Convert to mesh
|
||||
mesh = bpy.data.meshes.new_from_object(eval_obj, depsgraph=depsgraph)
|
||||
mesh.name = f"BakedKnot_Mesh_F{f}"
|
||||
|
||||
# Compute fingerprints for duplicate detection
|
||||
mesh_fp = _mesh_fingerprint(mesh)
|
||||
transform_fp = _obj_transform_fingerprint(knot_obj)
|
||||
|
||||
if (mesh_fp == prev_fingerprint
|
||||
and transform_fp == prev_transform_fp
|
||||
and baked_objects):
|
||||
# Identical to the previous frame — extend the previous
|
||||
# object's visibility window and discard the duplicate mesh
|
||||
bpy.data.meshes.remove(mesh)
|
||||
_, first_f, _ = baked_objects[-1]
|
||||
baked_objects[-1] = (baked_objects[-1][0], first_f, f)
|
||||
continue
|
||||
|
||||
prev_fingerprint = mesh_fp
|
||||
prev_transform_fp = transform_fp
|
||||
|
||||
# Create a new object for this frame
|
||||
obj = bpy.data.objects.new(f"BakedKnot_F{f}", mesh)
|
||||
|
||||
# Copy transform
|
||||
obj.location = knot_obj.location.copy()
|
||||
obj.rotation_euler = knot_obj.rotation_euler.copy()
|
||||
obj.scale = knot_obj.scale.copy()
|
||||
|
||||
# Copy material slots
|
||||
for src_slot in knot_obj.material_slots:
|
||||
obj.data.materials.append(src_slot.material)
|
||||
|
||||
# Copy display settings
|
||||
obj.display_type = knot_obj.display_type
|
||||
|
||||
bake_coll.objects.link(obj)
|
||||
baked_objects.append((obj, f, f))
|
||||
|
||||
# Restore preview resolution if we overrode it
|
||||
if self.use_render_resolution:
|
||||
glob.preview_resolution = orig_preview_res
|
||||
glob.preview_bevel_resolution = orig_preview_bevel
|
||||
|
||||
# Restore frame
|
||||
# Restore original frame
|
||||
scene.frame_set(original_frame)
|
||||
|
||||
if not baked_objects:
|
||||
self.report({'ERROR'}, "No frames were baked — the handler may not be producing geometry.")
|
||||
# Clean up the empty collection
|
||||
bpy.data.collections.remove(bake_coll)
|
||||
return {'CANCELLED'}
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Phase 2: Set up visibility keyframes
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
for obj, first_f, last_f in baked_objects:
|
||||
# Default: hidden on all frames
|
||||
# We keyframe "hidden" before and after the active window,
|
||||
# and "visible" for the active range.
|
||||
|
||||
# Hidden before the active range
|
||||
if first_f > frame_start:
|
||||
_keyframe_visibility(obj, frame_start, False)
|
||||
if first_f > frame_start + 1:
|
||||
_keyframe_visibility(obj, first_f - 1, False)
|
||||
|
||||
# Visible during the active range
|
||||
_keyframe_visibility(obj, first_f, True)
|
||||
if last_f != first_f:
|
||||
_keyframe_visibility(obj, last_f, True)
|
||||
|
||||
# Hidden after the active range
|
||||
if last_f < frame_end:
|
||||
_keyframe_visibility(obj, last_f + 1, False)
|
||||
if last_f < frame_end - 1:
|
||||
_keyframe_visibility(obj, frame_end, False)
|
||||
|
||||
_set_constant_interpolation(obj)
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Phase 3: Bake camera shake to keyframes
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
cam = scene.camera
|
||||
if cam and glob.camera_shake_amplitude > 0.0:
|
||||
shake = glob.camera_shake_amplitude
|
||||
for f in range(frame_start, frame_end + 1):
|
||||
import random
|
||||
random.seed(int(f * 1000))
|
||||
cam.delta_location = (
|
||||
random.uniform(-shake, shake),
|
||||
random.uniform(-shake, shake),
|
||||
random.uniform(-shake, shake),
|
||||
)
|
||||
cam.keyframe_insert(data_path="delta_location", frame=f)
|
||||
|
||||
# Set interpolation to LINEAR for smooth shake
|
||||
for fc in _get_action_fcurves(cam):
|
||||
if fc.data_path == "delta_location":
|
||||
for kp in fc.keyframe_points:
|
||||
kp.interpolation = 'LINEAR'
|
||||
|
||||
# If no shake, ensure delta_location is zeroed
|
||||
elif cam:
|
||||
cam.delta_location = (0.0, 0.0, 0.0)
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Phase 4: Clean up the procedural AnimKnot object
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
# Remove the original NURBS curve object — we've replaced it with
|
||||
# baked mesh objects
|
||||
knot_obj = bpy.data.objects.get(KNOT_OBJ_NAME)
|
||||
if knot_obj:
|
||||
curve_data = knot_obj.data
|
||||
bpy.data.objects.remove(knot_obj, do_unlink=True)
|
||||
if curve_data and curve_data.users == 0:
|
||||
bpy.data.curves.remove(curve_data)
|
||||
|
||||
# Remove the frame_change_post handler so the baked file is
|
||||
# completely standalone
|
||||
from .handler import knot_frame_handler
|
||||
handlers = bpy.app.handlers.frame_change_post
|
||||
for h in list(handlers):
|
||||
if getattr(h, "__name__", "") == knot_frame_handler.__name__:
|
||||
handlers.remove(h)
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Phase 5: Pack data and ensure render-farm compatibility
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
# Set output format to PNG image sequence (required by SheepIt)
|
||||
orig_file_format = scene.render.image_settings.file_format
|
||||
orig_color_mode = scene.render.image_settings.color_mode
|
||||
scene.render.image_settings.file_format = 'PNG'
|
||||
scene.render.image_settings.color_mode = 'RGBA'
|
||||
|
||||
if pack_and_clean:
|
||||
# Make all paths relative — only needed once; subsequent chunks
|
||||
# operate on the same in-memory data which is already relative.
|
||||
try:
|
||||
bpy.ops.file.make_paths_relative()
|
||||
except RuntimeError:
|
||||
pass # May fail if file has never been saved
|
||||
|
||||
# Pack external data if requested — run once so that all packed
|
||||
# images are available in memory for every subsequent chunk save.
|
||||
if self.pack_textures:
|
||||
try:
|
||||
bpy.ops.file.pack_all()
|
||||
except RuntimeError:
|
||||
self.report({'WARNING'}, "Some external files could not be packed.")
|
||||
|
||||
# Remove unused data blocks to reduce file size. Only safe to
|
||||
# do on the first chunk; later chunks share the same in-memory
|
||||
# materials/images and we do not want to inadvertently purge data
|
||||
# that is still referenced by a subsequent chunk's bake.
|
||||
for mat in list(bpy.data.materials):
|
||||
if mat.users == 0:
|
||||
bpy.data.materials.remove(mat)
|
||||
for m in list(bpy.data.meshes):
|
||||
if m.users == 0:
|
||||
bpy.data.meshes.remove(m)
|
||||
for c in list(bpy.data.curves):
|
||||
if c.users == 0:
|
||||
bpy.data.curves.remove(c)
|
||||
for ng in list(bpy.data.node_groups):
|
||||
if ng.users == 0:
|
||||
bpy.data.node_groups.remove(ng)
|
||||
for img in list(bpy.data.images):
|
||||
if img.users == 0 and not img.use_fake_user:
|
||||
bpy.data.images.remove(img)
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Phase 6: Save as a COPY — the original session is untouched
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
bpy.ops.wm.save_as_mainfile(filepath=filepath, copy=True, compress=True)
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Phase 7: Restore the original session state
|
||||
# -------------------------------------------------------------------
|
||||
# We just saved a copy, but the in-memory state has been modified
|
||||
# (baked objects added, AnimKnot removed, handler removed).
|
||||
# Restore everything so the user can keep working.
|
||||
|
||||
# Restore render settings
|
||||
scene.render.image_settings.file_format = orig_file_format
|
||||
scene.render.image_settings.color_mode = orig_color_mode
|
||||
|
||||
# Remove all baked objects and their meshes from the current session
|
||||
for obj, _, _ in baked_objects:
|
||||
if obj and obj.name in bpy.data.objects:
|
||||
mesh_data = obj.data
|
||||
bpy.data.objects.remove(obj, do_unlink=True)
|
||||
if mesh_data and mesh_data.users == 0:
|
||||
bpy.data.meshes.remove(mesh_data)
|
||||
|
||||
# Remove the baked collection
|
||||
bake_coll_check = bpy.data.collections.get("BakedAnimation")
|
||||
if bake_coll_check:
|
||||
bpy.data.collections.remove(bake_coll_check)
|
||||
|
||||
# Remove baked camera shake keyframes (restore static camera)
|
||||
if cam:
|
||||
cam_fcurves = _get_action_fcurves(cam)
|
||||
# cam_fcurves may be a ChannelBag or ActionFCurves — both support .remove()
|
||||
fcurves_to_remove = [
|
||||
fc for fc in cam_fcurves
|
||||
if fc.data_path == "delta_location"
|
||||
]
|
||||
for fc in fcurves_to_remove:
|
||||
try:
|
||||
cam_fcurves.remove(fc)
|
||||
except (TypeError, RuntimeError):
|
||||
pass # Some API versions don't support removal — keyframes will persist
|
||||
cam.delta_location = (0.0, 0.0, 0.0)
|
||||
|
||||
# Re-create the AnimKnot object and re-register the handler
|
||||
from .geometry import _make_torus_knot
|
||||
from .materials import prewarm_materials_and_blends
|
||||
|
||||
# We need to prewarm materials and blends because removing unused materials might have deleted them
|
||||
prewarm_materials_and_blends(scene)
|
||||
|
||||
# Re-register the handler
|
||||
handlers = bpy.app.handlers.frame_change_post
|
||||
already_registered = any(
|
||||
getattr(h, "__name__", "") == knot_frame_handler.__name__
|
||||
for h in handlers
|
||||
)
|
||||
if not already_registered:
|
||||
handlers.append(knot_frame_handler)
|
||||
|
||||
# Trigger the handler once to rebuild the AnimKnot object
|
||||
knot_frame_handler(scene)
|
||||
|
||||
# Restore the frame
|
||||
scene.frame_set(original_frame)
|
||||
|
||||
if len(chunks) == 1:
|
||||
self.report({'INFO'},
|
||||
f"Baked .blend saved to: {chunks[0][2]} "
|
||||
f"({total_unique} unique meshes)")
|
||||
else:
|
||||
self.report({'INFO'},
|
||||
f"Exported {len(chunks)} files "
|
||||
f"({total_unique} unique meshes total)")
|
||||
return {'FINISHED'}
|
||||
|
||||
def draw(self, context):
|
||||
@@ -456,9 +508,11 @@ class KNOT_OT_BakeExport(bpy.types.Operator, ExportHelper):
|
||||
layout.label(text="Bake Options:", icon='SETTINGS')
|
||||
layout.prop(self, "use_render_resolution")
|
||||
layout.prop(self, "pack_textures")
|
||||
|
||||
|
||||
layout.separator()
|
||||
layout.prop(self, "chunk_frames")
|
||||
layout.prop(self, "split_export")
|
||||
if self.split_export:
|
||||
layout.prop(self, "frames_per_file")
|
||||
|
||||
scene = context.scene
|
||||
total = scene.frame_end - scene.frame_start + 1
|
||||
@@ -466,10 +520,3 @@ class KNOT_OT_BakeExport(bpy.types.Operator, ExportHelper):
|
||||
layout.label(text=f"Frames: {scene.frame_start} – {scene.frame_end} ({total} total)")
|
||||
layout.label(text=f"Playlist: {len(scene.knot_list)} knots")
|
||||
|
||||
if self.chunk_frames > 0:
|
||||
n_chunks = -(-total // self.chunk_frames) # ceil division
|
||||
layout.label(text=f"Will produce {n_chunks} file(s)",
|
||||
icon='FILE_BLEND')
|
||||
else:
|
||||
layout.label(text="Single file (no splitting)", icon='FILE_BLEND')
|
||||
|
||||
|
||||
+28
-47
@@ -64,27 +64,21 @@ def _inner_NEON_GLOW(nodes, links, y=0, color=(0.0, 1.0, 0.9, 1.0),
|
||||
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
|
||||
prin.inputs["Base Color"].default_value = color
|
||||
prin.inputs["Metallic"].default_value = metallic
|
||||
prin.inputs["Roughness"].default_value = roughness
|
||||
prin.inputs["Specular IOR Level"].default_value = 1.0
|
||||
return prin.outputs["BSDF"]
|
||||
|
||||
|
||||
def _inner_GLASS(nodes, links, y=0, color=(0.85, 0.95, 1.0, 1.0),
|
||||
roughness=0.0, metallic=0.0, emission_strength=1.0, **kwargs):
|
||||
prin = nodes.new("ShaderNodeBsdfPrincipled"); prin.location = (0, y)
|
||||
prin.inputs["Base Color"].default_value = color
|
||||
prin.inputs["Metallic"].default_value = metallic
|
||||
prin.inputs["Roughness"].default_value = roughness
|
||||
prin.inputs["IOR"].default_value = 1.45
|
||||
for key in ("Transmission Weight", "Transmission"):
|
||||
if key in prin.inputs:
|
||||
prin.inputs[key].default_value = 1.0
|
||||
break
|
||||
prin.inputs["Base Color"].default_value = color
|
||||
prin.inputs["Metallic"].default_value = metallic
|
||||
prin.inputs["Roughness"].default_value = roughness
|
||||
prin.inputs["IOR"].default_value = 1.45
|
||||
prin.inputs["Transmission Weight"].default_value = 1.0
|
||||
return prin.outputs["BSDF"]
|
||||
|
||||
|
||||
@@ -169,8 +163,6 @@ def _inner_MATTE_CLAY(nodes, links, y=0, color=(0.6, 0.4, 0.3, 1.0),
|
||||
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"]
|
||||
|
||||
|
||||
@@ -192,15 +184,11 @@ def _inner_GHOST(nodes, links, y=0, color=(0.7, 0.8, 1.0, 1.0),
|
||||
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
|
||||
prin.inputs["Base Color"].default_value = color
|
||||
prin.inputs["Metallic"].default_value = metallic
|
||||
prin.inputs["Roughness"].default_value = roughness
|
||||
prin.inputs["Coat Weight"].default_value = 1.0
|
||||
prin.inputs["Coat Roughness"].default_value = 0.03
|
||||
return prin.outputs["BSDF"]
|
||||
|
||||
|
||||
@@ -216,28 +204,22 @@ def _inner_CHROME(nodes, links, y=0, color=(0.95, 0.95, 0.95, 1.0),
|
||||
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
|
||||
prin.inputs["Base Color"].default_value = color
|
||||
prin.inputs["Metallic"].default_value = metallic
|
||||
prin.inputs["Roughness"].default_value = roughness
|
||||
prin.inputs["IOR"].default_value = 1.6
|
||||
prin.inputs["Transmission Weight"].default_value = 1.0
|
||||
return prin.outputs["BSDF"]
|
||||
|
||||
|
||||
def _inner_FROSTED_ICE(nodes, links, y=0, color=(0.9, 0.95, 1.0, 1.0),
|
||||
roughness=0.4, metallic=0.0, emission_strength=1.0, **kwargs):
|
||||
prin = nodes.new("ShaderNodeBsdfPrincipled"); prin.location = (0, y)
|
||||
prin.inputs["Base Color"].default_value = color
|
||||
prin.inputs["Metallic"].default_value = metallic
|
||||
prin.inputs["Roughness"].default_value = roughness
|
||||
prin.inputs["IOR"].default_value = 1.31
|
||||
for key in ("Transmission Weight", "Transmission"):
|
||||
if key in prin.inputs:
|
||||
prin.inputs[key].default_value = 1.0
|
||||
break
|
||||
prin.inputs["Base Color"].default_value = color
|
||||
prin.inputs["Metallic"].default_value = metallic
|
||||
prin.inputs["Roughness"].default_value = roughness
|
||||
prin.inputs["IOR"].default_value = 1.31
|
||||
prin.inputs["Transmission Weight"].default_value = 1.0
|
||||
return prin.outputs["BSDF"]
|
||||
|
||||
|
||||
@@ -375,11 +357,10 @@ def _inner_PEARLESCENT(nodes, links, y=0, color=(1.0, 0.4, 0.7, 1.0),
|
||||
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
|
||||
prin.inputs["Metallic"].default_value = metallic
|
||||
prin.inputs["Roughness"].default_value = roughness
|
||||
prin.inputs["Coat Weight"].default_value = 1.0
|
||||
prin.inputs["Coat Roughness"].default_value = 0.02
|
||||
links.new(lw.outputs["Facing"], ramp.inputs["Fac"])
|
||||
links.new(ramp.outputs["Color"], prin.inputs["Base Color"])
|
||||
return prin.outputs["BSDF"]
|
||||
|
||||
+19
-13
@@ -71,8 +71,9 @@ def _rand_apply_choice(gen, item, prop: str, choices: list) -> None:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class KNOT_OT_Add(bpy.types.Operator):
|
||||
bl_idname = "knot.add_item"
|
||||
bl_label = "Add Knot"
|
||||
bl_idname = "knot.add_item"
|
||||
bl_label = "Add Knot"
|
||||
bl_description = "Add a new knot entry to the end of the playlist"
|
||||
|
||||
def execute(self, context):
|
||||
item = context.scene.knot_list.add()
|
||||
@@ -85,8 +86,9 @@ class KNOT_OT_Add(bpy.types.Operator):
|
||||
|
||||
|
||||
class KNOT_OT_Remove(bpy.types.Operator):
|
||||
bl_idname = "knot.remove_item"
|
||||
bl_label = "Remove Knot"
|
||||
bl_idname = "knot.remove_item"
|
||||
bl_label = "Remove Knot"
|
||||
bl_description = "Remove the selected knot entry from the playlist"
|
||||
|
||||
def execute(self, context):
|
||||
idx = context.scene.knot_list_index
|
||||
@@ -99,9 +101,10 @@ class KNOT_OT_Remove(bpy.types.Operator):
|
||||
|
||||
|
||||
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', '')])
|
||||
bl_idname = "knot.move_item"
|
||||
bl_label = "Move Knot"
|
||||
bl_description = "Move the selected knot up or down in the playlist order"
|
||||
direction: bpy.props.EnumProperty(items=[('UP', 'Up', 'Move knot earlier in the playlist'), ('DOWN', 'Down', 'Move knot later in the playlist')])
|
||||
|
||||
def execute(self, context):
|
||||
idx = context.scene.knot_list_index
|
||||
@@ -114,8 +117,9 @@ class KNOT_OT_Move(bpy.types.Operator):
|
||||
|
||||
|
||||
class KNOT_OT_Populate(bpy.types.Operator):
|
||||
bl_idname = "knot.populate"
|
||||
bl_label = "Populate Default List"
|
||||
bl_idname = "knot.populate"
|
||||
bl_label = "Populate Default List"
|
||||
bl_description = "Clear the playlist and load the 10 built-in example knots (trefoil, cinquefoil, Möbius, Lissajous, and more)"
|
||||
|
||||
def execute(self, context):
|
||||
context.scene.knot_list.clear()
|
||||
@@ -134,8 +138,9 @@ class KNOT_OT_Populate(bpy.types.Operator):
|
||||
|
||||
|
||||
class KNOT_OT_BakePreview(bpy.types.Operator):
|
||||
bl_idname = "knot.bake_preview"
|
||||
bl_label = "Update Preview"
|
||||
bl_idname = "knot.bake_preview"
|
||||
bl_label = "Update Preview"
|
||||
bl_description = "Immediately regenerate the viewport knot using the currently selected playlist entry's settings, without waiting for a frame change"
|
||||
|
||||
def execute(self, context):
|
||||
idx = context.scene.knot_list_index
|
||||
@@ -196,8 +201,9 @@ class KNOT_OT_SyncGeneratorMaterials(bpy.types.Operator):
|
||||
|
||||
|
||||
class KNOT_OT_GenerateRandom(bpy.types.Operator):
|
||||
bl_idname = "knot.generate_random"
|
||||
bl_label = "Generate Playlist"
|
||||
bl_idname = "knot.generate_random"
|
||||
bl_label = "Generate Playlist"
|
||||
bl_description = "Clear the playlist and fill it with randomly configured knots using the ranges and toggles set in the Randomise Toggles section below"
|
||||
|
||||
def execute(self, context):
|
||||
scene = context.scene
|
||||
|
||||
+124
-45
@@ -121,17 +121,23 @@ class KnotAllowedMaterial(bpy.types.PropertyGroup):
|
||||
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)
|
||||
name="Frames per Knot", default=12, min=1,
|
||||
description="Base display duration (in frames) for each playlist entry. Each knot's effective duration is frames_per_knot × cycle_rate")
|
||||
preview_resolution: bpy.props.IntProperty(
|
||||
name="Preview Curve Res", default=64, min=3, max=1024)
|
||||
name="Preview Curve Res", default=64, min=3, max=1024,
|
||||
description="NURBS subdivision used in the viewport. Higher values produce a smoother curve but update more slowly")
|
||||
render_resolution: bpy.props.IntProperty(
|
||||
name="Render Curve Res", default=128, min=3, max=2048)
|
||||
name="Render Curve Res", default=128, min=3, max=2048,
|
||||
description="NURBS subdivision used during renders and baking. Higher values produce higher quality output")
|
||||
preview_bevel_resolution: bpy.props.IntProperty(
|
||||
name="Preview Bevel Res", default=4, min=0, max=64)
|
||||
name="Preview Bevel Res", default=4, min=0, max=64,
|
||||
description="Number of sides on the tube cross-section in the viewport")
|
||||
render_bevel_resolution: bpy.props.IntProperty(
|
||||
name="Render Bevel Res", default=8, min=0, max=64)
|
||||
name="Render Bevel Res", default=8, min=0, max=64,
|
||||
description="Number of sides on the tube cross-section during renders and baking")
|
||||
knot_scale: bpy.props.FloatProperty(
|
||||
name="Global Scale", default=1.0, min=0.01)
|
||||
name="Global Scale", default=1.0, min=0.01,
|
||||
description="Uniform scale multiplier applied to all knots")
|
||||
global_speed: bpy.props.FloatProperty(
|
||||
name="Global Speed", default=1.0, min=0.01,
|
||||
description="Multiplies the effective frame counter for all knots. Keyframeable.")
|
||||
@@ -183,20 +189,29 @@ class KnotItem(bpy.types.PropertyGroup):
|
||||
# Shape Type
|
||||
shape_type: bpy.props.EnumProperty(
|
||||
name="Shape",
|
||||
description="Parametric curve type used to generate this knot",
|
||||
items=[
|
||||
('TORUS_KNOT', "Torus Knot", ""),
|
||||
('MOBIUS', "Mobius Strip", ""),
|
||||
('LISSAJOUS', "Lissajous 3D", ""),
|
||||
('SPIRAL', "Spherical Spiral", ""),
|
||||
('TORUS_KNOT', "Torus Knot", "Classic (p,q) torus knot wound around a torus surface"),
|
||||
('MOBIUS', "Mobius Strip", "One-sided surface with a half-twist — non-orientable loop"),
|
||||
('LISSAJOUS', "Lissajous 3D", "3-axis frequency-ratio figure trace"),
|
||||
('SPIRAL', "Spherical Spiral", "Spherical spiral (loxodrome) with configurable turns and radius"),
|
||||
],
|
||||
default='TORUS_KNOT'
|
||||
)
|
||||
|
||||
# Topology (Torus Knot)
|
||||
torus_p: bpy.props.IntProperty(name="Revolutions (p)", default=2, min=1)
|
||||
mod_torus_p: bpy.props.FloatProperty(name="Mod p", default=0.0)
|
||||
torus_q: bpy.props.IntProperty(name="Spins (q)", default=3, min=1)
|
||||
mod_torus_q: bpy.props.FloatProperty(name="Mod q", default=0.0)
|
||||
torus_p: bpy.props.IntProperty(
|
||||
name="Revolutions (p)", default=2, min=1,
|
||||
description="Number of revolutions around the torus axis. Must be coprime with q for a true knot")
|
||||
mod_torus_p: bpy.props.FloatProperty(
|
||||
name="Mod p", default=0.0,
|
||||
description="Per-frame attenuverter offset applied to p before rendering")
|
||||
torus_q: bpy.props.IntProperty(
|
||||
name="Spins (q)", default=3, min=1,
|
||||
description="Number of spins around the torus tube. Must be coprime with p for a true knot")
|
||||
mod_torus_q: bpy.props.FloatProperty(
|
||||
name="Mod q", default=0.0,
|
||||
description="Per-frame attenuverter offset applied to q before rendering")
|
||||
|
||||
# Topology (Mobius)
|
||||
mobius_twists: bpy.props.IntProperty(name="Half Twists", default=1, min=1)
|
||||
@@ -221,25 +236,48 @@ class KnotItem(bpy.types.PropertyGroup):
|
||||
mod_spiral_R: bpy.props.FloatProperty(name="Mod Radius", default=0.0)
|
||||
|
||||
# Radii (Torus Knot)
|
||||
torus_R: bpy.props.FloatProperty(name="Major", default=2.0, min=0.0)
|
||||
mod_torus_R: bpy.props.FloatProperty(name="Mod Major", default=0.0)
|
||||
torus_r: bpy.props.FloatProperty(name="Minor", default=1.0, min=0.0)
|
||||
mod_torus_r: bpy.props.FloatProperty(name="Mod Minor", default=0.0)
|
||||
torus_R: bpy.props.FloatProperty(
|
||||
name="Major", default=2.0, min=0.0,
|
||||
description="Distance from the centre of the torus to the centre of the tube")
|
||||
mod_torus_R: bpy.props.FloatProperty(
|
||||
name="Mod Major", default=0.0,
|
||||
description="Per-frame attenuverter offset applied to the major radius")
|
||||
torus_r: bpy.props.FloatProperty(
|
||||
name="Minor", default=1.0, min=0.0,
|
||||
description="Radius of the tube itself")
|
||||
mod_torus_r: bpy.props.FloatProperty(
|
||||
name="Mod Minor", default=0.0,
|
||||
description="Per-frame attenuverter offset applied to the minor radius")
|
||||
|
||||
# Colors (legacy TKP path)
|
||||
multiple_links: bpy.props.BoolProperty(name="Multiple Links", default=False)
|
||||
use_colors: bpy.props.BoolProperty(name="Use Colors", default=False)
|
||||
multiple_links: bpy.props.BoolProperty(
|
||||
name="Multiple Links", default=False,
|
||||
description="Render all gcd(p,q) link components as separate curves")
|
||||
use_colors: bpy.props.BoolProperty(
|
||||
name="Use Colors", default=False,
|
||||
description="Apply per-link vertex colors to the curve (legacy TKP color mode)")
|
||||
colorSet: bpy.props.EnumProperty(
|
||||
name="Color Set",
|
||||
items=[('1', "RGBish", ""), ('2', "Rainbow", "")],
|
||||
description="Which built-in vertex color palette to use",
|
||||
items=[('1', "RGBish", "Red/Green/Blue-based palette"), ('2', "Rainbow", "Full-spectrum rainbow palette")],
|
||||
default='1')
|
||||
random_colors: bpy.props.BoolProperty(name="Randomize Colors", default=False)
|
||||
random_colors: bpy.props.BoolProperty(
|
||||
name="Randomize Colors", default=False,
|
||||
description="Shuffle the vertex color assignment randomly each frame")
|
||||
|
||||
# Multipliers & phases
|
||||
torus_u: bpy.props.IntProperty( name="Rev. Multiplier", default=1, min=1)
|
||||
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)
|
||||
torus_u: bpy.props.IntProperty(
|
||||
name="Rev. Multiplier", default=1, min=1,
|
||||
description="Repeats the revolution pattern — creates multiple overlapping copies of the knot")
|
||||
torus_v: bpy.props.IntProperty(
|
||||
name="Spin Multiplier", default=1, min=1,
|
||||
description="Repeats the spin pattern around the tube")
|
||||
torus_rP: bpy.props.FloatProperty(
|
||||
name="Rev. Phase", default=0.0,
|
||||
description="Revolution phase offset in radians (orbit rotation)")
|
||||
torus_sP: bpy.props.FloatProperty(
|
||||
name="Spin Phase", default=0.0,
|
||||
description="Spin phase offset in radians (tube rotation)")
|
||||
|
||||
# Animation rates
|
||||
spin_phase_rate: bpy.props.FloatProperty(
|
||||
@@ -268,36 +306,69 @@ class KnotItem(bpy.types.PropertyGroup):
|
||||
update=_update_knot_material_cb)
|
||||
transition_easing: bpy.props.EnumProperty(
|
||||
name="Easing",
|
||||
description="Interpolation curve applied during the morph window",
|
||||
items=[
|
||||
('LINEAR', "Linear", ""),
|
||||
('QUAD_IN_OUT', "Quadratic In/Out", ""),
|
||||
('SMOOTHSTEP', "Smoothstep", ""),
|
||||
('LINEAR', "Linear", "Constant-rate blend between the two knots"),
|
||||
('QUAD_IN_OUT', "Quadratic In/Out", "Slow start and end, faster in the middle"),
|
||||
('SMOOTHSTEP', "Smoothstep", "S-curve blend — smooth at both endpoints"),
|
||||
],
|
||||
default='QUAD_IN_OUT')
|
||||
|
||||
# Geometry
|
||||
geo_extrude: bpy.props.FloatProperty(name="Extrude", default=0.0, min=0.0)
|
||||
mod_geo_extrude: bpy.props.FloatProperty(name="Mod Extrude", default=0.0)
|
||||
geo_offset: bpy.props.FloatProperty(name="Offset", default=0.0)
|
||||
mod_geo_offset: bpy.props.FloatProperty(name="Mod Offset", default=0.0)
|
||||
geo_bDepth: bpy.props.FloatProperty(name="Bevel Depth", default=0.04, min=0.0)
|
||||
mod_geo_bDepth: bpy.props.FloatProperty(name="Mod BDepth", default=0.0)
|
||||
geo_extrude: bpy.props.FloatProperty(
|
||||
name="Extrude", default=0.0, min=0.0,
|
||||
description="Extrude the curve profile outward — creates a ribbon effect when combined with Offset")
|
||||
mod_geo_extrude: bpy.props.FloatProperty(
|
||||
name="Mod Extrude", default=0.0,
|
||||
description="Per-frame attenuverter offset applied to Extrude")
|
||||
geo_offset: bpy.props.FloatProperty(
|
||||
name="Offset", default=0.0,
|
||||
description="Offset the extruded profile from the curve centreline")
|
||||
mod_geo_offset: bpy.props.FloatProperty(
|
||||
name="Mod Offset", default=0.0,
|
||||
description="Per-frame attenuverter offset applied to Offset")
|
||||
geo_bDepth: bpy.props.FloatProperty(
|
||||
name="Bevel Depth", default=0.04, min=0.0,
|
||||
description="Tube thickness — controls how wide the knot strand is")
|
||||
mod_geo_bDepth: bpy.props.FloatProperty(
|
||||
name="Mod BDepth", default=0.0,
|
||||
description="Per-frame attenuverter offset applied to Bevel Depth")
|
||||
|
||||
# Dimensions mode
|
||||
mode: bpy.props.EnumProperty(
|
||||
name="Dimensions Mode",
|
||||
items=[('MAJOR_MINOR', "Major/Minor", ""), ('EXT_INT', "Exterior/Interior", "")],
|
||||
description="Choose how to specify the torus radii",
|
||||
items=[
|
||||
('MAJOR_MINOR', "Major/Minor", "Specify inner and outer radius as Major and Minor"),
|
||||
('EXT_INT', "Exterior/Interior", "Specify the outer edge and the hole as Exterior and Interior radii"),
|
||||
],
|
||||
default='MAJOR_MINOR')
|
||||
torus_eR: bpy.props.FloatProperty(name="Exterior", default=3.0, min=0.0)
|
||||
mod_torus_eR: bpy.props.FloatProperty(name="Mod Ext", default=0.0)
|
||||
torus_iR: bpy.props.FloatProperty(name="Interior", default=1.0, min=0.0)
|
||||
mod_torus_iR: bpy.props.FloatProperty(name="Mod Int", default=0.0)
|
||||
torus_h: bpy.props.FloatProperty(name="Height", default=1.0, min=0.0)
|
||||
mod_torus_h: bpy.props.FloatProperty(name="Mod Height", default=0.0)
|
||||
torus_eR: bpy.props.FloatProperty(
|
||||
name="Exterior", default=3.0, min=0.0,
|
||||
description="Outer edge radius of the torus (Exterior/Interior mode)")
|
||||
mod_torus_eR: bpy.props.FloatProperty(
|
||||
name="Mod Ext", default=0.0,
|
||||
description="Per-frame attenuverter offset applied to Exterior radius")
|
||||
torus_iR: bpy.props.FloatProperty(
|
||||
name="Interior", default=1.0, min=0.0,
|
||||
description="Inner hole radius of the torus (Exterior/Interior mode)")
|
||||
mod_torus_iR: bpy.props.FloatProperty(
|
||||
name="Mod Int", default=0.0,
|
||||
description="Per-frame attenuverter offset applied to Interior radius")
|
||||
torus_h: bpy.props.FloatProperty(
|
||||
name="Height", default=1.0, min=0.0,
|
||||
description="Vertical stretch of the torus — values above 1.0 elongate the knot")
|
||||
mod_torus_h: bpy.props.FloatProperty(
|
||||
name="Mod Height", default=0.0,
|
||||
description="Per-frame attenuverter offset applied to Height")
|
||||
|
||||
# Direction
|
||||
flip_p: bpy.props.BoolProperty(name="Flip p", default=False)
|
||||
flip_q: bpy.props.BoolProperty(name="Flip q", default=False)
|
||||
flip_p: bpy.props.BoolProperty(
|
||||
name="Flip p", default=False,
|
||||
description="Reverse the direction of the p (revolution) winding")
|
||||
flip_q: bpy.props.BoolProperty(
|
||||
name="Flip q", default=False,
|
||||
description="Reverse the direction of the q (spin) winding")
|
||||
|
||||
# UI expand state (display-only, not serialised to animation)
|
||||
ui_show_shape: bpy.props.BoolProperty(name="Shape", default=True)
|
||||
@@ -311,7 +382,11 @@ class KnotItem(bpy.types.PropertyGroup):
|
||||
# Material
|
||||
material_mode: bpy.props.EnumProperty(
|
||||
name="Material Mode",
|
||||
items=[('PRESET', "Shader Preset", ""), ('PROJECT', "Project Material", "")],
|
||||
description="Choose between a built-in shader preset or an existing material from the project",
|
||||
items=[
|
||||
('PRESET', "Shader Preset", "Use one of the 20 built-in procedural shader presets"),
|
||||
('PROJECT', "Project Material", "Use any material already in this .blend file"),
|
||||
],
|
||||
default='PRESET',
|
||||
update=_update_knot_material_cb)
|
||||
project_material: bpy.props.PointerProperty(
|
||||
@@ -327,15 +402,19 @@ class KnotItem(bpy.types.PropertyGroup):
|
||||
preset_color: bpy.props.FloatVectorProperty(
|
||||
name="Preset Color", subtype='COLOR', size=3,
|
||||
default=(0.2, 0.6, 1.0), min=0.0, max=1.0,
|
||||
description="Base colour tint passed into the selected shader preset",
|
||||
update=_update_knot_material_cb)
|
||||
preset_roughness: bpy.props.FloatProperty(
|
||||
name="Preset Roughness", default=0.1, min=0.0, max=1.0,
|
||||
description="Roughness override for the active preset (0 = mirror-smooth, 1 = fully diffuse)",
|
||||
update=_update_knot_material_cb)
|
||||
preset_metallic: bpy.props.FloatProperty(
|
||||
name="Preset Metallic", default=0.0, min=0.0, max=1.0,
|
||||
description="Metallic factor override for the active preset (0 = dielectric, 1 = metal)",
|
||||
update=_update_knot_material_cb)
|
||||
preset_emission_strength: bpy.props.FloatProperty(
|
||||
name="Preset Emission Strength", default=1.0, min=0.0, max=100.0,
|
||||
description="Emission strength override for presets that emit light",
|
||||
update=_update_knot_material_cb)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
|
||||
+13
-8
@@ -6,7 +6,7 @@ All Blender UI classes for knot_animation:
|
||||
KNOT_UL_List — playlist UIList
|
||||
KNOT_UL_AllowedMaterialsList— generator filter UIList
|
||||
draw_knot_properties() — shared property layout helper (collapsible sections)
|
||||
KNOT_PT_Panel — main N-panel (VIEW_3D > UI > AnimKnots)
|
||||
KNOT_PT_Panel — main N-panel (VIEW_3D > UI > Pr3tz)
|
||||
|
||||
Every major block is collapsible via per-item or per-settings ui_show_* flags.
|
||||
KnotItem flags are per-item so each playlist entry remembers its own state.
|
||||
@@ -182,11 +182,11 @@ def draw_knot_properties(layout, item) -> None:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class KNOT_PT_Panel(bpy.types.Panel):
|
||||
bl_label = "AnimKnots"
|
||||
bl_label = "Pr3tz"
|
||||
bl_idname = "KNOT_PT_panel"
|
||||
bl_space_type = 'VIEW_3D'
|
||||
bl_region_type = 'UI'
|
||||
bl_category = 'AnimKnots'
|
||||
bl_category = 'Pr3tz'
|
||||
|
||||
def draw(self, context):
|
||||
layout = self.layout
|
||||
@@ -209,8 +209,10 @@ class KNOT_PT_Panel(bpy.types.Panel):
|
||||
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'
|
||||
col.operator("knot.move_item", text="", icon='TRIA_UP'
|
||||
).direction = 'UP'
|
||||
col.operator("knot.move_item", text="", icon='TRIA_DOWN'
|
||||
).direction = 'DOWN'
|
||||
pl_box.operator("knot.populate", icon='FILE_REFRESH')
|
||||
|
||||
# ── Selected Knot ─────────────────────────────────────────────────────
|
||||
@@ -223,7 +225,7 @@ class KNOT_PT_Panel(bpy.types.Panel):
|
||||
icon_only=True, emboss=False)
|
||||
ke_row.label(text=f"Knot: {item.name}", icon='CURVE_PATH')
|
||||
if glob.ui_show_knot_edit:
|
||||
ke_box.prop(item, "name", text="")
|
||||
ke_box.prop(item, "name", text="",)
|
||||
draw_knot_properties(ke_box, item)
|
||||
ke_box.operator("knot.bake_preview", icon='FILE_TICK')
|
||||
|
||||
@@ -400,8 +402,11 @@ class KNOT_PT_Panel(bpy.types.Panel):
|
||||
gen, "allowed_materials",
|
||||
gen, "allowed_materials_index",
|
||||
rows=5)
|
||||
row_sync.column(align=True).operator(
|
||||
"knot.sync_generator_materials", icon='FILE_REFRESH', text="")
|
||||
sync_col = row_sync.column(align=True)
|
||||
sync_col.operator(
|
||||
"knot.sync_generator_materials",
|
||||
icon='FILE_REFRESH', text=""
|
||||
)
|
||||
if len(gen.allowed_materials) == 0:
|
||||
filter_box.label(
|
||||
text="Click Sync to load project materials & presets",
|
||||
|
||||
Reference in New Issue
Block a user