Files
blender-portable-repo/extensions/user_default/blenderkit/append_link.py
T
2026-03-17 15:25:32 -06:00

786 lines
29 KiB
Python

# ##### BEGIN GPL LICENSE BLOCK #####
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# ##### END GPL LICENSE BLOCK #####
import logging
import uuid
from typing import Optional
import bpy
from . import utils, reports
bk_logger = logging.getLogger(__name__)
def find_layer_collection(layer_collection, collection_name):
"""Helper function to find a layer_collection by name"""
if layer_collection.collection.name == collection_name:
return layer_collection
for child in layer_collection.children:
result = find_layer_collection(child, collection_name)
if result:
return result
return None
def append_brush(file_name, brushname=None, link=False, fake_user=True):
"""append a brush"""
brushes_before = bpy.data.brushes[:]
with bpy.data.libraries.load(file_name, link=link, relative=True) as (
data_from,
data_to,
):
for m in data_from.brushes:
if brushname is None or m.strip() == brushname.strip():
data_to.brushes = [m]
brushname = m
for b in bpy.data.brushes:
if b not in brushes_before:
brush = b
break
brush.use_fake_user = fake_user
return brush
def append_nodegroup(
file_name,
nodegroupname=None,
link=False,
fake_user=True,
node_x=0,
node_y=0,
target_object=None,
nodegroup_mode="",
model_location=(0, 0, 0),
model_rotation=(0, 0, 0),
**kwargs,
):
"""Append selected node group. If nodegroupname is None, first node group is appended.
If node group with the same name is already in the scene, it is not appended again.
Try to look for a suitable node editor and insert the node group there, or create/use modifier based on mode.
For geometry nodegroups, if no target object is provided, a target object will be created automatically.
Args:
file_name: Path to the .blend file containing the nodegroup
nodegroupname: Name of the nodegroup to append
link: Whether to link or append
fake_user: Whether to set fake user
node_x: X position for node placement in editor
node_y: Y position for node placement in editor
target_object: Target object for modifier mode (name string). If None and nodegroup is geometry type, a target object will be created
nodegroup_mode: How to add the nodegroup - "MODIFIER" for new modifier, "NODE" for node in editor, "" for default behavior
model_location: Location for the target object (used when creating new target)
model_rotation: Rotation for the target object (used when creating new target)
Returns:
tuple: (nodegroup, added_to_editor) - The nodegroup and whether it was added to an editor
"""
with bpy.data.libraries.load(file_name, link=link, relative=True) as (
data_from,
data_to,
):
for g in data_from.node_groups:
if nodegroupname is None or g.strip() == nodegroupname.strip():
data_to.node_groups = [g]
nodegroupname = g
nodegroup = bpy.data.node_groups[nodegroupname]
nodegroup.use_fake_user = fake_user
# Create target object automatically for geometry nodegroups when no target is provided
auto_created_target: Optional[bpy.types.Object] = None
if nodegroup.bl_rna.identifier == "GeometryNodeTree" and not target_object:
# Create a default mesh cube
bpy.ops.mesh.primitive_cube_add(
size=2, location=model_location, rotation=model_rotation
)
target_obj = bpy.context.active_object
target_obj.name = "GeometryNodeTarget"
target_object = target_obj.name
auto_created_target = target_obj
# Make sure it's selected and active
bpy.context.view_layer.objects.active = target_obj
target_obj.select_set(True)
# Mapping dict for node editor tree types to node group node types
sdict = {
"GeometryNodeTree": "GeometryNodeGroup",
"ShaderNodeTree": "ShaderNodeGroup",
"CompositorNodeTree": "CompositorNodeGroup",
}
# Get the nodegroup type
nodegroup_type = nodegroup.bl_rna.identifier
# If no explicit mode is set, try to detect if we should add to an existing editor first
# This allows drag-drop into existing node editors to work properly
if not nodegroup_mode:
# Find a suitable node editor
for area in bpy.context.screen.areas:
if area.type != "NODE_EDITOR":
continue
if area.spaces.active.tree_type == nodegroup_type:
nt = area.spaces.active.edit_tree
if nt is None:
continue
# Add node to this editor
for n in nt.nodes:
n.select = False
node_type = sdict.get(nodegroup_type)
if node_type:
node = nt.nodes.new(node_type)
node.node_tree = nodegroup
node.location = (node_x, node_y)
node.select = True
nt.nodes.active = node
return (nodegroup, True)
# Handle modifier mode for geometry nodegroups
if nodegroup_mode == "MODIFIER" and target_object:
target_obj = bpy.data.objects.get(target_object)
if target_obj and nodegroup.bl_rna.identifier == "GeometryNodeTree":
# Create a new geometry nodes modifier with this nodegroup
gn_mod = target_obj.modifiers.new(name=nodegroup.name, type="NODES")
gn_mod.node_group = nodegroup
# Select the target object to make the change visible
bpy.context.view_layer.objects.active = target_obj
if target_obj not in bpy.context.selected_objects:
target_obj.select_set(True)
return (
nodegroup,
True,
) # Return True as we "added" it successfully to the modifier
# Handle node mode for geometry nodegroups with target object
# Create a modifier setup and then add the nodegroup as a node to the tree
if (
nodegroup_mode == "NODE"
and target_object
and nodegroup.bl_rna.identifier == "GeometryNodeTree"
):
target_obj = bpy.data.objects.get(target_object)
if target_obj:
# Select the target object to make it active
bpy.context.view_layer.objects.active = target_obj
if target_obj not in bpy.context.selected_objects:
target_obj.select_set(True)
# look for the geometry nodes modifier
gn_mod = None
for mod in target_obj.modifiers:
if mod.type == "NODES" and mod.node_group:
gn_mod = mod
break
if not gn_mod:
# create a new geometry nodes modifier
gn_mod = target_obj.modifiers.new(name="GeometryNodes", type="NODES")
if not gn_mod.node_group:
# create a new node group
bpy.ops.node.new_geometry_node_group_assign()
node_tree = gn_mod.node_group
if node_tree:
# Add the nodegroup as a node to the tree
group_node = node_tree.nodes.new("GeometryNodeGroup")
group_node.node_tree = nodegroup
group_node.location = (node_x, node_y)
group_node.select = True
node_tree.nodes.active = group_node
return (nodegroup, True)
# If not added yet through modes or if no mode specified, try to find any compatible editor
added_to_editor = False
# Try any compatible editor
for area in bpy.context.screen.areas:
if area.type != "NODE_EDITOR":
continue
nt = area.spaces.active.edit_tree
if nt is None:
continue
# Check if this editor type is compatible
if area.spaces.active.tree_type in sdict:
# Add node to this editor
for n in nt.nodes:
n.select = False
node_type = sdict.get(area.spaces.active.tree_type)
if node_type:
# Check if nodegroup is compatible with this editor
# For example, don't add shader nodegroups to geometry node editor
if (
nodegroup_type == "ShaderNodeTree"
and area.spaces.active.tree_type != "ShaderNodeTree"
) or (
nodegroup_type == "GeometryNodeTree"
and area.spaces.active.tree_type != "GeometryNodeTree"
):
continue
node = nt.nodes.new(node_type)
node.node_tree = nodegroup
node.location = (node_x, node_y)
node.select = True
nt.nodes.active = node
added_to_editor = True
break
# Ensure automatically created targets receive the nodegroup as modifier
if auto_created_target:
gn_mod = None
for mod in auto_created_target.modifiers:
if mod.type == "NODES":
gn_mod = mod
break
if not gn_mod:
gn_mod = auto_created_target.modifiers.new(
name=nodegroup.name, type="NODES"
)
gn_mod.node_group = nodegroup
auto_created_target.select_set(True)
bpy.context.view_layer.objects.active = auto_created_target
return nodegroup, added_to_editor
def append_material(file_name, matname=None, link=False, fake_user=True):
"""append a material type asset
first, we have to check if there is a material with same name
in previous step there's check if the imported material
is already in the scene, so we know same name != same material
"""
mats_before = bpy.data.materials[:]
try:
with bpy.data.libraries.load(file_name, link=link, relative=True) as (
data_from,
data_to,
):
found = False
for m in data_from.materials:
if matname is None or m.strip() == matname.strip():
data_to.materials = [m]
matname = m
found = True
break
# not found yet? probably some name inconsistency then.
if not found and len(data_from.materials) > 0:
data_to.materials = [data_from.materials[0]]
matname = data_from.materials[0]
bk_logger.warning(
f"the material wasn't found under the exact name, appended another one: {matname}"
)
except Exception as e:
bk_logger.error(f"{e} - failed to open the asset file")
# we have to find the new material , due to possible name changes
mat = None
for m in bpy.data.materials:
if m not in mats_before:
mat = m
break
# still not found?
if mat is None:
mat = bpy.data.materials.get(matname)
if fake_user:
mat.use_fake_user = True
return mat
def append_scene(file_name, scenename=None, link=False, fake_user=False):
"""append a scene type asset"""
with bpy.data.libraries.load(file_name, link=link, relative=True) as (
data_from,
data_to,
):
for s in data_from.scenes:
if scenename is None or s.strip() == scenename.strip():
data_to.scenes = [s]
scenename = s
scene = bpy.data.scenes[scenename]
if fake_user:
scene.use_fake_user = True
# scene has to have a new uuid, so user reports aren't screwed.
scene["uuid"] = str(uuid.uuid4())
# reset ui_props of the scene to defaults:
ui_props = bpy.context.window_manager.blenderkitUI
ui_props.down_up = "SEARCH"
return scene
def get_node_sure(node_tree, ntype=""):
"""
Gets a node of certain type, but creates a new one if not pre
"""
node = None
for n in node_tree.nodes:
if ntype == n.bl_rna.identifier:
node = n
return node
if not node:
node = node_tree.nodes.new(type=ntype)
return node
def hdr_swap(name, hdr):
"""
Try to replace the hdr in current world setup. If this fails, create a new world.
:param name: Name of the resulting world (renamse the current one if swap is successfull)
:param hdr: Image type
:return: None
"""
w = bpy.context.scene.world
if not w:
new_hdr_world(name, hdr)
if bpy.app.version < (5, 0, 0):
w.use_nodes = True
w.name = name
nt = w.node_tree
for n in nt.nodes:
if "ShaderNodeTexEnvironment" == n.bl_rna.identifier:
env_node = n
env_node.image = hdr
return
new_hdr_world(name, hdr)
def new_hdr_world(name, hdr):
"""
creates a new world, links in the hdr with mapping node, and links the world to scene
:param name: Name of the world datablock
:param hdr: Image type
:return: None
"""
w = bpy.data.worlds.new(name=name)
if bpy.app.version < (5, 0, 0):
w.use_nodes = True
bpy.context.scene.world = w
nt = w.node_tree
env_node = nt.nodes.new(type="ShaderNodeTexEnvironment")
env_node.image = hdr
background = get_node_sure(nt, "ShaderNodeBackground")
tex_coord = get_node_sure(nt, "ShaderNodeTexCoord")
mapping = get_node_sure(nt, "ShaderNodeMapping")
nt.links.new(env_node.outputs["Color"], background.inputs["Color"])
nt.links.new(tex_coord.outputs["Generated"], mapping.inputs["Vector"])
nt.links.new(mapping.outputs["Vector"], env_node.inputs["Vector"])
env_node.location.x = -400
mapping.location.x = -600
tex_coord.location.x = -800
def load_HDR(file_name, name):
"""Load a HDR into file and link it to scene world."""
already_linked = False
for i in bpy.data.images:
if i.filepath == file_name:
hdr = i
already_linked = True
break
if not already_linked:
hdr = bpy.data.images.load(file_name)
hdr_swap(name, hdr)
return hdr
def link_collection(
file_name,
obnames: Optional[list] = None,
location=(0, 0, 0),
link: bool = False,
parent: Optional[str] = None,
collection: str = "",
**kwargs,
):
"""link an instanced group - model type asset"""
if obnames is None:
obnames = []
sel = utils.selection_get()
# Store the original active collection
orig_active_collection = bpy.context.view_layer.active_layer_collection # type: ignore[union-attr]
# Activate target collection if specified
if collection:
target_collection = bpy.data.collections.get(collection)
if target_collection:
# Find and activate the layer collection
layer_collection = find_layer_collection(
bpy.context.view_layer.layer_collection, collection # type: ignore[union-attr]
)
if layer_collection:
bpy.context.view_layer.active_layer_collection = layer_collection # type: ignore[union-attr]
with bpy.data.libraries.load(file_name, link=link, relative=True) as (
data_from,
data_to,
):
for col in data_from.collections:
if col.strip() == kwargs["name"].strip():
data_to.collections = [col]
rotation = (0, 0, 0)
if kwargs.get("rotation") is not None:
rotation = kwargs["rotation"]
bpy.ops.object.empty_add(type="PLAIN_AXES", location=location, rotation=rotation)
main_object = bpy.context.view_layer.objects.active # type: ignore[union-attr]
main_object.instance_type = "COLLECTION" # type: ignore[union-attr]
if parent is not None and parent != "":
main_object.parent = bpy.data.objects.get(parent) # type: ignore[union-attr]
main_object.matrix_world.translation = location # type: ignore[union-attr]
for col in bpy.data.collections:
if col.library is not None:
fp = bpy.path.abspath(col.library.filepath)
fp1 = bpy.path.abspath(file_name)
if fp == fp1:
main_object.instance_collection = col # type: ignore[union-attr]
break
# sometimes, the lib might already be without the actual link.
if not main_object.instance_collection and kwargs["name"]: # type: ignore[union-attr]
col = bpy.data.collections.get(kwargs["name"])
if col:
main_object.instance_collection = col # type: ignore[union-attr]
main_object.name = main_object.instance_collection.name # type: ignore[union-attr]
# Restore original active collection
if orig_active_collection:
bpy.context.view_layer.active_layer_collection = orig_active_collection # type: ignore[union-attr]
utils.selection_set(sel)
return main_object, []
def append_particle_system(
file_name, obnames=None, location=(0, 0, 0), link=False, **kwargs
):
"""link an instanced group - model type asset"""
if obnames is None:
obnames = []
pss = []
with bpy.data.libraries.load(file_name, link=link, relative=True) as (
data_from,
data_to,
):
for ps in data_from.particles:
pss.append(ps)
data_to.particles = pss
s = bpy.context.scene
sel = utils.selection_get()
target_object = bpy.context.scene.objects.get(kwargs["target_object"])
if target_object is not None and target_object.type == "MESH":
target_object.select_set(True)
bpy.context.view_layer.objects.active = target_object
for ps in pss:
# now let's tune this ps to the particular objects area:
totarea = 0
for p in target_object.data.polygons:
totarea += p.area
count = int(ps.count * totarea)
if ps.child_type in ("INTERPOLATED", "SIMPLE"):
total_count = count * ps.rendered_child_count
disp_count = count * ps.child_nbr
else:
total_count = count
bbox_threshold = 25000
display_threshold = 200000
total_max_threshold = 2000000
# emitting too many parent particles just kills blender now.
# this part tuned child count, we'll leave children to artists only.
# if count > total_max_threshold:
# ratio = round(count / total_max_threshold)
#
# if ps.child_type in ('INTERPOLATED', 'SIMPLE'):
# ps.rendered_child_count *= ratio
# else:
# ps.child_type = 'INTERPOLATED'
# ps.rendered_child_count = ratio
# count = max(2, int(count / ratio))
# 1st level of optimizaton - switch t bounding boxes.
if total_count > bbox_threshold:
target_object.display_type = "BOUNDS"
# 2nd level of optimization - reduce percentage of displayed particles.
ps.display_percentage = min(
ps.display_percentage,
max(1, int(100 * display_threshold / total_count)),
)
# here we can also tune down number of children displayed.
# set the count
ps.count = count
# add the modifier
bpy.ops.object.particle_system_add()
# 3rd level - hide particle system from viewport - is done on the modifier..
if total_count > total_max_threshold:
target_object.modifiers[-1].show_viewport = False
target_object.particle_systems[-1].settings = ps
target_object.select_set(False)
utils.selection_set(sel)
return target_object, []
def append_objects(
file_name,
obnames: Optional[list] = None,
location=(0, 0, 0),
link: bool = False,
parent: Optional[str] = None,
collection: str = "",
**kwargs,
):
"""Append object into scene individually. 2 approaches based in definition of name argument.
TODO: really split this function into 2 functions: kwargs.get('name')==None and else.
"""
if obnames is None:
obnames = []
# simplified version of append
if kwargs.get("name"):
scene = bpy.context.scene
sel = utils.selection_get()
# Store the original active collection
orig_active_collection = bpy.context.view_layer.active_layer_collection # type: ignore[union-attr]
# Activate target collection if specified
if collection:
target_collection = bpy.data.collections.get(collection)
if target_collection:
# Find and activate the layer collection
layer_collection = find_layer_collection(
bpy.context.view_layer.layer_collection, collection # type: ignore[union-attr]
)
if layer_collection:
bpy.context.view_layer.active_layer_collection = layer_collection # type: ignore[union-attr]
try:
bpy.ops.object.select_all(action="DESELECT")
except Exception as e:
reports.add_report(
f"append_objects.1: {str(e)}",
3,
type="ERROR",
)
raise e
path = file_name + "/Collection"
collection_name = kwargs.get("name")
if collection_name is None:
bk_logger.warning("collection_name is None")
collection_name = ""
bpy.ops.wm.append(filename=collection_name, directory=path)
# fc = utils.get_fake_context(bpy.context, area_type='VIEW_3D')
# bpy.ops.wm.append(fc, filename=collection_name, directory=path)
return_obs = []
to_hidden_collection = []
appended_collection = None
main_object = None
# get first at least one parent for sure
for ob in bpy.context.scene.objects: # type: ignore[union-attr]
if ob.select_get():
if not ob.parent:
main_object = ob
ob.location = location
# do once again to ensure hidden objects are hidden
for ob in bpy.context.scene.objects: # type: ignore[union-attr]
if ob.select_get():
return_obs.append(ob)
# check for object that should be hidden
if ob.users_collection[0].name == collection_name:
appended_collection = ob.users_collection[0]
appended_collection["is_blenderkit_asset"] = True
if not ob.parent:
main_object = ob
ob.location = location
else:
to_hidden_collection.append(ob)
assert (
main_object != None
), f"asset {kwargs['name']} not found in scene after appending"
if kwargs.get("rotation"):
main_object.rotation_euler = kwargs["rotation"]
if parent is not None and parent != "":
main_object.parent = bpy.data.objects[parent]
main_object.matrix_world.translation = location
# move objects that should be hidden to a sub collection
if len(to_hidden_collection) > 0 and appended_collection is not None:
hidden_collections = []
scene_collection = bpy.context.scene.collection # type: ignore[union-attr]
for ob in to_hidden_collection:
hide_collection = ob.users_collection[0]
# objects from scene collection (like rigify widgets go to a new collection
if (
hide_collection == scene_collection
or hide_collection.name in scene_collection.children
):
hidden_collection_name = collection_name + "_hidden"
h_col = bpy.data.collections.get(hidden_collection_name)
if h_col is None:
h_col = bpy.data.collections.new(name=hidden_collection_name)
# If target collection is specified, make the hidden collection a child of target collection
if collection and bpy.data.collections.get(collection):
bpy.data.collections.get(collection).children.link(h_col)
else:
appended_collection.children.link(h_col)
utils.exclude_collection(hidden_collection_name)
ob.users_collection[0].objects.unlink(ob)
h_col.objects.link(ob)
continue
if hide_collection in hidden_collections:
continue
# All other collections are moved to be children of the model collection
bk_logger.info(f"{hide_collection}, {appended_collection}")
# If target collection is specified, move collections there instead
if collection and bpy.data.collections.get(collection):
utils.move_collection(
hide_collection, bpy.data.collections.get(collection)
)
else:
utils.move_collection(hide_collection, appended_collection)
utils.exclude_collection(hide_collection.name)
hidden_collections.append(hide_collection)
try:
bpy.ops.object.select_all(action="DESELECT")
except Exception as e:
reports.add_report(
f"append_objects.2: {str(e)}",
3,
type="ERROR",
)
raise e
# Restore original active collection
if orig_active_collection:
bpy.context.view_layer.active_layer_collection = orig_active_collection # type: ignore[union-attr]
utils.selection_set(sel)
# let collection also store info that it was created by BlenderKit, for purging reasons
return main_object, return_obs
# this is used for uploads:
with bpy.data.libraries.load(file_name, link=link, relative=True) as (
data_from,
data_to,
):
sobs = []
# for col in data_from.collections:
# if col == kwargs.get('name'):
for ob in data_from.objects:
if ob in obnames or obnames == []:
sobs.append(ob)
data_to.objects = sobs
# data_to.objects = data_from.objects#[name for name in data_from.objects if name.startswith("house")]
# link them to scene
scene = bpy.context.scene
sel = utils.selection_get()
try:
bpy.ops.object.select_all(action="DESELECT")
except Exception as e:
reports.add_report(
f"append_objects.3: {str(e)}",
3,
type="ERROR",
)
raise e
return_obs = [] # this might not be needed, but better be sure to rewrite the list.
main_object = None
hidden_objects = []
for obj in data_to.objects:
if obj is not None:
# if obj.name not in scene.objects:
scene.collection.objects.link(obj) # type: ignore[union-attr]
if obj.parent is None:
obj.location = location
main_object = obj
obj.select_set(True)
# we need to unhide object so make_local op can use those too.
if link == True:
if obj.hide_viewport:
hidden_objects.append(obj)
obj.hide_viewport = False
return_obs.append(obj)
# Only after all objects are in scene! Otherwise gets broken relationships
if link == True:
bpy.ops.object.make_local(type="SELECT_OBJECT")
for ob in hidden_objects:
ob.hide_viewport = True
if kwargs.get("rotation") is not None:
main_object.rotation_euler = kwargs["rotation"] # type: ignore[union-attr]
if parent is not None and parent != "":
main_object.parent = bpy.data.objects[parent] # type: ignore[union-attr]
main_object.matrix_world.translation = location # type: ignore[union-attr]
try:
bpy.ops.object.select_all(action="DESELECT")
except Exception as e:
reports.add_report(
f"append_objects.4: {str(e)}",
3,
type="ERROR",
)
raise e
utils.selection_set(sel)
return main_object, return_obs