515 lines
18 KiB
Python
515 lines
18 KiB
Python
import bpy
|
|
from bpy.props import StringProperty, BoolProperty, IntProperty
|
|
from bpy.utils import register_class, unregister_class
|
|
from bpy.types import Operator
|
|
|
|
import os, signal
|
|
import uuid
|
|
import tempfile
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
from .utils import show_message_box, force_to_object_mode, SBConstants, pbr_selections_to_list, specials_selection_to_list
|
|
#from .messages import print_message
|
|
|
|
class BakeInProgress:
|
|
is_baking = False
|
|
was_error = False
|
|
|
|
class Sequence:
|
|
"""State for an image-sequence bake in progress.
|
|
|
|
The image_sequence operator is the sole writer of these flags. All
|
|
other modules must consume them via ``should_run_setup`` /
|
|
``should_run_teardown`` rather than reading the booleans directly,
|
|
so that "first-frame" / "last-frame" / "not-a-sequence" semantics
|
|
stay in one place.
|
|
"""
|
|
|
|
active = False # True while a sequence bake is running
|
|
is_first_frame = False # True during the bake of the first frame
|
|
is_last_frame = False # True during the bake of the final frame
|
|
total_frames = 0 # Number of frames covered by the sequence
|
|
|
|
@classmethod
|
|
def should_run_setup(cls):
|
|
"""Per-frame operators that do one-shot setup (master backup,
|
|
material initialise) should only run on the first frame of a
|
|
sequence — or on every bake if we're not in a sequence at all.
|
|
"""
|
|
return (not cls.active) or cls.is_first_frame
|
|
|
|
@classmethod
|
|
def should_run_teardown(cls):
|
|
"""Per-frame operators that do one-shot teardown (master
|
|
restore, copy-and-apply, S2A hide, completion report, mesh
|
|
export) should only run on the final frame of a sequence —
|
|
or on every bake if we're not in a sequence at all.
|
|
"""
|
|
return (not cls.active) or cls.is_last_frame
|
|
|
|
@classmethod
|
|
def reset(cls):
|
|
cls.active = False
|
|
cls.is_first_frame = False
|
|
cls.is_last_frame = False
|
|
cls.total_frames = 0
|
|
|
|
@classmethod
|
|
def reset(cls):
|
|
cls.is_baking = False
|
|
cls.was_error = False
|
|
cls.Sequence.reset()
|
|
|
|
|
|
class BackgroundBakeTasks:
|
|
#Non-blender class. BG bake task
|
|
|
|
active_tasks = []
|
|
completed_tasks = []
|
|
queued_tasks = []
|
|
|
|
@classmethod
|
|
def get_completed_or_active_bgt_by_pid(cls, pid):
|
|
pid = int(pid)
|
|
|
|
match = [t for t in cls.completed_tasks if t.pid == pid]
|
|
if len(match) == 0:
|
|
match = [t for t in cls.active_tasks if t.pid == pid]
|
|
assert(len(match)>0)
|
|
match = match[0]
|
|
|
|
return match
|
|
|
|
@classmethod
|
|
def get_queued_bgt_by_id(cls, id):
|
|
match = [t for t in cls.queued_tasks if t.id == id]
|
|
assert(len(match) == 1)
|
|
match = match[0]
|
|
|
|
return match
|
|
|
|
@classmethod
|
|
def get_bgbake_status(cls, active_bgbake):
|
|
sbp = bpy.context.scene.SimpleBake_Props
|
|
pid = active_bgbake.pid
|
|
path = Path(sbp.bgbake_temp_dir) / "progress" / str(pid)
|
|
|
|
try:
|
|
with open(str(path), "r") as progfile:
|
|
progress = int(progfile.readline())
|
|
except:
|
|
#No file yet, as no bake operation has completed yet. Holding message
|
|
progress = 0
|
|
|
|
active_bgbake.progress = progress
|
|
|
|
if progress == 100:
|
|
cls.completed_tasks.append(active_bgbake)
|
|
cls.active_tasks.remove(active_bgbake)
|
|
cls.next_please()
|
|
|
|
return progress
|
|
|
|
@classmethod
|
|
def next_please(cls):
|
|
if len(cls.active_tasks) == 0 and len(cls.queued_tasks) > 0:
|
|
t = cls.queued_tasks[0]
|
|
cls.active_tasks.append(t)
|
|
cls.queued_tasks.remove(t)
|
|
t.spawn_bg_process()
|
|
return 2
|
|
|
|
elif len(cls.active_tasks) == 0 and len(cls.queued_tasks) == 0:
|
|
#Nothing happening, kill timer
|
|
return None
|
|
|
|
else:
|
|
if len(cls.active_tasks)>0: # Just in case
|
|
active_bgt = cls.active_tasks[0]
|
|
cls.get_bgbake_status(active_bgt)
|
|
return 2
|
|
|
|
def save_file_copy(self):
|
|
sbp = bpy.context.scene.SimpleBake_Props
|
|
unique_id = str(uuid.uuid4())[0:7]
|
|
tmp_dir_path = Path(sbp.bgbake_temp_dir)
|
|
if not os.path.exists(str(tmp_dir_path)):
|
|
os.makedirs(str(tmp_dir_path))
|
|
|
|
save_path = tmp_dir_path / (unique_id + ".blend")
|
|
|
|
#Pack dirty images or we lose them on save
|
|
[i.pack() for i in bpy.data.images if i.is_dirty]
|
|
|
|
bpy.ops.wm.save_as_mainfile(filepath=str(save_path), check_existing=False, copy=True)
|
|
self.initial_save_path = save_path
|
|
|
|
return True
|
|
|
|
def spawn_bg_process(self):
|
|
sbp = bpy.context.scene.SimpleBake_Props
|
|
tmp_dir_path = sbp.bgbake_temp_dir #Already posix path
|
|
base_folder_override = Path(bpy.data.filepath).parent.as_posix()
|
|
|
|
args = [bpy.app.binary_path]
|
|
if not sbp.fake_background:
|
|
args.append("--background")
|
|
#args.append("--factory-startup")
|
|
args.append(str(self.initial_save_path))
|
|
args.append("--python-expr")
|
|
args.append(f"""
|
|
import bpy;
|
|
import os;
|
|
from pathlib import Path;
|
|
prefs = bpy.context.preferences;
|
|
bpy.ops.preferences.addon_enable(module='SimpleBake');
|
|
allowed_addons = ['io_anim_bvh', 'io_curve_svg', 'io_mesh_uv_layout', 'io_scene_fbx', 'io_scene_gltf2', 'cycles', 'pose_library', 'bl_pkg', 'SimpleBake'];
|
|
for addon in prefs.addons.keys():
|
|
if addon not in allowed_addons:
|
|
try:
|
|
bpy.ops.preferences.addon_disable(module=addon)
|
|
print(f"Disabled " + addon)
|
|
except:
|
|
print(f"Error when disabling addon " + addon)
|
|
orig_file_path = bpy.data.filepath;
|
|
savepath=str(Path('{tmp_dir_path}') / (str(os.getpid()) + '.blend'));
|
|
bpy.ops.wm.save_as_mainfile(filepath=str(savepath), check_existing=False);
|
|
os.remove(orig_file_path);
|
|
bpy.context.scene.SimpleBake_Props.bgbake_temp_dir = '{tmp_dir_path}';
|
|
bpy.context.scene.SimpleBake_Props.base_folder_override = '{base_folder_override}';
|
|
{self.start_command};
|
|
""")
|
|
process = subprocess.Popen(args, shell=False)
|
|
|
|
self.pid = process.pid
|
|
self.file_path_po = Path(tmp_dir_path) / (str(process.pid) + ".blend")
|
|
|
|
|
|
return True
|
|
|
|
def __init__(self, name, copy_and_apply, start_command, bg_bake_id):
|
|
sbp = bpy.context.scene.SimpleBake_Props
|
|
|
|
#Check for queued that need promotion
|
|
bpy.app.timers.register(BackgroundBakeTasks.next_please, first_interval=2)
|
|
|
|
# #If this is some kind of PBR bake, run the pre-bake now
|
|
# master_switch = "P" if sbp.global_mode == "PBR" else "C"
|
|
# need = (True if ("pbr" in start_command) or
|
|
# ("automatch" in start_command and master_switch == "P") or
|
|
# ("decals" in start_command)
|
|
# else False)
|
|
# if need:
|
|
# bpy.ops.simplebake.pbr_pre_bake()
|
|
|
|
|
|
self.start_command = start_command
|
|
self.save_file_copy()
|
|
self.progress = 0
|
|
if name == "": name = "Untitled"
|
|
self.name = name
|
|
self.copy_and_apply = copy_and_apply
|
|
self.bg_bake_id = bg_bake_id
|
|
self.pid = 0
|
|
self.id = str(uuid.uuid4())
|
|
|
|
# #Restore materials in the foreground file
|
|
# bpy.ops.simplebake.material_backup(mode="master_restore")
|
|
|
|
#Assume queued for now
|
|
self.__class__.queued_tasks.append(self)
|
|
|
|
#print_message(context, "BG bake task created")
|
|
|
|
class SimpleBake_OT_Import_BGBake(Operator):
|
|
"""Import baked textures and objects from the background"""
|
|
bl_idname = "simplebake.import_bgbake"
|
|
bl_label = "Import"
|
|
|
|
pid: StringProperty()
|
|
|
|
def execute(self, context):
|
|
sbp = context.scene.SimpleBake_Props
|
|
bgt = BackgroundBakeTasks.get_completed_or_active_bgt_by_pid(self.pid)
|
|
|
|
if not force_to_object_mode(context):
|
|
return {'CANCELLED'}
|
|
|
|
b_img_names = [i.name for i in bpy.data.images]
|
|
b_obj_names = [o.name for o in context.scene.objects]
|
|
|
|
bg_blend_path = str(Path(sbp.bgbake_temp_dir) / (self.pid + ".blend"))
|
|
|
|
if bgt.copy_and_apply:
|
|
# Import objects + images together via collection append
|
|
savepath = bg_blend_path + "/Collection/"
|
|
bpy.ops.wm.append(filename="SimpleBake_Bakes_Background", directory=savepath,
|
|
use_recursive=False, active_collection=False)
|
|
|
|
sbc = bpy.data.collections["SimpleBake_Bakes_Background"]
|
|
sbc.name = "SimpleBake_Bakes"
|
|
|
|
# Relocate, as append leaves it in a funny place
|
|
context.scene.collection.children.link(sbc)
|
|
del_list = []
|
|
for c in bpy.data.collections:
|
|
if sbc.name in c.children:
|
|
c.children.unlink(sbc)
|
|
if len(c.children) == 0:
|
|
del_list.append(c.name)
|
|
for name in del_list:
|
|
c = bpy.data.collections.get(name)
|
|
if c is not None:
|
|
bpy.data.collections.remove(c)
|
|
else:
|
|
# Import images directly — no need to append objects just to delete them
|
|
replaced_names = set()
|
|
with bpy.data.libraries.load(bg_blend_path, link=False) as (data_from, data_to):
|
|
intended_names = list(data_from.images)
|
|
data_to.images = list(data_from.images)
|
|
# data_to.images[i] corresponds to intended_names[i].
|
|
# If the loaded image's name differs from its intended name, Blender renamed
|
|
# it due to a conflict with an existing foreground image. We compare by
|
|
# intended name (not by stripping .001/.002 suffixes) so that materials whose
|
|
# names legitimately contain ".001"/".002" are not misidentified as conflicts.
|
|
for intended, img in zip(intended_names, data_to.images):
|
|
if img is None:
|
|
continue
|
|
if "SB_bake_operation_id" not in img:
|
|
bpy.data.images.remove(img)
|
|
continue
|
|
if img.name != intended:
|
|
# Blender renamed this image — it conflicted with an existing foreground image
|
|
fg_img = bpy.data.images.get(intended)
|
|
if fg_img is not None and fg_img.get("SB_bake_operation_id") == img.get("SB_bake_operation_id"):
|
|
# Stale: identical image already in foreground — discard the import
|
|
bpy.data.images.remove(img)
|
|
else:
|
|
# New image that collided with an existing one (overwrite)
|
|
if fg_img is not None and "SB_bake_operation_id" in fg_img:
|
|
bpy.data.images.remove(fg_img)
|
|
replaced_names.add(intended)
|
|
img.name = intended
|
|
img.use_fake_user = True
|
|
else:
|
|
# Name unchanged — no conflict, freshly baked image
|
|
img.use_fake_user = True
|
|
# Exclude replaced names so count reflects actual new/updated images
|
|
b_img_names = [n for n in b_img_names if n not in replaced_names]
|
|
|
|
a_img_names = [i.name for i in bpy.data.images]
|
|
a_obj_names = [o.name for o in context.scene.objects]
|
|
|
|
message = []
|
|
message.append(f"Imported - {len(a_img_names) - len(b_img_names)} images")
|
|
message.append(f"Imported - {len(a_obj_names) - len(b_obj_names)} objects")
|
|
|
|
# Set fake_user on newly arrived images
|
|
diflist = [n for n in a_img_names if n not in b_img_names]
|
|
for i_name in diflist:
|
|
if i := bpy.data.images.get(i_name):
|
|
i.use_fake_user = True
|
|
|
|
# Duplicate handling only needed for copy_and_apply (wm.append path)
|
|
if bgt.copy_and_apply:
|
|
for name in b_img_names:
|
|
if name + ".002" in a_img_names:
|
|
bpy.data.images.remove(bpy.data.images[name])
|
|
bpy.data.images[name + ".002"].name = name
|
|
elif name + ".001" in a_img_names:
|
|
bpy.data.images.remove(bpy.data.images[name])
|
|
bpy.data.images[name + ".001"].name = name
|
|
|
|
BackgroundBakeTasks.completed_tasks.remove(bgt)
|
|
|
|
#Hide source and/or cage object if requested
|
|
for obj in context.scene.objects:
|
|
if "SB_BG_HIDE" in obj:
|
|
l = obj["SB_BG_HIDE"]
|
|
if bgt.bg_bake_id in l:
|
|
obj.hide_set(True)
|
|
|
|
|
|
show_message_box(context, message, "Import complete", icon = 'INFO')
|
|
return{'FINISHED'}
|
|
|
|
|
|
class SimpleBake_OT_Delete_Queued_BGBake(Operator):
|
|
"""Delete a queued background bake"""
|
|
bl_idname = "simplebake.delete_queued_bgbake"
|
|
bl_label = "Delete"
|
|
|
|
id: StringProperty()
|
|
|
|
def execute(self, context):
|
|
sbp = context.scene.SimpleBake_Props
|
|
bgt = BackgroundBakeTasks.get_queued_bgt_by_id(self.id)
|
|
|
|
BackgroundBakeTasks.queued_tasks.remove(bgt)
|
|
|
|
return{'FINISHED'}
|
|
|
|
class SimpleBake_OT_Discard_BGBake(Operator):
|
|
"""Discard a completed background bake"""
|
|
bl_idname = "simplebake.discard_bgbake"
|
|
bl_label = "Discard"
|
|
|
|
pid: StringProperty()
|
|
|
|
def execute(self, context):
|
|
sbp = context.scene.SimpleBake_Props
|
|
bgt = BackgroundBakeTasks.get_completed_or_active_bgt_by_pid(self.pid)
|
|
pid = bgt.pid
|
|
|
|
BackgroundBakeTasks.completed_tasks.remove(bgt)
|
|
path = str(bgt.file_path_po)
|
|
try:
|
|
os.remove(path)
|
|
os.remove(path.replace(".blend", ".blend1"))
|
|
except:
|
|
pass
|
|
|
|
return{'FINISHED'}
|
|
|
|
|
|
class SimpleBake_OT_Kill_Active_BGBake(Operator):
|
|
"""Kill an active background bake"""
|
|
bl_idname = "simplebake.kill_active_bgbake"
|
|
bl_label = "Kill"
|
|
|
|
pid: StringProperty()
|
|
|
|
def execute(self, context):
|
|
sbp = context.scene.SimpleBake_Props
|
|
bgt = BackgroundBakeTasks.get_completed_or_active_bgt_by_pid(self.pid)
|
|
pid = bgt.pid
|
|
|
|
BackgroundBakeTasks.active_tasks.remove(bgt)
|
|
try:
|
|
os.kill(pid, signal.SIGTERM)
|
|
except OSError:
|
|
print(f"Unable to kill process {pid}")
|
|
|
|
return{'FINISHED'}
|
|
|
|
class SimpleBake_OT_Update_Progress(Operator):
|
|
bl_idname = "simplebake.update_progress"
|
|
bl_label = "Update"
|
|
|
|
def execute(self, context):
|
|
sbp = context.scene.SimpleBake_Props
|
|
|
|
print(f"Progress update - {sbp.current_bake_image_number}")
|
|
|
|
self.background = True if "--background" in sys.argv or sbp.fake_background else False
|
|
|
|
sbp.current_bake_image_number+=1
|
|
|
|
percent_complete = (sbp.current_bake_image_number/sbp.total_bake_images_number) *100
|
|
percent_complete = round(percent_complete)
|
|
sbp.percent_complete = percent_complete
|
|
|
|
if self.background:
|
|
path = Path(sbp.bgbake_temp_dir) / "progress"
|
|
|
|
if not os.path.exists(str(path)):
|
|
os.makedirs(str(path))
|
|
|
|
pid = os.getpid()
|
|
path = path / str(pid)
|
|
|
|
with open(str(path), "w") as progfile:
|
|
progfile.write(str(percent_complete))
|
|
|
|
return{'FINISHED'}
|
|
|
|
|
|
#Non-Blender class
|
|
class BakeProgress:
|
|
|
|
def __init__(self, mode, objnum, hl_matches=None):
|
|
|
|
sbp = bpy.context.scene.SimpleBake_Props
|
|
|
|
t=0
|
|
|
|
if mode == SBConstants.PBRS2A_DECALS:
|
|
ps = len(pbr_selections_to_list(bpy.context))
|
|
ss = len(specials_selection_to_list(bpy.context))
|
|
|
|
#The S2A element
|
|
t = (ps+ss) * 1
|
|
#The non-S2A element
|
|
t+= ps * 1
|
|
|
|
sbp.total_bake_images_number = t
|
|
|
|
|
|
if mode == SBConstants.PBR:
|
|
ps = len(pbr_selections_to_list(bpy.context))
|
|
print(f"Got {ps} pbr bakes")
|
|
ss = len(specials_selection_to_list(bpy.context))
|
|
print(f"Got {ss} pbr bakes")
|
|
t = (ps + ss) * objnum
|
|
print(f"Total of {t} based on {ps+ss} x {objnum}")
|
|
|
|
sbp.total_bake_images_number = t
|
|
|
|
|
|
if mode == SBConstants.PBRS2A:
|
|
ps = len(pbr_selections_to_list(bpy.context))
|
|
ss = len(specials_selection_to_list(bpy.context))
|
|
o = 1 #S2A bake
|
|
|
|
t = (ps + ss) * o
|
|
|
|
sbp.total_bake_images_number = t
|
|
|
|
if mode == SBConstants.CYCLESBAKE:
|
|
cb = 1
|
|
ss = len(specials_selection_to_list(bpy.context))
|
|
t = (cb + ss) * objnum
|
|
sbp.total_bake_images_number = t
|
|
|
|
if mode == SBConstants.CYCLESBAKE_S2A:
|
|
cb = 1 #CyclesBake
|
|
ss = len(specials_selection_to_list(bpy.context))
|
|
objnum = 1 #S2A bake
|
|
|
|
t = (cb + ss) * objnum
|
|
sbp.total_bake_images_number = t
|
|
|
|
if mode in [SBConstants.PBRS2A_AUTOMATCH, SBConstants.CYCLESBAKES2A_AUTOMATCH]:
|
|
|
|
#Cycles bake always has only one bake mode
|
|
ps = len(pbr_selections_to_list(bpy.context)) if mode == SBConstants.PBRS2A_AUTOMATCH else 1
|
|
|
|
ss = len(specials_selection_to_list(bpy.context))
|
|
t = len(hl_matches) * (ps + ss)
|
|
|
|
sbp.total_bake_images_number = t
|
|
|
|
|
|
classes = ([
|
|
SimpleBake_OT_Update_Progress,
|
|
SimpleBake_OT_Import_BGBake,
|
|
SimpleBake_OT_Delete_Queued_BGBake,
|
|
SimpleBake_OT_Kill_Active_BGBake,
|
|
SimpleBake_OT_Discard_BGBake
|
|
])
|
|
|
|
def register():
|
|
|
|
global classes
|
|
BakeInProgress.reset()
|
|
for cls in classes:
|
|
register_class(cls)
|
|
|
|
def unregister():
|
|
global classes
|
|
BakeInProgress.reset()
|
|
for cls in classes:
|
|
unregister_class(cls)
|