Export to blend file for cloud rendering
This commit is contained in:
@@ -64,9 +64,14 @@ if "bpy" in locals():
|
|||||||
importlib.reload(operators)
|
importlib.reload(operators)
|
||||||
importlib.reload(ui)
|
importlib.reload(ui)
|
||||||
importlib.reload(scene_setup)
|
importlib.reload(scene_setup)
|
||||||
|
# bake_export may not exist from a prior session — import if needed
|
||||||
|
if "bake_export" in locals():
|
||||||
|
importlib.reload(bake_export)
|
||||||
|
else:
|
||||||
|
from . import bake_export
|
||||||
else:
|
else:
|
||||||
import bpy
|
import bpy
|
||||||
from . import compat, constants, types, materials, geometry, handler, properties, operators, ui, scene_setup
|
from . import compat, constants, types, materials, geometry, handler, properties, operators, ui, scene_setup, bake_export
|
||||||
|
|
||||||
from .compat import apply_compat_patches
|
from .compat import apply_compat_patches
|
||||||
from .properties import (
|
from .properties import (
|
||||||
@@ -86,6 +91,7 @@ from .operators import (
|
|||||||
KNOT_OT_FitPlaylist,
|
KNOT_OT_FitPlaylist,
|
||||||
KNOT_OT_GenerateRandom,
|
KNOT_OT_GenerateRandom,
|
||||||
)
|
)
|
||||||
|
from .bake_export import KNOT_OT_BakeExport
|
||||||
from .ui import (
|
from .ui import (
|
||||||
KNOT_UL_List,
|
KNOT_UL_List,
|
||||||
KNOT_UL_AllowedMaterialsList,
|
KNOT_UL_AllowedMaterialsList,
|
||||||
@@ -130,6 +136,7 @@ classes = (
|
|||||||
KNOT_OT_FitTimeline,
|
KNOT_OT_FitTimeline,
|
||||||
KNOT_OT_FitPlaylist,
|
KNOT_OT_FitPlaylist,
|
||||||
KNOT_OT_GenerateRandom,
|
KNOT_OT_GenerateRandom,
|
||||||
|
KNOT_OT_BakeExport,
|
||||||
KNOT_PT_Panel,
|
KNOT_PT_Panel,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,475 @@
|
|||||||
|
"""
|
||||||
|
bake_export.py
|
||||||
|
--------------
|
||||||
|
Operator to bake the AnimKnots procedural animation into a fully
|
||||||
|
self-contained .blend file suitable for distributed render farms
|
||||||
|
(SheepIt, etc.).
|
||||||
|
|
||||||
|
The resulting file contains:
|
||||||
|
- One mesh object per unique frame, with hide_render / hide_viewport
|
||||||
|
keyframes so only the correct mesh is visible on each frame.
|
||||||
|
- All materials (preset, blend, project) referenced by the objects.
|
||||||
|
- Camera with baked delta_location keyframes (camera shake).
|
||||||
|
- Light, world, and render settings copied from the source scene.
|
||||||
|
- No addon dependencies — the file is fully standalone.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import random
|
||||||
|
import struct
|
||||||
|
|
||||||
|
import bpy
|
||||||
|
from bpy_extras.io_utils import ExportHelper
|
||||||
|
|
||||||
|
from .constants import KNOT_OBJ_NAME
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Blender 5.0 compat — Action.fcurves was replaced with layered actions
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _get_action_fcurves(obj):
|
||||||
|
"""Return an iterable of FCurves for the given object's animation.
|
||||||
|
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
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
|
||||||
|
return []
|
||||||
|
except (ImportError, AttributeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# ── Blender 4.x (legacy) ──────────────────────────────────────────────
|
||||||
|
try:
|
||||||
|
return action.fcurves
|
||||||
|
except AttributeError:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _mesh_fingerprint(mesh: bpy.types.Mesh) -> str:
|
||||||
|
"""Compute a fast hash of a mesh's vertex positions + material index.
|
||||||
|
|
||||||
|
Used to detect consecutive identical frames so we can extend the
|
||||||
|
previous object's visibility window instead of creating a duplicate.
|
||||||
|
"""
|
||||||
|
h = hashlib.md5(usedforsecurity=False)
|
||||||
|
|
||||||
|
# Hash vertex positions
|
||||||
|
for v in mesh.vertices:
|
||||||
|
h.update(struct.pack('fff', *v.co))
|
||||||
|
|
||||||
|
# Hash which material slots are assigned
|
||||||
|
for slot_idx, slot in enumerate(mesh.materials):
|
||||||
|
name = slot.name if slot else ""
|
||||||
|
h.update(f"{slot_idx}:{name}".encode())
|
||||||
|
|
||||||
|
return h.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _obj_transform_fingerprint(obj: bpy.types.Object) -> str:
|
||||||
|
"""Hash an object's location, rotation_euler, and scale."""
|
||||||
|
h = hashlib.md5(usedforsecurity=False)
|
||||||
|
h.update(struct.pack('fff', *obj.location))
|
||||||
|
h.update(struct.pack('fff', *obj.rotation_euler))
|
||||||
|
h.update(struct.pack('fff', *obj.scale))
|
||||||
|
return h.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _keyframe_visibility(obj, frame: int, visible: bool) -> None:
|
||||||
|
"""Insert hide_render and hide_viewport keyframes for a single frame."""
|
||||||
|
obj.hide_render = not visible
|
||||||
|
obj.hide_viewport = not visible
|
||||||
|
obj.keyframe_insert(data_path="hide_render", frame=frame)
|
||||||
|
obj.keyframe_insert(data_path="hide_viewport", frame=frame)
|
||||||
|
|
||||||
|
|
||||||
|
def _set_constant_interpolation(obj) -> None:
|
||||||
|
"""Set all visibility F-curves to CONSTANT interpolation.
|
||||||
|
|
||||||
|
This prevents Blender from smoothly fading between True/False,
|
||||||
|
which would make objects partially visible on intermediate frames.
|
||||||
|
"""
|
||||||
|
for fc in _get_action_fcurves(obj):
|
||||||
|
if fc.data_path in ("hide_render", "hide_viewport"):
|
||||||
|
for kp in fc.keyframe_points:
|
||||||
|
kp.interpolation = 'CONSTANT'
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Main bake operator
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class KNOT_OT_BakeExport(bpy.types.Operator, ExportHelper):
|
||||||
|
"""Bake the procedural animation into a standalone .blend file."""
|
||||||
|
bl_idname = "knot.bake_export"
|
||||||
|
bl_label = "Bake & Export for Render Farm"
|
||||||
|
bl_description = (
|
||||||
|
"Iterate every frame, convert the procedural knot to mesh, "
|
||||||
|
"and save a self-contained .blend with per-frame visibility keyframes. "
|
||||||
|
"The output file requires no addons and is render-farm ready"
|
||||||
|
)
|
||||||
|
bl_options = {'REGISTER'}
|
||||||
|
|
||||||
|
# ExportHelper provides the file browser
|
||||||
|
filename_ext = ".blend"
|
||||||
|
filter_glob: bpy.props.StringProperty(default="*.blend", options={'HIDDEN'})
|
||||||
|
|
||||||
|
# User-facing options shown in the file browser sidebar
|
||||||
|
use_render_resolution: bpy.props.BoolProperty(
|
||||||
|
name="Use Render Resolution",
|
||||||
|
description="Bake at the Render Curve / Bevel resolution (recommended for final output)",
|
||||||
|
default=True,
|
||||||
|
)
|
||||||
|
pack_textures: bpy.props.BoolProperty(
|
||||||
|
name="Pack Textures",
|
||||||
|
description="Pack all external textures into the .blend file",
|
||||||
|
default=True,
|
||||||
|
)
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------
|
||||||
|
# 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:
|
||||||
|
self.report({'ERROR'}, "Playlist is empty — nothing to bake.")
|
||||||
|
return {'CANCELLED'}
|
||||||
|
|
||||||
|
knot_obj = bpy.data.objects.get(KNOT_OBJ_NAME)
|
||||||
|
if not knot_obj:
|
||||||
|
self.report({'ERROR'}, f"AnimKnot object '{KNOT_OBJ_NAME}' not found.")
|
||||||
|
return {'CANCELLED'}
|
||||||
|
|
||||||
|
filepath = self.filepath
|
||||||
|
if not filepath.lower().endswith(".blend"):
|
||||||
|
filepath += ".blend"
|
||||||
|
|
||||||
|
frame_start = scene.frame_start
|
||||||
|
frame_end = scene.frame_end
|
||||||
|
total_frames = frame_end - frame_start + 1
|
||||||
|
original_frame = scene.frame_current
|
||||||
|
|
||||||
|
# Temporarily override preview resolution to 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)]
|
||||||
|
|
||||||
|
self.report({'INFO'},
|
||||||
|
f"Baking {total_frames} frames into {len(chunks)} file(s)...")
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
# Restore resolution
|
||||||
|
if self.use_render_resolution:
|
||||||
|
glob.preview_resolution = orig_preview_res
|
||||||
|
glob.preview_bevel_resolution = orig_preview_bevel
|
||||||
|
|
||||||
|
# Restore 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):
|
||||||
|
"""Draw options in the file browser sidebar."""
|
||||||
|
layout = self.layout
|
||||||
|
layout.label(text="Bake Options:", icon='SETTINGS')
|
||||||
|
layout.prop(self, "use_render_resolution")
|
||||||
|
layout.prop(self, "pack_textures")
|
||||||
|
|
||||||
|
layout.separator()
|
||||||
|
layout.prop(self, "chunk_frames")
|
||||||
|
|
||||||
|
scene = context.scene
|
||||||
|
total = scene.frame_end - scene.frame_start + 1
|
||||||
|
layout.separator()
|
||||||
|
layout.label(text=f"Frames: {scene.frame_start} – {scene.frame_end} ({total} total)")
|
||||||
|
layout.label(text=f"Playlist: {len(scene.knot_list)} knots")
|
||||||
|
|
||||||
|
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')
|
||||||
|
|
||||||
@@ -408,3 +408,12 @@ class KNOT_PT_Panel(bpy.types.Panel):
|
|||||||
icon='INFO')
|
icon='INFO')
|
||||||
|
|
||||||
gen_box.operator("knot.generate_random", icon='PLAY')
|
gen_box.operator("knot.generate_random", icon='PLAY')
|
||||||
|
|
||||||
|
# ── Export ────────────────────────────────────────────────────────────
|
||||||
|
ex_box = layout.box()
|
||||||
|
ex_box.label(text="Export", icon='EXPORT')
|
||||||
|
ex_box.label(text="Bake all frames to a standalone .blend file",
|
||||||
|
icon='INFO')
|
||||||
|
row = ex_box.row()
|
||||||
|
row.scale_y = 1.4
|
||||||
|
row.operator("knot.bake_export", icon='RENDER_ANIMATION')
|
||||||
|
|||||||
Reference in New Issue
Block a user