Files
blender-portable-repo/extensions/blender_org/optiploy/panel.py
T
2026-03-17 14:30:01 -06:00

1255 lines
49 KiB
Python

from . import base_package
import bpy, os
from bpy.types import (UIList, Panel, Operator, Menu)
from bpy.props import *
from collections import defaultdict
from pathlib import Path
folder_path = os.path.dirname(__file__)
options = [
'localize_collections',
'localize_objects',
'localize_meshes',
'localize_materials',
'localize_node_groups',
'localize_images',
'localize_armatures',
'localize_actions',
]
extra_types = [
'localize_lights',
'localize_cameras',
'localize_curves',
'localize_text_curves',
'localize_metaballs',
'localize_surface_curves',
'localize_volumes',
'localize_grease_pencil',
]
op_props = [
'activate',
'blend',
'collection',
'folder',
'index',
'object'
]
def only(item, *argv):
for arg in argv:
if arg != item:
return False
return True
def load_data(op: bpy.types.Operator, context: bpy.types.Context, scene_viewlayer, *, post_process=False, ind_prefs=None, obj:bpy.types.Object=None, col:bpy.types.Collection=None, ):
from typing import Dict, Set
from bpy.types import ID
prefs = context.preferences.addons[base_package].preferences
props = context.scene.optiploy_props
scene, view_layer = scene_viewlayer
scene: bpy.types.Scene
view_layer: bpy.types.ViewLayer
activeCol = view_layer.active_layer_collection.collection
bone_shapes = set()
arms = set()
map_to_do = {}
gatherings = {
'override': list(),
'linked': list()
}
rev_leveled_map = dict()
refd_by = defaultdict(set)
def remap():
for linked, local in list(map_to_do.items()):
linked.user_remap(local)
map_to_do.clear()
def clean_remap(TYPE):
for ID in filter(lambda a: isinstance(a, TYPE), gatherings['override']):
map_to_do[ID] = ID.make_local()
remap()
for ID in filter(lambda a: isinstance(a, TYPE), gatherings['linked']):
map_to_do[ID] = ID.make_local()
remap()
def get_id_reference_map() -> Dict[ID, Set[ID]]:
"""Return a dictionary of direct datablock references for every datablock in the blend file."""
inv_map = {}
for key, values in bpy.data.user_map().items():
for value in values:
if value == key:
# So an object is not considered to be referencing itself.
continue
inv_map.setdefault(value, set()).add(key)
return inv_map
def recursive_get_referenced_ids(
ref_map: Dict[ID, Set[ID]], id: ID, referenced_ids: Set, visited: Set, level
):
"""Recursively populate referenced_ids with IDs referenced by id."""
if id in visited:
# Avoid infinite recursion from circular references.
return
visited.add(id)
if isinstance(id, bpy.types.Object) and isinstance(getattr(id, 'data', None), bpy.types.Armature):
arms.add(id)
bone_shapes.update(set(bone.custom_shape for bone in id.pose.bones))
OP_keep = list()
for ref in ref_map.get(id, []):
skip = False
if (ref in bone_shapes) and (id in arms):
continue
if id in refd_by[ref]:
# if the current ID was already referenced by its reference, then don't process it.
continue
refd_by[id].add(ref)
rev_leveled_map[ref] = max(rev_leveled_map.get(ref, -1), level)
if isinstance(ref, bpy.types.Collection) and not (ref in tuple(scene.collection.children_recursive)) and not (getattr(id.override_library, 'reference', None) == ref):
#ref.use_fake_user = True
OP_keep.append(ref)
continue
if isinstance(ref, bpy.types.Object) and not (ref in tuple(view_layer.objects)) and not (getattr(id.override_library, 'reference', None) == ref):
#ref.use_fake_user = True
OP_keep.append(ref)
continue
referenced_ids.add(ref)
recursive_get_referenced_ids(
ref_map=ref_map, id=ref, referenced_ids=referenced_ids, visited=visited, level=level+1
)
if OP_keep: id['OP_keep'] = OP_keep
def get_all_referenced_ids(id: ID, ref_map: Dict[ID, Set[ID]]) -> Set[ID]:
"""Return a set of IDs directly or indirectly referenced by id."""
referenced_ids = set()
rev_leveled_map[id] = 0
recursive_get_referenced_ids(
ref_map=ref_map, id=id, referenced_ids=referenced_ids, visited=set(), level=0
)
return referenced_ids
# Need local versions of bpy_extras.id_map_utils to modify how I see fit.
# Changes include:
# Finding at what level IDs are referenced
# Preventing IDs from being processed if they reference an ID who has referenced the current ID
# Collections and objects are overridden by default through override_hierarchy_create
override_support = (
bpy.types.Mesh,
bpy.types.Material,
bpy.types.SurfaceCurve,
bpy.types.Light,
bpy.types.Curve,
bpy.types.GreasePencil,
getattr(bpy.types, 'GreasePencilv3', bpy.types.GreasePencil),
bpy.types.MetaBall,
bpy.types.TextCurve,
bpy.types.Volume,
bpy.types.Armature,
bpy.types.Camera
#bpy.types.ShaderNodeTree,
#bpy.types.GeometryNodeTree,
#bpy.types.Image,
# do NOT add images to this lol
# i think it actually just makes a copy of the image. bad for optimization
)
additional = list()
prime_override = dict()
def override_order(reference):
rev_l = list(reversed(sorted(list(
rev_leveled_map.items()
) + additional, key=lambda a: a[1])))
for ID, _ in rev_l:
ID: bpy.types.ID
scene['test_prop'] = ID
possible_override = ID.override_create(remap_local_usages=True)
del scene['test_prop']
if possible_override != None:
drivers = getattr(getattr(getattr(possible_override, 'shape_keys', None),'animation_data', None),'drivers', None)
if drivers:
[setattr(target, 'id', possible_override) if target.id == ID else None for driver in drivers for variable in driver.driver.variables for target in variable.targets]
if (prime := prime_override.get(ID)) != None:
possible_override.user_remap(prime)
if isinstance(possible_override, bpy.types.Mesh) and (getattr(possible_override, 'shape_keys', None) != None):
bpy.data.batch_remove({possible_override.shape_keys})
bpy.data.batch_remove({possible_override})
else:
prime_override[ID] = possible_override
possible_override.use_fake_user = True
if ID == reference:
old, spawned = ID, possible_override
return spawned
def recurse2(ID, level=0, line:list=[]):
'''
This function was the missing piece of a puzzle. OptiPloy is complete now. No more errors when spawning.
I'd been searching for this functionality for EVER. Finally found it, without AI and without searching.
I feel like I have to flaunt it, idk
I know how simple it is, but what it does is so crucial
UPDATE:
I was so wrong when I wrote that and I shattered into pieces when I realized it didn't work.
Now it does!
The old version that message is referring to did not account for loops in the user hierarchy. This one does.
If ID2 is referencing/using ID1 but has already been in the "line", we need to stop here.
So instead of infinitely looping, lets mention ID1 again in the overriding process specifically for ID2.
So after the duplicate ID1 has been overridden, we can replace it with the original ID1 that was overridden.
We can't replace the linked IDs with the overridden IDs or else the overridden IDs will reference themselves.
THAT causes a data corruption error. But that's not happening in this case.
Now there are zero errors :)
UPDATE 2 may 24 2025:
sisyphean struggle
UPDATE 3 may 25 2025:
i talked with zayjax today about their rigs, and how one of their very complicated rigs broke the importer.
i also told them how i worked around it and fixed the importer. i had him try the importer on his many rigs,
and it worked. EVERY. SINGLE. TIME. could this be it??
UPDATE 4 may 27 2025:
i talked with dotflare this time about one of their problems. there was an issue in the way objects and collections
are handled if they are indirectly referenced by the import. if they are used, they are prone to getting deleted
because they have no users. somehow. so i attach them to the ID that uses those objects to keep them from getting
deleted.
'''
if rev_leveled_map.get(ID, -1) >= level: return
if type(ID) != bpy.types.Key:
rev_leveled_map[ID] = level
line = list(line)
line.append(ID)
refs = bpy.data.user_map().get(ID, [])
# refs is the list of IDs that are using the given ID
for ref in refs:
if ref == ID: continue
if type(ref) == bpy.types.Key:
if getattr(ID, 'shape_keys', None) == ID: continue
if getattr(ref, 'library', None) == None: continue
if ID in refd_by[ref]:
continue
refd_by[ID].add(ref)
if ref in line:
additional.append((ID, line.index(ref)-1))
continue
recurse2(ref, level + 1, line)
if obj:
if not obj in list(view_layer.objects):
activeCol.objects.link(obj)
if ind_prefs.importer == 'STABLE':
new_obj = obj.override_hierarchy_create(scene, view_layer, reference=None, do_fully_editable=True)
for user_col in obj.users_collection:
if user_col.library: continue
user_col.objects.unlink(obj)
obj = new_obj
if obj == None: return {'CANCELLED'}
spawned = obj
else:
parent = obj
while parent.parent:
parent = parent.parent
if parent in list(view_layer.objects): continue
activeCol.objects.link(parent)
rev_leveled_map[obj] = 0
recurse2(obj, 1)
spawned = override_order(obj)
override_order(obj)
rev_leveled_map.clear()
refd_by.clear()
if col:
if not col in scene.collection.children_recursive:
scene.collection.children.link(col)
if ind_prefs.importer == 'STABLE':
col_users = bpy.data.user_map(subset=[col])[col]
new_col = col.override_hierarchy_create(scene, view_layer, reference=None, do_fully_editable=True)
if new_col == None: return {'CANCELLED'}
for user in col_users:
if isinstance(user, bpy.types.Scene):
if col in list(user.collection.children):
user.collection.children.unlink(col)
if isinstance(user, bpy.types.Collection):
if user.library: continue
if col in list(user.children):
user.children.unlink(col)
col = new_col
spawned = col
else:
recurse2(col, 0, [])
for object in list(col.all_objects):
if object.parent: continue
recurse2(object, 0, [])
spawned = override_order(col)
override_order(col)
rev_leveled_map.clear()
refd_by.clear()
#return {'FINISHED'}
id_ref = get_id_reference_map()
id_ref = get_all_referenced_ids(spawned, id_ref)
sorted_refs = list(map(lambda a: a[0],
sorted(list(
rev_leveled_map.items()
), key=lambda a: a[1])
))
rev_leveled_map.clear()
refd_by.clear()
for ID in filter(lambda a: getattr(a, 'library', None) != None, sorted_refs):
if isinstance(ID, override_support):
possible_override = ID.override_create(remap_local_usages=True)
if possible_override != None:
drivers = getattr(getattr(getattr(possible_override, 'shape_keys', None),'animation_data', None),'drivers', None)
if drivers:
[setattr(target, 'id', possible_override) if target.id == ID else None for driver in drivers for variable in driver.driver.variables for target in variable.targets]
# This is really specific, but with good cause.
# Say you have a mesh ID with a shape key ID, and the shape key has values that are being driven by the mesh ID.
# On some occasions, when creating an overridden mesh with a shape key ID (which will therefore creating an overridden copy of the shape key), the values on the shape key ID will continue to be driven by the linked mesh. This "function comprehension" corrects that.
# Don't know how many other situations where something like this can happen, but I don't imagine it being difficult to fix.
# This has led me to come across a serious design flaw in Blender, but one I'm not sure can be fixed. You can replace IDs with other IDs, even if users are using those IDs on a read-only attribute. It will lead to data corruption.
ID = possible_override
gatherings['linked'].append(ID)
for ID in filter(lambda a: getattr(a, 'override_library', None) != None, sorted_refs):
if ID.override_library.reference in gatherings['linked']:
gatherings['linked'].remove(ID.override_library.reference)
gatherings['override'].append(ID)
'''
What's the reason for all this weird code?
This is the result of my desire for "data isolation." When OptiPloy used my first method of linking, spawning previously spawned collections with different settings would localize
the data in the previously spawned collections. That really annoyed me. Using bpy.data.temp_data() didn't help, because linking within it would break Blender, and my only real
solution was to have a second instance of Blender running to prepare the data for the main instance to use, which actually worked. But I didn't like it, because despite using
factory settings, its RAM usage would increase with every spawned item. Too bad considering how well it worked. But I *really* wanted it to all be local.
My solution? Library overrides!
Library overridden IDs float between a state of localized and linked. Technically with every "LO" ID, you *are* increasing storage usage, but not as much as you would be since it
still very much relies on the linked stuff. It *is* a good solution for this "data isolation" concept, because it prevents the localization of pre-existing data, but its far from
the best one. I have to implement checks for specific things. I don't doubt that makes people unhappy, but its not like this code is being ran 24/7. Right now, the only checks
being performed are for drivers between meshes and their shape keys (literally) and preventing bone shapes from being processed if they are only used by any armature. It's
possible I'll run into more situations that I need to counter, but it cannot be that hard to fix.
Famous last words?
'''
if ind_prefs.localize_collections:
clean_remap(bpy.types.Collection)
if ind_prefs.localize_objects:
clean_remap(bpy.types.Object)
if ind_prefs.localize_meshes:
clean_remap(bpy.types.Mesh)
if ind_prefs.localize_armatures:
clean_remap(bpy.types.Armature)
if ind_prefs.localize_materials:
clean_remap(bpy.types.Material)
if ind_prefs.localize_node_groups:
clean_remap(bpy.types.NodeGroup)
clean_remap(bpy.types.GeometryNodeTree)
clean_remap(bpy.types.ShaderNodeTree)
if ind_prefs.localize_images:
clean_remap(bpy.types.Image)
if ind_prefs.localize_actions:
clean_remap(bpy.types.Action)
if ind_prefs.localize_surface_curves:
clean_remap(bpy.types.SurfaceCurve)
if ind_prefs.localize_lights:
clean_remap(bpy.types.Light)
if ind_prefs.localize_cameras:
clean_remap(bpy.types.Camera)
if ind_prefs.localize_curves:
clean_remap(bpy.types.Curve)
if ind_prefs.localize_text_curves:
clean_remap(bpy.types.TextCurve)
if ind_prefs.localize_metaballs:
clean_remap(bpy.types.MetaBall)
if ind_prefs.localize_volumes:
clean_remap(bpy.types.Volume)
if ind_prefs.localize_grease_pencil:
clean_remap(bpy.types.GreasePencil)
clean_remap(bpy.types.GreasePencilv3)
if op.do_storage_benchmark:
return spawned
if col and prefs.to_cursor:
for object in spawned.all_objects:
if object.parent: continue
object.location = scene.cursor.location
if obj and prefs.to_cursor:
top = spawned
while top.parent != None:
top = top.parent
top.location = scene.cursor.location
context.scene['new_spawn'] = spawned # assign the newly spawned item to a globally accessible variable, giving developers the opportunity to further modify data in the scripts execution stage
scene['optiploy_last_spawned'] = spawned
context.scene['optiploy_last_spawned'] = spawned
if prefs.execute_scripts:
for text in filter(lambda a: isinstance(a, bpy.types.Text), gatherings['linked']):
text.as_module()
scn = context.scene
# init rigid body physics
for id in filter(lambda a: isinstance(a, bpy.types.Object), gatherings['override']):
if getattr(id, 'rigid_body', None):
if scn.rigidbody_world == None:
bpy.ops.rigidbody.world_add()
if (rbw := getattr(scn.rigidbody_world, 'collection', None)) == None:
rbw = bpy.data.collections.new('RigidBodyWorld')
scn.rigidbody_world.collection = rbw
if not id in list(rbw.objects): rbw.objects.link(id)
if getattr(id, 'rigid_body_constraint', None):
if scn.rigidbody_world == None:
bpy.ops.rigidbody.world_add()
if (rbc := getattr(scn.rigidbody_world, 'constraints', None)) == None:
rbc = bpy.data.collections.new('RigidBodyConstraints')
scn.rigidbody_world.constraints = rbc
if not id in list(rbc.objects): rbc.objects.link(id)
del context.scene['new_spawn']
map_to_do.clear()
gatherings['linked'].clear()
gatherings['override'].clear()
arms.clear()
additional.clear()
bone_shapes.clear()
del sorted_refs, map_to_do, gatherings
bpy.data.orphans_purge(True, False, True)
return {'FINISHED'}
class SPAWNER_GENERIC_SPAWN_UL_List(UIList):
def draw_item(self, context,
layout: bpy.types.UILayout, data,
item, icon,
active_data, active_propname,
index):
prefs = context.preferences.addons[base_package].preferences
props = context.scene.optiploy_props
itemType = item.bl_rna.identifier
Type = 'OBJECT' if itemType == 'objects' else 'COLLECTION'
Icon = 'OBJECT_DATA' if itemType == 'objects' else 'OUTLINER_COLLECTION'
row = layout.row()
row.label(text=item.name, icon=Icon)
row = row.row()
row.alignment='RIGHT'
if Type == 'OBJECT':
if index != prefs.obj_index:
row.label(text='Spawn')
row.separator()
row.enabled = False
return
if Type == 'COLLECTION':
if index != prefs.col_index:
row.label(text='Spawn')
row.separator()
row.enabled = False
return
row = row.row(align=True)
row.alignment = 'RIGHT'
benchmark_op = row.operator('spawner.spawner', icon='DISK_DRIVE', text='')
op = row.operator('spawner.spawner')
op.activate=True
if props.view == 'BLENDS':
op.blend = int(props.selected_blend)
op.folder = -1
if props.view == 'FOLDERS':
folder = prefs.folders[int(props.selected_folder)]
op.folder = int(props.selected_folder)
op.blend = int(folder.selected_blend)
if Type == 'OBJECT':
op.object = item.name
op.collection = ''
if Type == 'COLLECTION':
op.collection = item.name
op.object = ''
for attr in op_props:
setattr(benchmark_op, attr, getattr(op, attr))
benchmark_op.do_storage_benchmark = True
op.do_storage_benchmark = False
class SPAWNER_PT_folder_settings(Panel):
bl_label = 'Settings'
bl_space_type = 'VIEW_3D'
bl_region_type = 'WINDOW'
bl_options = {'INSTANCED'}
def draw(self, context):
prefs = context.preferences.addons[base_package].preferences
props = context.scene.optiploy_props
layout = self.layout
layout.label(text='Folder Settings')
folder = prefs.folders[int(props.selected_folder)]
layout.prop(folder, 'override_behavior')
box = layout.box()
r = box.row()
r.label(text = 'Importer')
r.operator('wm.url_open', text='', icon='QUESTION').url = os.path.join(os.path.dirname(__file__), 'htmls', 'importers.html')
box.row().prop(folder, 'importer', expand=True)
box.enabled = folder.override_behavior
box = layout.box()
box.enabled = folder.override_behavior
for i in options:
box.prop(folder, i)
box.label(text='Extra Types')
for i in extra_types:
box.prop(folder, i)
class SPAWNER_PT_blend_settings(Panel):
bl_label = 'Settings'
bl_space_type = 'VIEW_3D'
bl_region_type = 'WINDOW'
bl_options = {'INSTANCED'}
def draw(self, context):
prefs = context.preferences.addons[base_package].preferences
props = context.scene.optiploy_props
layout = self.layout
folder = None
if props.view == 'BLENDS':
blend = prefs.blends[int(props.selected_blend)]
else:
folder = prefs.folders[int(props.selected_folder)]
blend = folder.blends[int(folder.selected_blend)]
layout.label(text='Blend Settings')
row = layout.row()
row.prop(blend, 'override_behavior')
#row.enabled = getattr(folder, 'override_behavior', True)
box = layout.box()
box.enabled = blend.override_behavior
r = box.row()
r.label(text = 'Importer')
r.operator('wm.url_open', text='', icon='QUESTION').url = os.path.join(os.path.dirname(__file__), 'htmls', 'importers.html')
box.row().prop(blend, 'importer', expand=True)
box = layout.box()
box.enabled = blend.override_behavior
for i in options:
box.prop(blend, i)
box.label(text='Extra Types')
for i in extra_types:
box.prop(blend, i)
class SPAWNER_PT_extra_settings(Panel):
bl_label = 'Extra Localization Options'
bl_space_type = 'VIEW_3D'
bl_region_type = 'WINDOW'
bl_options = {'INSTANCED'}
def draw(self, context):
prefs = context.preferences.addons[base_package].preferences
props = context.scene.optiploy_props
layout = self.layout
layout.label(text=self.bl_label)
box = layout.box()
for i in extra_types:
box.prop(prefs, i)
class SPAWNER_PT_panel(Panel):
bl_label = 'OptiPloy'
bl_space_type='VIEW_3D'
bl_region_type='UI'
bl_category = 'OptiPloy'
def draw(self, context):
prefs = context.preferences.addons[base_package].preferences
data = prefs
props = context.scene.optiploy_props
layout = self.layout
box = layout.box()
row = box.row()
row.label(text='View Mode')
row.alignment = 'RIGHT'
row.scale_x = 2
row.scale_y = 1.6
row.prop(props, 'view', expand=True, icon_only=True, text='View')
if props.view == 'BLENDS':
if not len(prefs.blends):
layout.row().label(text='Add a .blend file in the preferences to get started!')
return
blend_ind = int(props.selected_blend or '0')
blend = prefs.blends[blend_ind]
row = layout.row()
row.alert = True
op = row.operator('spawner.open_blend', text='', icon='BLENDER')
op.text = '''Hold CTRL to reload the .blend file as a library.
Hold SHIFT to open the .blend file in a new instance of Blender.
Hold ALT to re-scan the .blend file in OptiPloy.
Don't worry about the button being red. It's only meant to call your attention.'''
op.icons='EVENT_CTRL,EVENT_SHIFT,EVENT_ALT,QUESTION'
op.size='56,56,56,56'
op.width=350
op.path = blend.filepath
op.folder = -1
op.blend = int(props.selected_blend or '0')
row = row.row()
row.alert = False
row.prop(props, 'selected_blend', text='')
row.popover('SPAWNER_PT_blend_settings', text='', icon='SETTINGS')
if props.view == 'FOLDERS':
if not len(prefs.folders):
layout.row().label(text='Add a folder of .blend files in the preferences to get started!')
return
folder_ind = int(props.selected_folder or '0')
folder = prefs.folders[folder_ind]
row = layout.row()
row.alert=True
op = row.operator('spawner.open_folder', text='', icon='FILE_FOLDER')
op.text = '''Hold ALT to re-scan the .blend file in OptiPloy.
Don't worry about the button being red. It's only meant to call your attention.'''
op.icons='EVENT_ALT,QUESTION'
op.size='56,56'
op.width=350
op.folder = folder_ind
row = row.row()
row.alert=False
#row.label(text='', icon='FILE_FOLDER')
row.prop(props, 'selected_folder', text='')
row.popover('SPAWNER_PT_folder_settings', text='', icon='SETTINGS')
if not len(folder.blends):
layout.row().label(text='This folder has no .blend files marked! Has it been scanned?')
return None
blend_ind = int(folder.selected_blend or '0')
blend = folder.blends[blend_ind]
row = layout.row()
row.alert = True
#row.label(text='', icon='BLENDER')
op = row.operator('spawner.open_blend', text='', icon='BLENDER')
op.text = '''Hold CTRL to reload the .blend file as a library.
Hold SHIFT to open the .blend file in a new instance of Blender.
Hold ALT to re-scan the .blend file in OptiPloy.
Don't worry about the button being red. It's only meant to call your attention.'''
op.icons='EVENT_CTRL,EVENT_SHIFT,EVENT_ALT,QUESTION'
op.size='56,56,56,56'
op.width=350
op.path = blend.filepath
op.folder = int(int(props.selected_folder))
op.blend = int(folder.selected_blend)
row = row.row()
row.alert = False
row.prop(folder, 'selected_blend', text='')
row.popover('SPAWNER_PT_blend_settings', text='', icon='SETTINGS')
if props.view != 'TOOLS':
box = layout.box()
if not (len(blend.objects) + len(blend.collections)):
box.row().label(text="Nothing in this file detected! Mark items as assets!")
return
objBox = box.box()
row = objBox.row()
row.label(text='Objects', icon='OBJECT_DATA')
op = row.operator('spawner.textbox', icon='QUESTION', text='')
if len(blend.objects):
objBox.row().template_list('SPAWNER_GENERIC_SPAWN_UL_List', 'Items',
blend, 'objects', prefs, 'obj_index')
op.text = '''Here are the objects you can spawn!
To spawn an item, it has to be the active item. This serves as a way of confirming.'''
op.icons = 'OBJECT_DATA,CHECKMARK'
op.size='56,56'
op.width=350
else:
objBox.row().label(text='No objects added!')
op.text = '''The selected .blend file has no objects marked as assets!'''
op.icons = 'ERROR'
op.size='56'
op.width=350
colBox = box.box()
row = colBox.row()
row.label(text='Collections', icon='OUTLINER_COLLECTION')
op = row.operator('spawner.textbox', icon='QUESTION', text='')
if len(blend.collections):
colBox.row().template_list('SPAWNER_GENERIC_SPAWN_UL_List', 'Collections',
blend, 'collections', prefs, 'col_index')
op.text = '''Here are the collections you can spawn!
To spawn an item, it has to be the active item. This serves as a way of confirming.'''
op.icons = 'OUTLINER_COLLECTION,CHECKMARK'
op.size='56,56'
op.width=350
else:
colBox.row().label(text='No collections added!')
op.text = '''The selected .blend file has no collections marked as assets!'''
op.icons = 'ERROR'
op.size='56'
op.width=350
if props.view == 'TOOLS':
col = layout.column()
r = col.row()
r.label(text='Importer')
r.operator('wm.url_open', text='', icon='QUESTION').url = os.path.join(os.path.dirname(__file__), 'htmls', 'importers.html')
col.box().row().prop(prefs, 'importer', expand=True)
layout.label(text='Post-Processing')
box = layout.box()
box.prop(prefs, 'to_cursor')
box.prop(prefs, 'execute_scripts')
layout.label(text='Behavior')
box = layout.box()
box.prop(prefs, 'localize_collections')
box.prop(prefs, 'localize_objects')
box.prop(prefs, 'localize_meshes')
box.prop(prefs, 'localize_materials')
box.prop(prefs, 'localize_node_groups')
box.prop(prefs, 'localize_images')
box.prop(prefs, 'localize_armatures')
box.prop(prefs, 'localize_actions')
box.popover('SPAWNER_PT_extra_settings')
layout.operator('preferences.addon_show', text='Open Preferences').module = base_package
if not context.preferences.use_preferences_save:
layout.operator('wm.save_userpref')
layout.separator()
op = layout.operator('spawner.textbox', text='Donate')
op.text = '''Like the add-on? Consider supporting my work:
LINK:https://ko-fi.com/hisanimations|NAME:Ko-Fi
LINK:https://superhivemarket.com/products/optiploy-pro|NAME:Buy OptiPloy Pro on Superhive'''
op.size = '56,56,56'
op.icons = 'BLANK1,NONE,NONE'
op.width = 350
class mod_saver(Operator):
def invoke(self, context, event):
ctrl, shift, alt = event.ctrl, event.shift, event.alt
scn = context.scene
scn['key_ctrl'] = ctrl
scn['key_shift'] = shift
scn['key_alt'] = alt
return_val = self.execute(context)
del scn['key_ctrl'], scn['key_shift'], scn['key_alt']
return return_val
class SPAWNER_OT_SPAWNER(mod_saver):
bl_idname = 'spawner.spawner'
bl_label = 'Spawn'
bl_description = 'Spawn it!'
bl_options = {'UNDO'}
type: EnumProperty(
items=(
('COLLECTION', 'Collection', 'This spawnable is a collection', 'COLLECTION', 0),
('OBJECT', 'Object', 'This spawnable is an object', 'OBJECT', 1)
),
name='Type',
options=set()
)
blend: IntProperty(default=-1)
folder: IntProperty(default=-1)
object: StringProperty(default='')
collection: StringProperty(default='')
activate: BoolProperty()
scene: StringProperty(default='')
index: IntProperty()
do_storage_benchmark: BoolProperty(default=False)
compress_append: BoolProperty(default=False, name='Compress Append Result', description='Choose to compress the result after loading the traditional way via appending')
compress_optiploy: BoolProperty(default=True, name='Compress OptiPloy Result', description='Choose to compress the result after loading via OptiPloy')
_time = None
def invoke(self, context, event):
if not self.do_storage_benchmark:
return super().invoke(context, event)
return context.window_manager.invoke_props_dialog(self)
def get_prefs(self, context):
prefs = context.preferences.addons[base_package].preferences
blend = self.blend
folder = self.folder
if (folder != -1) and (blend != -1):
folder = prefs.folders[folder]
if folder.override_behavior:
prefs = folder
entry = folder.blends[blend]
if entry.override_behavior:
prefs = entry
if (blend != -1) and (folder == -1):
entry = prefs.blends[blend]
if entry.override_behavior:
prefs = entry
return prefs, entry
def load_test(self, context):
obj = self.object
col = self.collection
prefs, entry = self.get_prefs(context)
test_path = os.path.join(os.path.dirname(__file__), 'test.blend')
temp = bpy.data
if (was_saved := temp.is_saved):
re_open = temp.filepath
bpy.ops.wm.save_mainfile()
bpy.ops.wm.read_homefile(app_template="")
with temp.libraries.load(entry.filepath) as (f, t):
if obj:
t.objects = [obj]
if col:
t.collections = [col]
item = (t.objects or t.collections)[0]
temp.libraries.write(test_path, {item}, compress=self.compress_append)
old_size = os.path.getsize(test_path)
bpy.ops.wm.read_homefile(app_template="")
with temp.libraries.load(entry.filepath, link=True) as (f, t):
if obj:
t.objects = [obj]
if col:
t.collections = [col]
scn = temp.scenes.new('scn')
item = (t.objects or t.collections)[0]
if obj:
spawned = load_data(self, context, [scn, scn.view_layers[0]], ind_prefs=prefs, obj=item)
if col:
spawned = load_data(self, context, [scn, scn.view_layers[0]], ind_prefs=prefs, col=item)
temp.libraries.write(test_path, {spawned}, compress=self.compress_optiploy)
new_size = os.path.getsize(test_path)
os.remove(test_path)
if was_saved:
bpy.ops.wm.open_mainfile(filepath=re_open)
else:
bpy.ops.wm.read_homefile(app_template="")
def format_size(size_in_bytes):
"""
Convert size in bytes to a human-readable format.
"""
for unit in ['B', 'KB', 'MB', 'GB', 'TB']:
if size_in_bytes < 1024.0:
return f"{size_in_bytes:.2f} {unit}"
size_in_bytes /= 1024.0
self.report({'INFO'}, f'OptiPloy saves {format_size(old_size-new_size)}')
return {'FINISHED'}
def execute(self, context):
if not self.activate: return {'CANCELLED'}
if self.do_storage_benchmark:
return self.load_test(context)
prefs = context.preferences.addons[base_package].preferences
obj = self.object
col = self.collection
prefs, entry = self.get_prefs(context)
if not os.path.exists(entry.filepath):
self.report({'ERROR'}, f"{entry.filepath} doesn't exist!")
#self.report({'ERROR'}, "The .blend file no longer exists!")
return {'CANCELLED'}
try:
with bpy.data.libraries.load(entry.filepath, link=True, relative=True) as (From, To):
if obj:
To.objects = [obj]
if col:
To.collections = [col]
except:
self.report({'ERROR'}, f'The .blend you are trying to open is corrupt!')
return {'CANCELLED'}
import_scene = bpy.data.scenes.get(self.scene, None) or context.scene
view_layer = getattr(import_scene, 'view_layers', [context.view_layer])[0] if self.scene else context.view_layer
scene_viewlayer = [import_scene, view_layer]
if obj:
if To.objects[0] == None:
self.report({'ERROR'}, f'Object "{obj}" could not be found in {os.path.basename(entry.filepath)}')
return {'CANCELLED'}
return load_data(self, context, scene_viewlayer, ind_prefs=prefs, obj=To.objects[0])
if col:
if To.collections[0] == None:
self.report({'ERROR'}, f'Collection "{col}" could not be found in {os.path.basename(entry.filepath)}')
return {'CANCELLED'}
return load_data(self, context, scene_viewlayer, ind_prefs=prefs, col=To.collections[0])
self.report({'WARNING'}, 'What?')
return {'CANCELLED'}
def draw(self, context):
sentences = f'''This is for checking how much storage you save with OptiPloy.
This process requires a clean slate.
{"This file has NOT been saved, and you will lose progress if you continue." if not bpy.data.is_saved else "This file will be saved, and then re-opened."}
Continue? '''.split('\n')
icons = f'DISK_DRIVE,ERROR,{"ERROR" if not bpy.data.is_saved else "CHECKMARK"},QUESTION'.split(',')
sizes = '56,56,56,56'.split(',')
for sentence, icon, size in zip(sentences, icons, sizes):
textBox(self.layout, sentence, icon, int(size))
self.layout.prop(self, 'compress_append')
self.layout.prop(self, 'compress_optiploy')
class SPAWNER_OT_POST_OPTIMIZE(mod_saver):
bl_idname = 'spawner.post_optimize'
bl_label = 'Optimize with OptiPloy'
bl_description = 'Optimize the selected linked objects with OptiPloy'
bl_options = {'UNDO'}
def execute(self, context):
#print(context.space_data, context.area.type, context.window, context.screen)
#return {'CANCELLED'}
#import_scene = bpy.data.scenes.get(self.scene, None) or context.scene
#view_layer = getattr(import_scene, 'view_layers', [context.view_layer])[0] if self.scene else context.view_layer
scene_viewlayer = [context.scene, context.view_layer]
if context.area.type == 'VIEW_3D':
ids = context.selected_objects
if context.area.type == 'OUTLINER':
ids = context.selected_ids
cols = set(filter(lambda a: isinstance(a, bpy.types.Collection) and getattr(a, 'library', None) != None, ids)) # get selected collections
objs = filter(lambda a: isinstance(a, bpy.types.Object), ids) # get selected objects
objs = set(filter(lambda a: not (True in [col in cols for col in a.users_collection]), objs)) # remove objects if their collection is selected
objs = set(filter(lambda a: not (getattr(a, 'parent', False) in objs), objs)) # only get the top most selected objects
true_ids = set()
for id in objs:
for col in id.users_collection:
if col.library:
cols.add(col)
break
else:
true_ids.add(id)
ids = true_ids.union(cols)
prefs = context.preferences.addons[base_package].preferences
for id in ids:
conditions_for_instance = [
getattr(id, 'type', None) == 'EMPTY', # if object is an empty
getattr(id, 'instance_type', None) == 'COLLECTION', # if the empty has its instance type set to collection
getattr(getattr(id, 'instance_collection', None), 'library', None) != None, # and the instanced collection has linked library data
]
conditions_for_object = [
isinstance(id, bpy.types.Object),
id.library != None,
]
conditions_for_col = [
isinstance(id, bpy.types.Collection),
id.library != None,
]
if not False in conditions_for_instance:
col = id.instance_collection
return_val = load_data(self, context, scene_viewlayer, ind_prefs=prefs, col=col)
if return_val == {'FINISHED'}:
bpy.data.objects.remove(id)
elif not False in conditions_for_col:
load_data(self, context, scene_viewlayer, ind_prefs=prefs, col=id)
elif not False in conditions_for_object:
load_data(self, context, scene_viewlayer, ind_prefs=prefs, obj=id)
return {'FINISHED'}
class SPAWNER_OBJECT_UL_List(UIList):
def draw_item(self, context,
layout, data,
item, icon,
active_data, active_propname,
index):
layout.label(text=item.name)
def textBox(self, sentence, icon='NONE', line=56):
layout = self.box().column()
if sentence.startswith('LINK:'):
url, name = sentence.split('|')
url = url.split('LINK:', maxsplit=1)[1]
name = name.split('NAME:', maxsplit=1)[1]
#print(url, name)
layout.row().operator('wm.url_open', text=name, icon='URL').url = url
return None
sentence = sentence.split(' ')
mix = sentence[0]
sentence.pop(0)
broken = False
while True:
add = ' ' + sentence[0]
if len(mix + add) < line:
mix += add
sentence.pop(0)
if sentence == []:
layout.row().label(text=mix, icon='NONE' if broken else icon)
return None
else:
layout.row().label(text=mix, icon='NONE' if broken else icon)
broken = True
mix = sentence[0]
sentence.pop(0)
if sentence == []:
layout.row().label(text=mix)
return None
class generictext(bpy.types.Operator):
text: StringProperty(default='')
icons: StringProperty()
size: StringProperty()
width: IntProperty(default=400)
url: StringProperty(default='')
def invoke(self, context, event):
if not getattr(self, 'prompt', True):
return self.execute(context)
if event.shift and self.url != '':
bpy.ops.wm.url_open(url=self.url)
return self.execute(context)
self.invoke_extra(context, event)
return context.window_manager.invoke_props_dialog(self, width=self.width)
def invoke_extra(self, context, event):
pass
def draw(self, context):
sentences = self.text.split('\n')
icons = self.icons.split(',')
sizes = self.size.split(',')
for sentence, icon, size in zip(sentences, icons, sizes):
textBox(self.layout, sentence, icon, int(size))
self.draw_extra(context)
def draw_extra(self, context):
pass
def execute(self, context):
return {'FINISHED'}
class SPAWNER_OT_genericText(generictext):
bl_idname = 'spawner.textbox'
bl_label = 'Hints'
bl_description = 'A window will display any possible questions you have'
class SPAWNER_OT_open_blend(generictext):
bl_idname = 'spawner.open_blend'
bl_label = 'Blend Multi-Tool'
bl_description = 'Hold Shift to open the selected .blend file, hold Ctrl to reload, hold Alt to re-scan'
blend: IntProperty()
folder: IntProperty()
path: StringProperty(name='Path')
blend_path_add: StringProperty(default='', subtype='FILE_PATH')
use_current_blend:BoolProperty(default=False)
def invoke(self, context, event):
blendPath = Path(str(self.path))
if (event.ctrl + event.shift + event.alt) > 1:
return {'CANCELLED'}
self.text = 'tee hee no'
self.icons = 'NONE'
self.size='56'
self.width = 310
return context.window_manager.invoke_props_dialog(self, width=self.width)
if event.ctrl:
for lib in bpy.data.libraries:
blendPathLib = Path(bpy.path.abspath(lib.filepath))
if blendPathLib == blendPath: lib.reload(); return {'FINISHED'}
return {'FINISHED'}
if event.shift:
import subprocess
subprocess.Popen([bpy.app.binary_path, blendPath])
return {'FINISHED'}
if event.alt:
return bpy.ops.spawner.scan('INVOKE_DEFAULT', blend=self.blend, folder=self.folder)
return context.window_manager.invoke_props_dialog(self, width=self.width)
def draw_extra(self, context):
layout = self.layout
layout.separator()
col = layout.column()
main_row = col.row()
r = main_row.row()
r.alignment = 'LEFT'
r.label(text='Add .blend')
r = main_row.row()
r.alignment = 'RIGHT'
r.enabled = bpy.data.is_saved
r.prop(self, 'use_current_blend', text='Add Active .blend')
box = col.box()
col_box = box.column()
row = col_box.row()
row.prop(self, 'blend_path_add', text='Filepath')
row.enabled = 1-(self.use_current_blend and bpy.data.is_saved)
op = col_box.operator('spawner.add_entry')
op.filepath = bpy.data.filepath if (self.use_current_blend and bpy.data.is_saved) else bpy.path.abspath(self.blend_path_add)
op.execute_only = True
op.blend = True
op.folder = bool(self.folder+1)
op.folder_select = self.folder
class SPAWNER_OT_open_folder(generictext):
bl_idname = 'spawner.open_folder'
bl_label = 'Folder Multi-Tool'
bl_description = 'Hold Shift to open the selected folder, hold Ctrl to reload, hold Alt to re-scan'
folder: IntProperty()
path: StringProperty(name='Path')
folder_path_add: StringProperty(default='', subtype='DIR_PATH')
add_category: BoolProperty(default=False)
category_name: StringProperty(default='New Category')
def invoke(self, context, event):
blendPath = Path(str(self.path))
self.category_name = 'New Category'
if (event.ctrl + event.shift + event.alt) > 1:
return {'CANCELLED'}
self.text = 'tee hee no'
self.icons = 'NONE'
self.size='56'
self.width = 310
return context.window_manager.invoke_props_dialog(self, width=self.width)
if event.ctrl:
#for lib in bpy.data.libraries:
# blendPathLib = Path(bpy.path.abspath(lib.filepath))
# if blendPathLib == blendPath: lib.reload(); return {'FINISHED'}
return {'FINISHED'}
if event.shift:
#import subprocess
#subprocess.Popen([bpy.app.binary_path, blendPath])
return {'FINISHED'}
if event.alt:
return bpy.ops.spawner.scan('INVOKE_DEFAULT', blend=-1, folder=self.folder)
return context.window_manager.invoke_props_dialog(self, width=self.width)
def draw_extra(self, context):
layout = self.layout
layout.separator()
col = layout.column()
main_row = col.row()
r = main_row.row()
r.alignment = 'LEFT'
r.label(text='Add Folder')
r = main_row.row()
r.alignment = 'RIGHT'
r.prop(self, 'add_category', text='Add Category')
box = col.box()
col_box = box.column()
row = col_box.row()
if self.add_category:
r = row.row()
r.alignment = 'LEFT'
r.label(text='Category Name:')
r = row.row()
r.alignment = 'RIGHT'
row.prop(self, 'category_name', text='')
else:
row.prop(self, 'folder_path_add', text='Filepath')
#row.enabled = 1-self.add_category
op = col_box.operator('spawner.add_entry', text='Add Folder Entry')
op.directory = self.folder_path_add
op.execute_only = True
op.blend = False
op.folder = True
op.category = self.add_category
op.category_name = self.category_name
def draw_item(self:bpy.types.Menu, context):
layout = self.layout
layout.separator()
layout.operator('spawner.post_optimize')
classes = [
SPAWNER_PT_panel,
SPAWNER_GENERIC_SPAWN_UL_List,
SPAWNER_OT_SPAWNER,
SPAWNER_OT_POST_OPTIMIZE,
SPAWNER_OT_genericText,
SPAWNER_PT_extra_settings,
SPAWNER_PT_folder_settings,
SPAWNER_PT_blend_settings,
SPAWNER_OT_open_blend,
SPAWNER_OT_open_folder
]
def register():
for i in classes:
bpy.utils.register_class(i)
bpy.types.VIEW3D_MT_object.append(draw_item)
bpy.types.OUTLINER_MT_context_menu.append(draw_item)
bpy.types.OUTLINER_MT_object.append(draw_item)
bpy.types.OUTLINER_MT_collection.append(draw_item)
def unregister():
for i in reversed(classes):
bpy.utils.unregister_class(i)
bpy.types.VIEW3D_MT_object.remove(draw_item)
bpy.types.OUTLINER_MT_context_menu.remove(draw_item)
bpy.types.OUTLINER_MT_object.remove(draw_item)
bpy.types.OUTLINER_MT_collection.remove(draw_item)