2473 lines
92 KiB
Python
2473 lines
92 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 json
|
|
import logging
|
|
import math
|
|
import os
|
|
import random
|
|
|
|
import bpy
|
|
import mathutils
|
|
from bpy.props import IntProperty, StringProperty
|
|
from bpy_extras import view3d_utils
|
|
from mathutils import Vector
|
|
|
|
from typing import Any, Optional, Tuple, Set, Union
|
|
|
|
from . import (
|
|
bg_blender,
|
|
colors,
|
|
download,
|
|
global_vars,
|
|
image_utils,
|
|
paths,
|
|
reports,
|
|
ui,
|
|
ui_bgl,
|
|
ui_panels,
|
|
utils,
|
|
search,
|
|
)
|
|
from .bl_ui_widgets.bl_ui_button import BL_UI_Button
|
|
from .bl_ui_widgets.bl_ui_drag_panel import BL_UI_Drag_Panel
|
|
from .bl_ui_widgets.bl_ui_draw_op import BL_UI_OT_draw_operator
|
|
from .bl_ui_widgets.bl_ui_image import BL_UI_Image
|
|
|
|
|
|
bk_logger = logging.getLogger(__name__)
|
|
|
|
handler_2d = None
|
|
handler_3d = None
|
|
|
|
|
|
DEAD_ZONE = 5 # pixels
|
|
"""Number of pixels mouse must move to start drag operation."""
|
|
|
|
DRAG_THRESHOLD = 10 # pixels
|
|
"""Number of pixels mouse must move to consider as a drag (vs click)."""
|
|
|
|
|
|
def is_draw_cb_available(self: bpy.types.Operator, context: bpy.types.Context) -> bool:
|
|
"""Check if drawing callbacks can be added safely.
|
|
|
|
Be defensive: accessing attributes on a destroyed Operator raises ReferenceError.
|
|
We avoid hasattr and use getattr in a try/except to prevent log spam.
|
|
|
|
Args:
|
|
self: The operator instance.
|
|
context: Blender context.
|
|
|
|
Returns:
|
|
True if drawing callbacks can be added, False otherwise.
|
|
"""
|
|
try:
|
|
if self is None:
|
|
return False
|
|
if self.active_region_pointer is None:
|
|
return False
|
|
except ReferenceError:
|
|
# The operator RNA is gone; skip drawing quietly
|
|
bk_logger.exception("Operator RNA is gone; skipping drawing callback.")
|
|
return False
|
|
except Exception:
|
|
return False
|
|
|
|
if context.region.as_pointer() != self.active_region_pointer:
|
|
return False
|
|
|
|
return True
|
|
|
|
|
|
def draw_callback_dragging(
|
|
self: bpy.types.Operator, context: bpy.types.Context
|
|
) -> None:
|
|
"""Draw drag & drop hints while dragging an asset.
|
|
|
|
Args:
|
|
self: The operator instance.
|
|
context: Blender context.
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
# Only draw 2D elements in the active region where the mouse is. Guard against destroyed operator.
|
|
|
|
if not is_draw_cb_available(self, context):
|
|
return
|
|
|
|
try:
|
|
## optimized
|
|
if hasattr(self, "_faux_img") and self._faux_img is not None:
|
|
img = self._faux_img
|
|
else:
|
|
# load mini thumbnail of a file and store in the previews collection
|
|
directory = paths.get_temp_dir(f"{self.asset_data['assetType']}_search")
|
|
thumbnail_path = os.path.join(directory, self.asset_data["thumbnail_small"])
|
|
img = self._faux_img = image_utils.IMG(
|
|
name=self.iname, filepath=thumbnail_path
|
|
)
|
|
except Exception:
|
|
bk_logger.exception("Error loading image while drawing:")
|
|
return
|
|
|
|
invalid_drop = False
|
|
|
|
line_length = 35
|
|
ui_props = bpy.context.window_manager.blenderkitUI
|
|
|
|
line_color = colors.WHITE
|
|
|
|
# Determine hint message and colors based on context
|
|
main_message = ""
|
|
main_color = (0.9, 0.9, 0.9, 1.0) # Default white
|
|
secondary_message = ""
|
|
secondary_color = (0.7, 0.7, 0.7, 1.0) # Default gray
|
|
|
|
# Base text position
|
|
text_x = self.mouse_x
|
|
text_y_main = self.mouse_y - line_length - 20 - ui_props.thumb_size
|
|
text_y_secondary = self.mouse_y - line_length - 40 - ui_props.thumb_size
|
|
|
|
# Determine messages based on area type and asset type
|
|
asset_type = self.asset_data["assetType"]
|
|
asset_node_type = self.asset_data["dictParameters"].get("nodeType")
|
|
if context.area.type == "VIEW_3D":
|
|
if asset_type == "material":
|
|
if asset_node_type == "shader":
|
|
main_message = "Drop to replace active material"
|
|
else:
|
|
main_message = "Drop to assign material"
|
|
|
|
elif asset_type in ["model", "printable"]:
|
|
if self.shift_pressed and self.object_name:
|
|
main_message = f"Drop to Set Parent to ({self.object_name})"
|
|
else:
|
|
collection_name = (
|
|
context.view_layer.active_layer_collection.collection.name
|
|
)
|
|
if self.object_name:
|
|
main_message = f"Drop into active collection '{collection_name}'"
|
|
|
|
secondary_message = "(Shift to parent)"
|
|
else:
|
|
main_message = f"Drop into collection '{collection_name}'"
|
|
|
|
elif asset_type == "nodegroup":
|
|
if asset_node_type == "geometry":
|
|
if self.object_name is not None:
|
|
# Hovering over an object
|
|
target_object = bpy.data.objects.get(self.object_name)
|
|
if target_object and target_object.type in ["MESH", "CURVE"]:
|
|
main_message = "Drop to add geometry nodegroup"
|
|
secondary_message = f"(Add as modifier to {self.object_name})"
|
|
else:
|
|
main_message = f"Unsupported object type: {target_object.type if target_object else 'Unknown'}"
|
|
main_color = (1.0, 0.5, 0.5, 1.0) # Error red
|
|
secondary_message = (
|
|
"Geometry nodes work with Mesh/Curve objects"
|
|
)
|
|
secondary_color = (0.8, 0.6, 0.6, 1.0) # Light red
|
|
invalid_drop = True
|
|
else:
|
|
main_message = "Drop to add geometry nodegroup"
|
|
# if active object is mesh/curve, mention modifier option
|
|
active_object = bpy.context.active_object
|
|
if active_object and active_object.type in ["MESH", "CURVE"]:
|
|
secondary_message = f"(Add as modifier to {active_object.name})"
|
|
else:
|
|
asset_node_type_display = asset_node_type or "nodegroup"
|
|
main_message = f"Drop to add {asset_node_type_display} nodegroup"
|
|
|
|
elif asset_type == "addon":
|
|
main_message = "Drop to install addon"
|
|
|
|
elif self.in_node_editor:
|
|
if asset_type not in ["material", "nodegroup"]:
|
|
if asset_type == "addon":
|
|
main_message = "Drop to install addon"
|
|
else:
|
|
main_message = "Cancel Drag & Drop"
|
|
invalid_drop = True
|
|
elif asset_type == "material" and self.node_editor_type == "shader":
|
|
main_message = "Drop to replace active material"
|
|
elif asset_type == "material" and self.node_editor_type == "compositing":
|
|
main_message = "Cancel Drag & Drop"
|
|
secondary_message = "Unsupported asset type for node editor type"
|
|
invalid_drop = True
|
|
elif asset_type == "nodegroup":
|
|
if self.is_nodegroup_compatible_with_editor(
|
|
asset_node_type, self.node_editor_type
|
|
):
|
|
if asset_node_type == "geometry":
|
|
# For geometry nodes, show dialog option
|
|
active_object = bpy.context.active_object
|
|
if active_object and active_object.type in ["MESH", "CURVE"]:
|
|
main_message = "Drop to show options"
|
|
secondary_message = (
|
|
f"(Add as modifier or node to {active_object.name})"
|
|
)
|
|
else:
|
|
main_message = "Drop to add node group"
|
|
secondary_message = "Select mesh/curve for modifier option"
|
|
secondary_color = (0.9, 0.9, 0.6, 1.0) # Soft highlight
|
|
else:
|
|
# For other nodegroup types, just add as node
|
|
main_message = "Drop to add node group"
|
|
else:
|
|
will_switch = asset_node_type in {"shader", "geometry", "compositing"}
|
|
if asset_node_type == "shader":
|
|
main_message = "Drop to switch to shader editor"
|
|
secondary_message = "Editor switches automatically"
|
|
elif asset_node_type == "geometry":
|
|
active_object = bpy.context.active_object
|
|
if active_object and active_object.type in ["MESH", "CURVE"]:
|
|
main_message = "Drop to switch & show options"
|
|
secondary_message = f"Geometry nodes for {active_object.name}"
|
|
else:
|
|
main_message = "Drop to switch to geometry nodes editor"
|
|
secondary_message = (
|
|
"Editor will switch; mesh optional for modifier"
|
|
)
|
|
secondary_color = (0.9, 0.9, 0.6, 1.0)
|
|
elif asset_node_type == "compositing":
|
|
main_message = "Drop to switch to compositing"
|
|
secondary_message = "Editor switches automatically"
|
|
else:
|
|
main_message = "Drop to switch editor type"
|
|
if not will_switch:
|
|
invalid_drop = True
|
|
|
|
elif context.area.type not in ["VIEW_3D", "OUTLINER"]:
|
|
main_message = "Cancel Drag & Drop"
|
|
invalid_drop = True
|
|
|
|
# Outliner specific hints
|
|
# TODO: drop obs into collections if they are hovered, not their parent collection
|
|
if context.area.type == "OUTLINER" and self.hovered_outliner_element:
|
|
main_message = ""
|
|
if isinstance(self.hovered_outliner_element, bpy.types.Object):
|
|
if asset_type == "nodegroup":
|
|
if asset_node_type != "geometry":
|
|
main_message = "Cancel Drag & Drop"
|
|
invalid_drop = True
|
|
else:
|
|
# Hovering over an object
|
|
target_object = bpy.data.objects.get(
|
|
self.hovered_outliner_element.name
|
|
)
|
|
if target_object and target_object.type in ["MESH", "CURVE"]:
|
|
main_message = "Assign as modifier"
|
|
secondary_message = f"(Geometry nodes for {target_object.name})"
|
|
else:
|
|
main_message = f"Unsupported object type: {target_object.type if target_object else 'Unknown'}"
|
|
invalid_drop = True
|
|
|
|
elif asset_type == "material":
|
|
main_message = "Drop to replace active material"
|
|
|
|
elif self.shift_pressed:
|
|
main_message = "Drop to Set Parent"
|
|
else:
|
|
collection_name = ""
|
|
if self.hovered_outliner_element.users_collection:
|
|
collection_name = self.hovered_outliner_element.users_collection[
|
|
0
|
|
].name
|
|
main_message = (
|
|
f"Drop into collection '{collection_name}' (Shift to parent)"
|
|
)
|
|
elif isinstance(self.hovered_outliner_element, bpy.types.Collection):
|
|
main_message = (
|
|
f"Drop into collection '{self.hovered_outliner_element.name}'"
|
|
)
|
|
|
|
transparency = 1.0
|
|
line_color = colors.WHITE
|
|
if invalid_drop:
|
|
line_color = colors.RED
|
|
transparency = 0.35
|
|
|
|
ui_bgl.draw_image_runtime(
|
|
self.mouse_x + line_length,
|
|
self.mouse_y - line_length - ui_props.thumb_size,
|
|
ui_props.thumb_size,
|
|
ui_props.thumb_size,
|
|
img,
|
|
transparency=transparency,
|
|
)
|
|
ui_bgl.draw_line2d(
|
|
self.mouse_x,
|
|
self.mouse_y,
|
|
self.mouse_x + line_length,
|
|
self.mouse_y - line_length,
|
|
2,
|
|
line_color,
|
|
)
|
|
|
|
if invalid_drop:
|
|
# red border
|
|
ui_bgl.draw_rect_outline(
|
|
self.mouse_x + line_length,
|
|
self.mouse_y - line_length - ui_props.thumb_size,
|
|
ui_props.thumb_size,
|
|
ui_props.thumb_size,
|
|
line_color,
|
|
)
|
|
# simple red line over the thumbnail (bottom left to top right)
|
|
ui_bgl.draw_line2d(
|
|
self.mouse_x + line_length,
|
|
self.mouse_y - line_length - ui_props.thumb_size,
|
|
self.mouse_x + line_length + ui_props.thumb_size,
|
|
self.mouse_y - line_length,
|
|
2,
|
|
line_color,
|
|
)
|
|
|
|
# Draw the text messages if we have any
|
|
if main_message:
|
|
ui_bgl.draw_text(main_message, text_x, text_y_main, 16, main_color)
|
|
|
|
if secondary_message:
|
|
ui_bgl.draw_text(
|
|
secondary_message, text_x, text_y_secondary, 14, secondary_color
|
|
)
|
|
|
|
|
|
def draw_callback_3d_dragging(
|
|
self: bpy.types.Operator, context: bpy.types.Context
|
|
) -> None:
|
|
"""Draw snapped bbox while dragging."""
|
|
if not utils.guard_from_crash():
|
|
return
|
|
|
|
# Only draw 3D elements in VIEW_3D areas, not in outliner
|
|
if context.area.type != "VIEW_3D":
|
|
return
|
|
|
|
# Check if operator is still valid before accessing its attributes
|
|
if not is_draw_cb_available(self, context):
|
|
return
|
|
|
|
# Check if all required attributes are available
|
|
required_attrs = [
|
|
"has_hit",
|
|
"snapped_location",
|
|
"snapped_rotation",
|
|
"snapped_bbox_min",
|
|
"snapped_bbox_max",
|
|
"asset_data",
|
|
]
|
|
|
|
for attr in required_attrs:
|
|
if not hasattr(self, attr):
|
|
return
|
|
|
|
# Only continue if we have a hit in a 3D view
|
|
if not self.has_hit:
|
|
return
|
|
|
|
if self.asset_data["assetType"] in ["model", "printable"]:
|
|
draw_bbox(
|
|
self.snapped_location,
|
|
self.snapped_rotation,
|
|
self.snapped_bbox_min,
|
|
self.snapped_bbox_max,
|
|
)
|
|
|
|
|
|
def draw_bbox(
|
|
location: Vector,
|
|
rotation: Vector,
|
|
bbox_min: Vector,
|
|
bbox_max: Vector,
|
|
progress: Optional[float] = None,
|
|
color: Tuple[float, float, float, float] = colors.PURE_GREEN,
|
|
) -> None:
|
|
rot_euler = mathutils.Euler(rotation)
|
|
|
|
side_min = Vector(bbox_min)
|
|
side_max = Vector(bbox_max)
|
|
v0 = Vector(side_min)
|
|
v1 = Vector((side_max.x, side_min.y, side_min.z))
|
|
v2 = Vector((side_max.x, side_max.y, side_min.z))
|
|
v3 = Vector((side_min.x, side_max.y, side_min.z))
|
|
v4 = Vector((side_min.x, side_min.y, side_max.z))
|
|
v5 = Vector((side_max.x, side_min.y, side_max.z))
|
|
v6 = Vector((side_max.x, side_max.y, side_max.z))
|
|
v7 = Vector((side_min.x, side_max.y, side_max.z))
|
|
|
|
arrow_x = side_min.x + (side_max.x - side_min.x) / 2
|
|
arrow_y = side_min.y - (side_max.x - side_min.x) / 2
|
|
v8 = Vector((arrow_x, arrow_y, side_min.z))
|
|
|
|
vertices = [v0, v1, v2, v3, v4, v5, v6, v7, v8]
|
|
for v in vertices:
|
|
v.rotate(rot_euler)
|
|
v += Vector(location)
|
|
|
|
lines = [
|
|
[0, 1],
|
|
[1, 2],
|
|
[2, 3],
|
|
[3, 0],
|
|
[4, 5],
|
|
[5, 6],
|
|
[6, 7],
|
|
[7, 4],
|
|
[0, 4],
|
|
[1, 5],
|
|
[2, 6],
|
|
[3, 7],
|
|
[0, 8],
|
|
[1, 8],
|
|
]
|
|
ui_bgl.draw_lines(vertices, lines, color)
|
|
if progress is not None:
|
|
# Draw side fill quads based on progress along +Z of the local bbox
|
|
color = (color[0], color[1], color[2], 0.2)
|
|
progress = progress * 0.01
|
|
vz0 = (v4 - v0) * progress + v0
|
|
vz1 = (v5 - v1) * progress + v1
|
|
vz2 = (v6 - v2) * progress + v2
|
|
vz3 = (v7 - v3) * progress + v3
|
|
rects = (
|
|
(v0, v1, vz1, vz0),
|
|
(v1, v2, vz2, vz1),
|
|
(v2, v3, vz3, vz2),
|
|
(v3, v0, vz0, vz3),
|
|
)
|
|
for r in rects:
|
|
ui_bgl.draw_rect_3d(r, color)
|
|
|
|
|
|
def draw_callback_2d_progress(
|
|
self: bpy.types.Operator, context: bpy.types.Context
|
|
) -> None:
|
|
if not utils.guard_from_crash():
|
|
return
|
|
ui = bpy.context.window_manager.blenderkitUI
|
|
|
|
x = ui.reports_x
|
|
y = ui.reports_y
|
|
index = 0
|
|
for key, task in download.download_tasks.items():
|
|
asset_data = task["asset_data"]
|
|
if not task.get("downloaders"):
|
|
draw_progress(
|
|
x,
|
|
y - index * 30,
|
|
text=f"downloading {asset_data['name']}",
|
|
percent=task["progress"],
|
|
)
|
|
index += 1
|
|
|
|
for process in bg_blender.bg_processes:
|
|
tcom = process[1]
|
|
n = ""
|
|
if tcom.name is not None:
|
|
n = tcom.name + ": "
|
|
draw_progress(x, y - index * 30, f"{n}{tcom.lasttext}", tcom.progress)
|
|
index += 1
|
|
for report in reports.reports:
|
|
report.draw(x, y - index * 30)
|
|
index += 1
|
|
report.fade()
|
|
|
|
|
|
def draw_callback_3d_progress(
|
|
self: bpy.types.Operator, context: bpy.types.Context
|
|
) -> None:
|
|
# 'star trek' mode is here
|
|
|
|
if not utils.guard_from_crash():
|
|
return
|
|
for key, task in download.download_tasks.items():
|
|
asset_data = task["asset_data"]
|
|
if task.get("downloaders"):
|
|
for d in task["downloaders"]:
|
|
if asset_data["assetType"] in ["model", "printable"]:
|
|
draw_bbox(
|
|
d["location"],
|
|
d["rotation"],
|
|
asset_data["bbox_min"],
|
|
asset_data["bbox_max"],
|
|
progress=task["progress"],
|
|
)
|
|
|
|
|
|
def draw_progress(
|
|
x: int,
|
|
y: int,
|
|
text: str = "",
|
|
percent: float = 0.0,
|
|
color: Tuple[float, float, float, float] = colors.GREEN,
|
|
):
|
|
ui_bgl.draw_rect(x, y, percent, 5, color)
|
|
ui_bgl.draw_text(text, x, y + 8, 16, color)
|
|
|
|
|
|
def find_and_activate_instancers(
|
|
obj: bpy.types.Object,
|
|
) -> Optional[bpy.types.Object]:
|
|
for ob in bpy.context.visible_objects:
|
|
if (
|
|
ob.instance_type == "COLLECTION"
|
|
and ob.instance_collection
|
|
and obj.name in ob.instance_collection.objects
|
|
):
|
|
utils.activate(ob)
|
|
return ob
|
|
|
|
|
|
def mouse_raycast(
|
|
region: bpy.types.Region, rv3d: bpy.types.RegionView3D, mx: int, my: int
|
|
) -> Tuple[
|
|
bool,
|
|
Vector,
|
|
Vector,
|
|
Vector,
|
|
Optional[int],
|
|
Optional[bpy.types.Object],
|
|
Optional[mathutils.Matrix],
|
|
]:
|
|
coord = mx, my
|
|
|
|
# get the ray from the viewport and mouse
|
|
view_vector = view3d_utils.region_2d_to_vector_3d(region, rv3d, coord)
|
|
if rv3d.view_perspective == "CAMERA" and rv3d.is_perspective == False:
|
|
# orthographic cameras don't work with region_2d_to_origin_3d
|
|
view_position = rv3d.view_matrix.inverted().translation
|
|
ray_origin = view3d_utils.region_2d_to_location_3d(
|
|
region, rv3d, coord, depth_location=view_position
|
|
)
|
|
else:
|
|
ray_origin = view3d_utils.region_2d_to_origin_3d(region, rv3d, coord, clamp=1.0)
|
|
|
|
ray_target = ray_origin + (view_vector * 1000000000)
|
|
|
|
vec = ray_target - ray_origin
|
|
|
|
(
|
|
has_hit,
|
|
snapped_location,
|
|
snapped_normal,
|
|
face_index,
|
|
obj,
|
|
matrix,
|
|
) = deep_ray_cast(ray_origin, vec)
|
|
|
|
# backface snapping inversion
|
|
if view_vector.angle(snapped_normal) < math.pi / 2:
|
|
snapped_normal = -snapped_normal
|
|
|
|
random_offset = math.pi
|
|
if has_hit:
|
|
props = bpy.context.window_manager.blenderkit_models
|
|
up = Vector((0, 0, 1))
|
|
|
|
if props.perpendicular_snap:
|
|
if snapped_normal.z > 1 - props.perpendicular_snap_threshold:
|
|
snapped_normal = Vector((0, 0, 1))
|
|
elif snapped_normal.z < -1 + props.perpendicular_snap_threshold:
|
|
snapped_normal = Vector((0, 0, -1))
|
|
elif abs(snapped_normal.z) < props.perpendicular_snap_threshold:
|
|
snapped_normal.z = 0
|
|
snapped_normal.normalize()
|
|
|
|
snapped_rotation = snapped_normal.to_track_quat("Z", "Y").to_euler()
|
|
|
|
if props.randomize_rotation and snapped_normal.angle(up) < math.radians(10.0):
|
|
random_offset = (
|
|
props.offset_rotation_amount
|
|
+ math.pi
|
|
+ (random.random() - 0.5) * props.randomize_rotation_amount
|
|
)
|
|
else:
|
|
random_offset = (
|
|
props.offset_rotation_amount
|
|
) # we don't rotate this way on walls and ceilings.
|
|
|
|
else:
|
|
snapped_rotation = mathutils.Quaternion((0, 0, 0, 0)).to_euler()
|
|
|
|
snapped_rotation.rotate_axis("Z", random_offset)
|
|
|
|
return (
|
|
has_hit,
|
|
snapped_location,
|
|
snapped_normal,
|
|
snapped_rotation,
|
|
face_index,
|
|
obj,
|
|
matrix,
|
|
)
|
|
|
|
|
|
def floor_raycast(
|
|
r: bpy.types.Region, rv3d: bpy.types.RegionView3D, mx: int, my: int
|
|
) -> Tuple[
|
|
bool,
|
|
Vector,
|
|
Vector,
|
|
Vector,
|
|
Optional[int],
|
|
Optional[bpy.types.Object],
|
|
Optional[mathutils.Matrix],
|
|
]:
|
|
coord = mx, my
|
|
|
|
# get the ray from the viewport and mouse
|
|
view_vector = view3d_utils.region_2d_to_vector_3d(r, rv3d, coord)
|
|
ray_origin = view3d_utils.region_2d_to_origin_3d(r, rv3d, coord)
|
|
ray_target = ray_origin + (view_vector * 1000)
|
|
|
|
# various intersection plane normals are needed for corner cases that might actually happen quite often - in front and side view.
|
|
# default plane normal is scene floor.
|
|
plane_normal = (0, 0, 1)
|
|
if math.isclose(view_vector.x, 0, abs_tol=1e-4) and math.isclose(
|
|
view_vector.z, 0, abs_tol=1e-4
|
|
):
|
|
plane_normal = (0, 1, 0)
|
|
elif math.isclose(view_vector.z, 0, abs_tol=1e-4):
|
|
plane_normal = (1, 0, 0)
|
|
|
|
snapped_location = mathutils.geometry.intersect_line_plane(
|
|
ray_origin, ray_target, (0, 0, 0), plane_normal, False
|
|
)
|
|
has_hit = False
|
|
snapped_normal = Vector((0, 0, 1))
|
|
snapped_rotation = Vector((0, 0, 0))
|
|
face_index = None
|
|
out_object = None
|
|
matrix = None
|
|
if snapped_location is not None:
|
|
has_hit = True
|
|
snapped_normal = Vector((0, 0, 1))
|
|
face_index = None
|
|
out_object = None
|
|
matrix = None
|
|
snapped_rotation = snapped_normal.to_track_quat("Z", "Y").to_euler()
|
|
props = bpy.context.window_manager.blenderkit_models
|
|
if props.randomize_rotation:
|
|
random_offset = (
|
|
props.offset_rotation_amount
|
|
+ math.pi
|
|
+ (random.random() - 0.5) * props.randomize_rotation_amount
|
|
)
|
|
else:
|
|
random_offset = props.offset_rotation_amount + math.pi
|
|
snapped_rotation.rotate_axis("Z", random_offset)
|
|
|
|
return (
|
|
has_hit,
|
|
snapped_location,
|
|
snapped_normal,
|
|
snapped_rotation,
|
|
face_index,
|
|
out_object,
|
|
matrix,
|
|
)
|
|
|
|
|
|
def deep_ray_cast(ray_origin: Vector, vec: Vector) -> Tuple[
|
|
bool,
|
|
Vector,
|
|
Vector,
|
|
Optional[int],
|
|
Optional[bpy.types.Object],
|
|
Optional[mathutils.Matrix],
|
|
]:
|
|
# this allows to ignore some objects, like objects with bounding box draw style or particle objects
|
|
obj = None
|
|
# while object is None or object.draw
|
|
depsgraph = bpy.context.view_layer.depsgraph
|
|
(
|
|
has_hit,
|
|
snapped_location,
|
|
snapped_normal,
|
|
face_index,
|
|
obj,
|
|
matrix,
|
|
) = bpy.context.scene.ray_cast(depsgraph, ray_origin, vec)
|
|
empty_set = False, Vector((0, 0, 0)), Vector((0, 0, 1)), None, None, None
|
|
if not obj:
|
|
return empty_set
|
|
try_object = obj
|
|
while try_object and (
|
|
try_object.display_type == "BOUNDS"
|
|
or object_in_particle_collection(try_object)
|
|
or not try_object.visible_get(viewport=bpy.context.space_data)
|
|
):
|
|
ray_origin = snapped_location + vec.normalized() * 0.0003
|
|
(
|
|
try_has_hit,
|
|
try_snapped_location,
|
|
try_snapped_normal,
|
|
try_face_index,
|
|
try_object,
|
|
try_matrix,
|
|
) = bpy.context.scene.ray_cast(depsgraph, ray_origin, vec)
|
|
if try_has_hit:
|
|
# this way only good hits are returned, otherwise
|
|
has_hit, snapped_location, snapped_normal, face_index, obj, matrix = (
|
|
try_has_hit,
|
|
try_snapped_location,
|
|
try_snapped_normal,
|
|
try_face_index,
|
|
try_object,
|
|
try_matrix,
|
|
)
|
|
if not (obj.display_type == "BOUNDS" or object_in_particle_collection(try_object)):
|
|
return has_hit, snapped_location, snapped_normal, face_index, obj, matrix
|
|
return empty_set
|
|
|
|
|
|
def object_in_particle_collection(o: bpy.types.Object) -> bool:
|
|
"""checks if an object is in a particle system as instance, to not snap to it and not to try to attach material."""
|
|
for p in bpy.data.particles:
|
|
if p.render_type == "COLLECTION":
|
|
if p.instance_collection:
|
|
for o1 in p.instance_collection.objects:
|
|
if o1 == o:
|
|
return True
|
|
if p.render_type == "COLLECTION":
|
|
if p.instance_object == o:
|
|
return True
|
|
return False
|
|
|
|
|
|
def get_node_tree(context: bpy.types.Context) -> bpy.types.NodeTree:
|
|
"""Blender version invariant way to get the node tree from the current node editor."""
|
|
if bpy.app.version < (5, 0, 0):
|
|
if context.scene.use_nodes and context.scene.node_tree:
|
|
node_tree = context.scene.node_tree
|
|
else:
|
|
# Enable compositor nodes if not already enabled
|
|
context.scene.use_nodes = True
|
|
node_tree = context.scene.node_tree
|
|
return node_tree
|
|
|
|
# blender 5.0+
|
|
# FUTURE check if valid in 5.RC
|
|
if not context.scene.compositing_node_group:
|
|
bpy.ops.node.new_compositing_node_group()
|
|
context.scene.compositing_node_group = bpy.data.node_groups[-1]
|
|
|
|
return context.scene.compositing_node_group
|
|
|
|
return context.scene.compositing_node_group
|
|
|
|
|
|
def assign_node_tree(
|
|
node_space: bpy.types.SpaceNodeEditor, node_tree: bpy.types.NodeTree
|
|
) -> None:
|
|
"""Blender version invariant way to assign a node tree to the current node editor."""
|
|
if bpy.app.version < (5, 0, 0):
|
|
node_space.node_tree = node_tree
|
|
return
|
|
|
|
# blender 5.0+
|
|
# recover the node_group from data and assign it
|
|
if hasattr(node_space, "node_group"):
|
|
node_space.node_group = bpy.data.node_groups[node_tree.name]
|
|
elif hasattr(node_space, "node_tree"):
|
|
node_space.node_tree = node_tree
|
|
|
|
|
|
class AssetDragOperator(bpy.types.Operator):
|
|
"""Drag & drop assets into scene. Operator being drawn when dragging asset."""
|
|
|
|
bl_idname = "view3d.asset_drag_drop"
|
|
bl_label = "BlenderKit asset drag drop"
|
|
|
|
asset_search_index: IntProperty(name="Active Index", default=0) # type: ignore
|
|
drag_length: IntProperty(name="Drag_length", default=0) # type: ignore
|
|
|
|
object_name = None
|
|
|
|
def __init__(self, *args, **kwargs) -> None:
|
|
super().__init__(*args, **kwargs)
|
|
# predefine all attributes to avoid dynamic creation during draw calls
|
|
self.asset_data = {}
|
|
self.active_region_pointer = None
|
|
|
|
self._handle_3d = None
|
|
self._handlers_universal = {}
|
|
|
|
self.hovered_outliner_element: Union[bpy.types.Object, bpy.types.Collection] = (
|
|
None
|
|
)
|
|
|
|
self.orig_active_object = None
|
|
self.orig_selected_objects = None
|
|
|
|
self.downloader = None
|
|
|
|
# Mouse tracking variables
|
|
self.start_mouse_x = None
|
|
self.start_mouse_y = None
|
|
|
|
self.mouse_x = 0
|
|
self.mouse_y = 0
|
|
self.mouse_screen_x = 0
|
|
self.mouse_screen_y = 0
|
|
self.steps = 0
|
|
|
|
# Store the initial active region pointer
|
|
self.active_region_pointer = None
|
|
|
|
# Initialize outliner tracking variables
|
|
self.hovered_outliner_element = None
|
|
self.outliner_area = None
|
|
self.outliner_region = None
|
|
self.orig_selected_objects = None
|
|
self.orig_active_object = None
|
|
self.orig_active_collection = None
|
|
self.prev_area_type = None
|
|
|
|
# Initialize node editor tracking
|
|
self.in_node_editor = False
|
|
self.node_editor_type = None
|
|
|
|
self.shift_pressed = False
|
|
|
|
# Initialize has_hit to False, and set other 3D properties
|
|
# We'll only use these in 3D views, not in outliner
|
|
self.has_hit = False
|
|
self.snapped_location = (0, 0, 0)
|
|
self.snapped_normal = (0, 0, 1)
|
|
self.snapped_rotation = (0, 0, 0)
|
|
self.face_index = 0
|
|
self.matrix = None
|
|
|
|
self.iname = ""
|
|
self.drag = False
|
|
|
|
def handlers_remove(self) -> None:
|
|
"""Remove all draw handlers."""
|
|
# Remove specific handlers for VIEW_3D and Outliner
|
|
bpy.types.SpaceView3D.draw_handler_remove(self._handle_3d, "WINDOW")
|
|
|
|
# Remove handlers for all other space types
|
|
for space_type, handler in self._handlers_universal.items():
|
|
if handler:
|
|
getattr(bpy.types, space_type).draw_handler_remove(handler, "WINDOW")
|
|
|
|
def is_nodegroup_compatible_with_editor(
|
|
self, nodegroup_type: str, editor_type: Optional[str] = None
|
|
) -> bool:
|
|
"""Check if a nodegroup of a specific type is compatible with the given editor type."""
|
|
# Direct matches
|
|
if nodegroup_type == editor_type:
|
|
return True
|
|
# Generic nodegroups can work in any editor
|
|
elif nodegroup_type is None:
|
|
return True
|
|
# Otherwise, not compatible
|
|
return False
|
|
|
|
def handle_view3d_drop(self, context: bpy.types.Context) -> None:
|
|
"""Handle dropping assets in the 3D view."""
|
|
scene = context.scene
|
|
if self.asset_data["assetType"] in ["model", "printable"]:
|
|
if not self.drag:
|
|
self.snapped_location = scene.cursor.location
|
|
self.snapped_rotation = (0, 0, 0)
|
|
|
|
parent = ""
|
|
target_collection = ""
|
|
|
|
if self.object_name is not None and self.shift_pressed:
|
|
parent = self.object_name
|
|
|
|
# If parent is set, put asset in parent's collection. Otherwise, active collection.
|
|
if parent:
|
|
parent_obj = bpy.data.objects.get(parent)
|
|
if parent_obj and parent_obj.users_collection:
|
|
target_collection = parent_obj.users_collection[0].name
|
|
else:
|
|
target_collection = (
|
|
context.view_layer.active_layer_collection.collection.name
|
|
)
|
|
|
|
if "particle_plants" in self.asset_data["tags"]:
|
|
bpy.ops.object.blenderkit_particles_drop(
|
|
"INVOKE_DEFAULT",
|
|
asset_search_index=self.asset_search_index,
|
|
model_location=self.snapped_location,
|
|
model_rotation=self.snapped_rotation,
|
|
target_object=self.object_name or "",
|
|
)
|
|
else:
|
|
# TODO: after we drop the support for B3.0, B3.1, we can update all the download operators called from the drag drop operator to use
|
|
# context.temp_override(), so the download is triggered in the area/window where it was dropped
|
|
bpy.ops.scene.blenderkit_download(
|
|
"EXEC_DEFAULT",
|
|
asset_index=self.asset_search_index,
|
|
model_location=self.snapped_location,
|
|
model_rotation=self.snapped_rotation,
|
|
parent=parent,
|
|
target_collection=target_collection,
|
|
)
|
|
|
|
if self.asset_data["assetType"] == "material":
|
|
obj = None
|
|
target_object = ""
|
|
target_slot = ""
|
|
if not self.drag:
|
|
# click interaction
|
|
obj = context.active_object
|
|
if obj is None:
|
|
ui_panels.ui_message(
|
|
title="Nothing selected",
|
|
message="Select something to assign materials by clicking.",
|
|
)
|
|
return
|
|
target_object = obj.name
|
|
target_slot = obj.active_material_index
|
|
self.snapped_location = obj.location
|
|
elif self.object_name is not None and self.has_hit:
|
|
# first, test if object can have material applied.
|
|
obj = bpy.data.objects[self.object_name]
|
|
# this enables to run Bring to scene automatically when dropping on a linked objects.
|
|
if (
|
|
obj is not None
|
|
and not obj.is_library_indirect
|
|
and obj.type in utils.supported_material_drag
|
|
):
|
|
target_object = obj.name
|
|
# create final mesh to extract correct material slot
|
|
depsgraph = context.evaluated_depsgraph_get()
|
|
object_eval = obj.evaluated_get(depsgraph)
|
|
|
|
if obj.type == "MESH":
|
|
temp_mesh = object_eval.to_mesh()
|
|
mapping = create_material_mapping(obj, temp_mesh)
|
|
target_slot = temp_mesh.polygons[self.face_index].material_index
|
|
object_eval.to_mesh_clear()
|
|
else:
|
|
self.snapped_location = obj.location
|
|
target_slot = obj.active_material_index
|
|
if not obj:
|
|
return
|
|
if obj.is_library_indirect:
|
|
ui_panels.ui_message(
|
|
title="This object is linked from outer file",
|
|
message="Please select the model,"
|
|
"go to the 'Selected Model' panel "
|
|
"in BlenderKit and hit 'Bring to Scene' first.",
|
|
)
|
|
return
|
|
if obj.type not in utils.supported_material_drag:
|
|
if obj.type in utils.supported_material_click:
|
|
ui_panels.ui_message(
|
|
title="Unsupported object type",
|
|
message=f"Use click interaction for {obj.type.lower()} object.",
|
|
)
|
|
return
|
|
|
|
ui_panels.ui_message(
|
|
title="Unsupported object type",
|
|
message=f"Can't assign materials to {obj.type.lower()} object.",
|
|
)
|
|
return
|
|
|
|
if target_object != "":
|
|
# position is for downloader:
|
|
loc = self.snapped_location
|
|
rotation = (0, 0, 0)
|
|
|
|
utils.automap(
|
|
target_object,
|
|
target_slot=target_slot,
|
|
tex_size=self.asset_data.get("texture_size_meters", 1.0),
|
|
)
|
|
bpy.ops.scene.blenderkit_download(
|
|
"EXEC_DEFAULT",
|
|
asset_index=self.asset_search_index,
|
|
model_location=loc,
|
|
model_rotation=rotation,
|
|
target_object=target_object,
|
|
material_target_slot=target_slot,
|
|
)
|
|
|
|
if self.asset_data["assetType"] == "nodegroup":
|
|
# Handle nodegroup drop in 3D view
|
|
nodegroup_type = self.asset_data["dictParameters"].get("nodeType")
|
|
|
|
# Only handle geometry nodegroups for now
|
|
if nodegroup_type == "geometry":
|
|
target_object_name = ""
|
|
target_location = self.snapped_location
|
|
target_rotation = self.snapped_rotation
|
|
|
|
if not self.drag:
|
|
# Click interaction - use active object like materials do
|
|
active_object = context.active_object
|
|
if active_object and active_object.type in ["MESH", "CURVE"]:
|
|
target_object_name = active_object.name
|
|
target_location = active_object.location
|
|
target_rotation = (0, 0, 0)
|
|
elif self.object_name is not None and self.has_hit:
|
|
# Drag interaction - use object under mouse
|
|
target_object = bpy.data.objects.get(self.object_name)
|
|
if target_object and target_object.type in ["MESH", "CURVE"]:
|
|
target_object_name = self.object_name
|
|
|
|
# Show dialog for geometry nodegroups
|
|
# modify default if not target_object_name is set
|
|
if target_object_name:
|
|
bpy.ops.wm.blenderkit_nodegroup_drop_dialog(
|
|
"INVOKE_DEFAULT",
|
|
asset_search_index=self.asset_search_index,
|
|
target_object_name=target_object_name,
|
|
snapped_location=target_location,
|
|
snapped_rotation=target_rotation,
|
|
)
|
|
else:
|
|
bpy.ops.wm.blenderkit_nodegroup_drop_dialog(
|
|
"INVOKE_DEFAULT",
|
|
asset_search_index=self.asset_search_index,
|
|
target_object_name=target_object_name,
|
|
add_mode="NODE",
|
|
snapped_location=target_location,
|
|
snapped_rotation=target_rotation,
|
|
)
|
|
else:
|
|
# For non-geometry nodegroups, use regular download
|
|
bpy.ops.scene.blenderkit_download(
|
|
"EXEC_DEFAULT",
|
|
asset_index=self.asset_search_index,
|
|
model_location=self.snapped_location,
|
|
model_rotation=self.snapped_rotation,
|
|
)
|
|
|
|
if self.asset_data["assetType"] in ["material", "model"]:
|
|
bpy.ops.view3d.blenderkit_download_gizmo_widget(
|
|
"INVOKE_REGION_WIN",
|
|
asset_base_id=self.asset_data["assetBaseId"],
|
|
)
|
|
|
|
def handle_outliner_drop(self, context: bpy.types.Context) -> None:
|
|
"""Handle dropping assets in the outliner."""
|
|
asset_type = self.asset_data["assetType"]
|
|
asset_node_type = self.asset_data.get("dictParameters", {}).get("nodeType")
|
|
if asset_type in ["model", "printable"]:
|
|
parent = ""
|
|
target_collection = ""
|
|
|
|
# Check what type of element we're dropping on
|
|
element_type = type(self.hovered_outliner_element).__name__
|
|
|
|
# If dropping on a collection, set target_collection parameter
|
|
if isinstance(self.hovered_outliner_element, bpy.types.Collection):
|
|
target_collection = self.hovered_outliner_element.name
|
|
# Otherwise if dropping on an object, place it in the same collection
|
|
elif isinstance(self.hovered_outliner_element, bpy.types.Object):
|
|
hovered_object = self.hovered_outliner_element
|
|
if self.shift_pressed:
|
|
parent = hovered_object.name
|
|
# even if we have parent, we also want the collection to be parented correctly
|
|
if hovered_object.users_collection:
|
|
target_collection = hovered_object.users_collection[0].name
|
|
else:
|
|
# Unsupported element type - just continue with default values
|
|
pass
|
|
|
|
# Place the asset at the origin or at a default location
|
|
self.snapped_location = (0, 0, 0)
|
|
self.snapped_rotation = (0, 0, 0)
|
|
|
|
# Download the asset with the target collection or parent
|
|
bpy.ops.scene.blenderkit_download(
|
|
"EXEC_DEFAULT",
|
|
asset_index=self.asset_search_index,
|
|
model_location=self.snapped_location,
|
|
model_rotation=self.snapped_rotation,
|
|
parent=parent,
|
|
target_collection=target_collection,
|
|
)
|
|
|
|
# Restore original selection
|
|
self.restore_original_selection()
|
|
|
|
elif asset_type == "material":
|
|
# If dropping a material on an object in the outliner
|
|
target_object = self.hovered_outliner_element.name
|
|
|
|
# Check if object supports materials, it can also be a collection
|
|
if not (
|
|
type(self.hovered_outliner_element) == bpy.types.Object
|
|
and self.hovered_outliner_element.type in ["MESH", "CURVE"]
|
|
):
|
|
reports.add_report(
|
|
"Can't assign materials to this outliner element.",
|
|
type="ERROR",
|
|
)
|
|
return
|
|
|
|
# Use active material slot or create one
|
|
target_slot = self.hovered_outliner_element.active_material_index
|
|
|
|
# Position is for downloader
|
|
loc = (0, 0, 0)
|
|
rotation = (0, 0, 0)
|
|
|
|
# Try to automap if it's a mesh
|
|
if self.hovered_outliner_element.type == "MESH":
|
|
utils.automap(
|
|
target_object,
|
|
target_slot=target_slot,
|
|
tex_size=self.asset_data.get("texture_size_meters", 1.0),
|
|
)
|
|
|
|
# Download the material
|
|
bpy.ops.scene.blenderkit_download(
|
|
"EXEC_DEFAULT",
|
|
asset_index=self.asset_search_index,
|
|
model_location=loc,
|
|
model_rotation=rotation,
|
|
target_object=target_object,
|
|
material_target_slot=target_slot,
|
|
)
|
|
|
|
# Restore original selection
|
|
self.restore_original_selection()
|
|
|
|
elif asset_type == "nodegroup":
|
|
if asset_node_type != "geometry":
|
|
reports.add_report(
|
|
"Only geometry nodegroups can be dropped in the outliner.",
|
|
type="ERROR",
|
|
)
|
|
return
|
|
target_object = self.hovered_outliner_element.name
|
|
|
|
# Check if object supports materials, it can also be a collection
|
|
if not (
|
|
type(self.hovered_outliner_element) == bpy.types.Object
|
|
and self.hovered_outliner_element.type in ["MESH", "CURVE"]
|
|
):
|
|
reports.add_report(
|
|
"Can't assign geometry node group to this outliner element.",
|
|
type="ERROR",
|
|
)
|
|
return
|
|
|
|
# call out wm operator
|
|
bpy.ops.wm.blenderkit_nodegroup_drop_dialog(
|
|
"INVOKE_DEFAULT",
|
|
asset_search_index=self.asset_search_index,
|
|
target_object_name=target_object,
|
|
add_mode="MODIFIER",
|
|
)
|
|
|
|
# If we reach this point, the nodegroup is valid for dropping
|
|
self.restore_original_selection()
|
|
|
|
elif self.asset_data["assetType"] == "addon":
|
|
# Handle addon drop in outliner - show management popup
|
|
|
|
bpy.ops.scene.blenderkit_addon_choice(
|
|
"INVOKE_DEFAULT", asset_data=json.dumps(self.asset_data)
|
|
)
|
|
# Restore original selection
|
|
self.restore_original_selection()
|
|
|
|
def make_node_editor_switch(
|
|
self, nodegroup_type: str, node_editor_type: Optional[str] = None
|
|
) -> bool:
|
|
"""Make a node editor switch safely.
|
|
|
|
Avoids raising if area or operator state is invalid. This prevents persistent
|
|
draw exceptions when the operator RNA gets destroyed mid-drag.
|
|
"""
|
|
try:
|
|
node_types_to_node_editor_type = {
|
|
"shader": "ShaderNodeTree",
|
|
"geometry": "GeometryNodeTree",
|
|
"compositing": "CompositorNodeTree",
|
|
}
|
|
node_editor_type = node_types_to_node_editor_type[nodegroup_type]
|
|
if self.active_area:
|
|
self.active_area.ui_type = node_editor_type
|
|
except KeyError:
|
|
# Be silent in production to avoid repeated errors during draw
|
|
bk_logger.exception("make_node_editor_switch failed")
|
|
return False
|
|
|
|
return True
|
|
|
|
def handle_node_editor_drop_material(self, context: bpy.types.Context) -> None:
|
|
"""Handle dropping materials in the node editor."""
|
|
active_object = context.active_object
|
|
|
|
if not active_object:
|
|
# No active object, can't assign material
|
|
reports.add_report("No active object to assign material to", type="ERROR")
|
|
return
|
|
|
|
if active_object.type not in utils.supported_material_drag:
|
|
# Object type doesn't support materials
|
|
reports.add_report(
|
|
f"Can't assign materials to {active_object.type.lower()} object",
|
|
type="ERROR",
|
|
)
|
|
return
|
|
|
|
# Use active material slot or create one
|
|
target_slot = active_object.active_material_index
|
|
|
|
# Download the material
|
|
bpy.ops.scene.blenderkit_download(
|
|
"EXEC_DEFAULT",
|
|
asset_index=self.asset_search_index,
|
|
model_location=(0, 0, 0),
|
|
model_rotation=(0, 0, 0),
|
|
target_object=active_object.name,
|
|
material_target_slot=target_slot,
|
|
)
|
|
return
|
|
|
|
def handle_node_editor_drop(self, context: bpy.types.Context) -> None:
|
|
"""Handle dropping assets in the node editor."""
|
|
# Check if asset type is compatible with the node editor
|
|
if self.asset_data["assetType"] not in ["material", "nodegroup"]:
|
|
reports.add_report(
|
|
f"{self.asset_data['assetType'].capitalize()} assets cannot be used in node editors",
|
|
type="ERROR",
|
|
)
|
|
return
|
|
|
|
# Handle material drop in shader editor
|
|
if (
|
|
self.asset_data["assetType"] == "material"
|
|
and self.node_editor_type == "shader"
|
|
):
|
|
self.handle_node_editor_drop_material(context)
|
|
return
|
|
|
|
# Handle nodegroup drop
|
|
if self.asset_data["assetType"] == "nodegroup":
|
|
# Get the mouse position in the node editor
|
|
nodegroup_type = self.asset_data["dictParameters"].get("nodeType")
|
|
|
|
# Check if the nodegroup type is compatible with the current editor
|
|
if not self.is_nodegroup_compatible_with_editor(
|
|
nodegroup_type, self.node_editor_type
|
|
):
|
|
has_switched = self.make_node_editor_switch(
|
|
nodegroup_type, self.node_editor_type
|
|
)
|
|
if not has_switched:
|
|
reports.add_report(
|
|
f"Nodegroup of type '{nodegroup_type}' cannot be used in {self.node_editor_type} editor",
|
|
type="ERROR",
|
|
)
|
|
return
|
|
# Special handling for geometry nodegroups - show dialog if active object supports it
|
|
if nodegroup_type == "geometry":
|
|
active_object = context.active_object
|
|
if active_object and active_object.type in ["MESH", "CURVE"]:
|
|
# Get node position for passing to dialog
|
|
node_pos = self.get_node_editor_cursor_position()
|
|
|
|
# Show dialog to choose how to add the geometry nodegroup
|
|
bpy.ops.wm.blenderkit_nodegroup_drop_dialog(
|
|
"INVOKE_DEFAULT",
|
|
asset_search_index=self.asset_search_index,
|
|
target_object_name=active_object.name,
|
|
snapped_location=(0, 0, 0), # Not used for node editor
|
|
snapped_rotation=(0, 0, 0), # Not used for node editor
|
|
node_x=node_pos[0],
|
|
node_y=node_pos[1],
|
|
)
|
|
return
|
|
|
|
# No compatible object, just add as node
|
|
reports.add_report(
|
|
"No compatible object selected, adding as node only",
|
|
type="INFO",
|
|
)
|
|
|
|
# Prepare geometry nodes editor (for when user chooses "As Node" or no object)
|
|
if active_object and active_object.type in ["MESH", "CURVE"]:
|
|
# Check if there's a geometry nodes modifier
|
|
gn_mod = None
|
|
for mod in active_object.modifiers:
|
|
if mod.type == "NODES":
|
|
gn_mod = mod
|
|
if gn_mod.node_group: # Only use it if it has a node group
|
|
break
|
|
# Otherwise keep looking for a better one
|
|
|
|
# If no geometry nodes modifier, add one
|
|
if not gn_mod:
|
|
# Create a new one
|
|
reports.add_report(
|
|
"No geometry nodes modifier found, adding one", type="INFO"
|
|
)
|
|
gn_mod = active_object.modifiers.new(
|
|
name="GeometryNodes", type="NODES"
|
|
)
|
|
|
|
if not gn_mod.node_group:
|
|
# Modifier exists but doesn't have a node group
|
|
# Create a new node group
|
|
node_group = bpy.data.node_groups.new(
|
|
"Geometry Nodes", "GeometryNodeTree"
|
|
)
|
|
# Add input and output nodes
|
|
input_node = node_group.nodes.new("NodeGroupInput")
|
|
output_node = node_group.nodes.new("NodeGroupOutput")
|
|
# Add a geometry socket to the group
|
|
node_group.interface.new_socket(
|
|
"Geometry",
|
|
description="Geometry",
|
|
in_out="OUTPUT",
|
|
socket_type="NodeSocketGeometry",
|
|
)
|
|
node_group.interface.new_socket(
|
|
"Geometry",
|
|
description="Geometry",
|
|
in_out="INPUT",
|
|
socket_type="NodeSocketGeometry",
|
|
)
|
|
# Position nodes
|
|
input_node.location = (-200, 0)
|
|
output_node.location = (200, 0)
|
|
# Link the nodes
|
|
node_group.links.new(
|
|
input_node.outputs["Geometry"],
|
|
output_node.inputs["Geometry"],
|
|
)
|
|
# Assign the node group to the modifier
|
|
gn_mod.node_group = node_group
|
|
|
|
# Make sure we have a node tree to work with
|
|
node_tree = gn_mod.node_group
|
|
if self.active_area:
|
|
self.active_area.spaces[0].node_tree = node_tree
|
|
self.active_area.tag_redraw()
|
|
|
|
# Third case: need to switch to shader nodes for shader nodegroup
|
|
elif nodegroup_type == "shader":
|
|
# Try to find a material to edit
|
|
active_object = context.active_object
|
|
node_tree = None
|
|
|
|
if not active_object:
|
|
reports.add_report("No active object", type="ERROR")
|
|
return
|
|
|
|
if not active_object.active_material:
|
|
temp_material = bpy.data.materials.new("Temporary Material")
|
|
active_object.active_material = temp_material
|
|
|
|
active_material = active_object.active_material
|
|
# Use active material
|
|
# TODO: material.use_nodes is expected to be removed in Blender 6.0
|
|
if not active_material.use_nodes:
|
|
active_material.use_nodes = True
|
|
node_tree = active_material.node_tree
|
|
|
|
# Set the node tree AFTER changing the editor type
|
|
if self.active_area:
|
|
self.active_area.spaces[0].node_tree = node_tree
|
|
|
|
elif nodegroup_type == "compositing":
|
|
# potential fix for blender5.0+
|
|
node_tree = get_node_tree(context)
|
|
|
|
# Finally doing the real stuff (only if we didn't show a dialog)
|
|
# Get node position relative to the active node editor area
|
|
node_pos = self.get_node_editor_cursor_position()
|
|
|
|
# Download the nodegroup with correct positioning
|
|
bpy.ops.scene.blenderkit_download(
|
|
"EXEC_DEFAULT",
|
|
asset_index=self.asset_search_index,
|
|
node_x=node_pos[0],
|
|
node_y=node_pos[1],
|
|
)
|
|
return
|
|
|
|
def mouse_release(self, context: bpy.types.Context) -> None:
|
|
"""Main mouse release handler that delegates to specific handlers based on area type."""
|
|
|
|
# first let's handle asset types that are independent of the area type
|
|
if self.asset_data["assetType"] == "hdr":
|
|
bpy.ops.scene.blenderkit_download(
|
|
"INVOKE_DEFAULT",
|
|
asset_index=self.asset_search_index,
|
|
invoke_resolution=True,
|
|
use_resolution_operator=True,
|
|
max_resolution=self.asset_data.get("max_resolution", 0),
|
|
)
|
|
|
|
if self.asset_data["assetType"] == "scene":
|
|
bpy.ops.scene.blenderkit_download(
|
|
"INVOKE_DEFAULT",
|
|
asset_index=self.asset_search_index,
|
|
invoke_resolution=False,
|
|
invoke_scene_settings=True,
|
|
)
|
|
|
|
if self.asset_data["assetType"] == "brush":
|
|
bpy.ops.scene.blenderkit_download(
|
|
asset_index=self.asset_search_index,
|
|
)
|
|
|
|
if self.asset_data["assetType"] == "addon":
|
|
# Show addon management popup instead of direct installation
|
|
|
|
bpy.ops.scene.blenderkit_addon_choice(
|
|
"INVOKE_DEFAULT", asset_data=json.dumps(self.asset_data)
|
|
)
|
|
|
|
# In any other area than 3D view and outliner, we just cancel the drag&drop
|
|
if self.prev_area_type not in ["VIEW_3D", "OUTLINER", "NODE_EDITOR"]:
|
|
return
|
|
|
|
# Handle Node Editor drop
|
|
if self.in_node_editor:
|
|
self.handle_node_editor_drop(context)
|
|
return
|
|
|
|
# Handle Outliner drop
|
|
if self.hovered_outliner_element is not None:
|
|
self.handle_outliner_drop(context)
|
|
return
|
|
|
|
# Handle 3D View drop
|
|
self.handle_view3d_drop(context)
|
|
|
|
def find_active_region(
|
|
self,
|
|
x: float,
|
|
y: float,
|
|
) -> Union[
|
|
Tuple[bpy.types.Window, bpy.types.Region, bpy.types.Area],
|
|
Tuple[None, None, None],
|
|
]:
|
|
"""Find the window, region and area under the mouse cursor."""
|
|
|
|
wins = bpy.context.window_manager.windows[:]
|
|
# reverse the list, seemed to work well at least on windows.
|
|
wins.reverse()
|
|
context_win = bpy.context.window
|
|
|
|
# let's prioritize the context window
|
|
if context_win is not None:
|
|
wins.remove(context_win)
|
|
wins.insert(0, context_win)
|
|
|
|
for window in wins:
|
|
# first let's test if it's in this window, so we know we shall continue
|
|
window_x = window.x * self.resolution_factor
|
|
window_y = window.y * self.resolution_factor
|
|
window_width = window.width * self.resolution_factor
|
|
window_height = window.height * self.resolution_factor
|
|
if (
|
|
x < window_x
|
|
or x > window_x + window_width
|
|
or y < window_y
|
|
or y > window_y + window_height
|
|
):
|
|
continue
|
|
for area in window.screen.areas:
|
|
for region in area.regions:
|
|
region_x = window_x + region.x
|
|
region_y = window_y + region.y
|
|
if region.type != "WINDOW":
|
|
continue
|
|
if (
|
|
region_x <= x < region_x + region.width
|
|
and region_y <= y < region_y + region.height
|
|
):
|
|
return window, area, region
|
|
return None, None, None
|
|
|
|
def find_outliner_element_under_mouse(
|
|
self,
|
|
) -> Union[bpy.types.Object, bpy.types.Collection, None]:
|
|
"""Find and select the element under the mouse in the outliner.
|
|
Returns the selected object, collection, or None."""
|
|
if not self.active_area or self.active_area.type != "OUTLINER":
|
|
return None
|
|
|
|
context = bpy.context
|
|
scene = context.scene
|
|
view_layer = context.view_layer
|
|
selected_objects = context.selected_objects
|
|
active_object = context.active_object
|
|
|
|
orig_selected_objects = selected_objects.copy()
|
|
orig_active_object = active_object
|
|
orig_active_collection = view_layer.active_layer_collection
|
|
|
|
selected_element = None
|
|
if bpy.app.version > (3, 1, 9):
|
|
# doesn't make sense for lower versions, we wouldn't get the selected_ids anyway.
|
|
# Simply drops into active_layer_collection in prehistoric Blender.
|
|
with bpy.context.temp_override(
|
|
window=self.active_window,
|
|
area=self.active_area,
|
|
region=self.active_region,
|
|
):
|
|
bpy.ops.outliner.select_box(
|
|
xmin=self.mouse_x - 1,
|
|
xmax=self.mouse_x + 1,
|
|
ymin=self.mouse_y - 1,
|
|
ymax=self.mouse_y + 1,
|
|
wait_for_input=False,
|
|
mode="SET",
|
|
)
|
|
|
|
# Get the newly selected element using selected_ids
|
|
if (
|
|
hasattr(bpy.context, "selected_ids")
|
|
and len(bpy.context.selected_ids) > 0
|
|
):
|
|
selected_element = bpy.context.selected_ids[0]
|
|
|
|
if selected_element is None and hasattr(view_layer, "active_layer_collection"):
|
|
alc = view_layer.active_layer_collection
|
|
if alc is not None and hasattr(alc, "collection"):
|
|
selected_element = alc.collection
|
|
|
|
self.orig_selected_objects = orig_selected_objects
|
|
self.orig_active_object = orig_active_object
|
|
self.orig_active_collection = orig_active_collection
|
|
|
|
return selected_element
|
|
|
|
def restore_original_selection(self) -> None:
|
|
"""Restore the original object selection that was active before entering the outliner."""
|
|
if self.orig_selected_objects:
|
|
# Deselect all objects
|
|
bpy.ops.object.select_all(action="DESELECT")
|
|
# Restore original selection
|
|
for obj in self.orig_selected_objects:
|
|
if obj: # Check if object still exists
|
|
obj.select_set(True)
|
|
if self.orig_active_object:
|
|
bpy.context.view_layer.objects.active = self.orig_active_object
|
|
|
|
# Reset the stored selection to avoid restoring it multiple times
|
|
self.orig_selected_objects = None
|
|
self.orig_active_object = None
|
|
|
|
# Restore original active collection
|
|
if self.orig_active_collection:
|
|
# Restore the original active layer collection
|
|
if hasattr(bpy.context, "view_layer"):
|
|
bpy.context.view_layer.active_layer_collection = (
|
|
self.orig_active_collection
|
|
)
|
|
self.orig_active_collection = None
|
|
|
|
# Clear outliner selection
|
|
if hasattr(bpy.context, "selected_ids") and self.prev_area_type == "OUTLINER":
|
|
# This is a read-only property, so we can't directly clear it
|
|
# Instead, we can deselect in the outliner
|
|
|
|
# need to create a new context to deselect in the outliner
|
|
if bpy.app.version < (3, 2, 0): # B3.0, B3.1 - custom context override
|
|
context = bpy.context
|
|
override = {
|
|
"window": context.window,
|
|
"screen": context.screen,
|
|
"area": self.outliner_area,
|
|
"region": self.outliner_region,
|
|
"scene": context.scene,
|
|
"view_layer": context.view_layer,
|
|
}
|
|
# Only try to deselect if we have valid area and region
|
|
if self.outliner_area and self.outliner_region:
|
|
bpy.ops.outliner.select_box(
|
|
override,
|
|
xmin=0,
|
|
xmax=1,
|
|
ymin=0,
|
|
ymax=1,
|
|
wait_for_input=False,
|
|
mode="SET",
|
|
) # Use a very small selection box in the corner to deselect everything
|
|
else: # B3.2+ can use context.temp_override()
|
|
with bpy.context.temp_override(
|
|
window=self.outliner_window,
|
|
area=self.outliner_area,
|
|
region=self.outliner_region,
|
|
):
|
|
bpy.ops.outliner.select_box(
|
|
xmin=0,
|
|
xmax=1,
|
|
ymin=0,
|
|
ymax=1,
|
|
wait_for_input=False,
|
|
mode="SET",
|
|
) # Use a very small selection box in the corner to deselect everything
|
|
|
|
def drag_raycast_3d_view(
|
|
self, context, event, active_window, active_region, active_area
|
|
):
|
|
"""Get the active object under the mouse cursor during drag."""
|
|
|
|
region_data = None
|
|
|
|
for space in active_area.spaces:
|
|
if space.type == "VIEW_3D":
|
|
region_data = space.region_3d
|
|
|
|
# Need to temporarily override context for raycasting
|
|
if bpy.app.version < (3, 2, 0): # B3.0, B3.1 - custom context override
|
|
override = {
|
|
"window": active_window,
|
|
"screen": active_window.screen,
|
|
"area": active_area,
|
|
"region": active_region,
|
|
"region_data": active_area.spaces[
|
|
0
|
|
].region_3d, # Get region_data from space_data
|
|
"scene": context.scene,
|
|
"view_layer": context.view_layer,
|
|
}
|
|
(
|
|
self.has_hit,
|
|
self.snapped_location,
|
|
self.snapped_normal,
|
|
self.snapped_rotation,
|
|
self.face_index,
|
|
obj,
|
|
self.matrix,
|
|
) = mouse_raycast(active_region, region_data, self.mouse_x, self.mouse_y)
|
|
if obj is not None:
|
|
self.object_name = obj.name
|
|
else: # B3.2+ can use context.temp_override()
|
|
with bpy.context.temp_override(
|
|
window=active_window, area=active_area, region=active_region
|
|
):
|
|
(
|
|
self.has_hit,
|
|
self.snapped_location,
|
|
self.snapped_normal,
|
|
self.snapped_rotation,
|
|
self.face_index,
|
|
obj,
|
|
self.matrix,
|
|
) = mouse_raycast(
|
|
active_region,
|
|
region_data,
|
|
self.mouse_x,
|
|
self.mouse_y,
|
|
)
|
|
if obj is not None:
|
|
self.object_name = obj.name
|
|
|
|
# MODELS and NODEGROUPS can be dragged on scene floor
|
|
if not self.has_hit and self.asset_data["assetType"] in [
|
|
"model",
|
|
"printable",
|
|
"nodegroup",
|
|
]:
|
|
# Need to temporarily override context for raycasting
|
|
if bpy.app.version < (3, 2, 0): # B3.0, B3.1 - custom context override
|
|
override = {
|
|
"window": active_window,
|
|
"screen": active_window.screen,
|
|
"area": active_area,
|
|
"region": active_region,
|
|
"region_data": region_data,
|
|
"scene": context.scene,
|
|
"view_layer": context.view_layer,
|
|
}
|
|
(
|
|
self.has_hit,
|
|
self.snapped_location,
|
|
self.snapped_normal,
|
|
self.snapped_rotation,
|
|
self.face_index,
|
|
obj,
|
|
self.matrix,
|
|
) = floor_raycast(
|
|
active_region,
|
|
region_data,
|
|
self.mouse_x,
|
|
self.mouse_y,
|
|
)
|
|
if obj is not None:
|
|
self.object_name = obj.name
|
|
else:
|
|
self.object_name = None
|
|
else: # B3.2+ can use context.temp_override()
|
|
with bpy.context.temp_override(
|
|
window=active_window, area=active_area, region=active_region
|
|
):
|
|
(
|
|
self.has_hit,
|
|
self.snapped_location,
|
|
self.snapped_normal,
|
|
self.snapped_rotation,
|
|
self.face_index,
|
|
obj,
|
|
self.matrix,
|
|
) = floor_raycast(
|
|
active_region,
|
|
region_data,
|
|
self.mouse_x,
|
|
self.mouse_y,
|
|
)
|
|
if obj is not None:
|
|
self.object_name = obj.name
|
|
else:
|
|
self.object_name = None
|
|
|
|
def _handle_node_editor_type(
|
|
self, current_area_type: Union[str, None], active_area: bpy.types.Area
|
|
) -> None:
|
|
"""Track if we're in a node editor and what type."""
|
|
if current_area_type and current_area_type == "NODE_EDITOR":
|
|
self.in_node_editor = True
|
|
if active_area.spaces.active.tree_type == "ShaderNodeTree":
|
|
self.node_editor_type = "shader"
|
|
elif active_area.spaces.active.tree_type == "GeometryNodeTree":
|
|
self.node_editor_type = "geometry"
|
|
elif active_area.spaces.active.tree_type == "CompositorNodeTree":
|
|
self.node_editor_type = "compositing"
|
|
elif active_area.spaces.active.tree_type == "TextureNodeTree":
|
|
self.node_editor_type = "texture"
|
|
else:
|
|
self.in_node_editor = False
|
|
self.node_editor_type = None
|
|
|
|
def modal(self, context: bpy.types.Context, event: bpy.types.Event) -> Set[str]:
|
|
ui_props = bpy.context.window_manager.blenderkitUI
|
|
|
|
self.resolution_factor = (
|
|
bpy.context.preferences.system.pixel_size
|
|
/ bpy.context.preferences.view.ui_scale
|
|
)
|
|
self.mouse_screen_x = int(
|
|
context.window.x * self.resolution_factor + event.mouse_x
|
|
)
|
|
self.mouse_screen_y = int(
|
|
context.window.y * self.resolution_factor + event.mouse_y
|
|
)
|
|
|
|
# Find the active region under the mouse cursor using actual screen coordinates
|
|
self.active_window, self.active_area, self.active_region = (
|
|
self.find_active_region(self.mouse_screen_x, self.mouse_screen_y)
|
|
)
|
|
# --- CURSOR VISIBILITY FIX ---
|
|
if self.active_region is None or self.active_area is None:
|
|
bpy.context.window.cursor_modal_set("STOP")
|
|
return {"PASS_THROUGH"}
|
|
elif self.drag:
|
|
bpy.context.window.cursor_modal_set("NONE")
|
|
|
|
# Convert screen coords (bottom-left) to region-local coords
|
|
# window.x/y and region.x/y are also in bottom-left coordinate system
|
|
self.mouse_x = int(
|
|
self.mouse_screen_x
|
|
- self.active_window.x * self.resolution_factor
|
|
- self.active_region.x
|
|
)
|
|
self.mouse_y = int(
|
|
self.mouse_screen_y
|
|
- self.active_window.y * self.resolution_factor
|
|
- self.active_region.y
|
|
)
|
|
|
|
if self.start_mouse_x is None or self.start_mouse_y is None:
|
|
self.start_mouse_x = self.mouse_x
|
|
self.start_mouse_y = self.mouse_y
|
|
|
|
# --- REDRAW ALL WINDOWS/AREAS FOR MULTI-WINDOW DRAG ---
|
|
for window in bpy.context.window_manager.windows:
|
|
for area in window.screen.areas:
|
|
area.tag_redraw()
|
|
|
|
current_area_type = self.active_area.type if self.active_area else None
|
|
|
|
# Check if we're transitioning out of the outliner
|
|
if (
|
|
self.prev_area_type
|
|
and self.prev_area_type == "OUTLINER"
|
|
and current_area_type != "OUTLINER"
|
|
):
|
|
# If we're leaving the outliner, restore the original selection
|
|
self.restore_original_selection()
|
|
|
|
# shift pressed
|
|
if event.shift:
|
|
self.shift_pressed = True
|
|
else:
|
|
self.shift_pressed = False
|
|
|
|
# Track if we're in a node editor
|
|
self._handle_node_editor_type(current_area_type, self.active_area)
|
|
|
|
# Update the previous area type for the next frame
|
|
if current_area_type:
|
|
self.prev_area_type = current_area_type
|
|
|
|
if self.active_region and self.active_area:
|
|
# Store the active region pointer for drawing 2D elements only in this region
|
|
self.active_region_pointer = self.active_region.as_pointer()
|
|
|
|
# Make sure all 3D views get redrawn
|
|
for area in context.screen.areas:
|
|
area.tag_redraw()
|
|
|
|
# Handle outliner interaction
|
|
if self.active_area.type == "OUTLINER":
|
|
self.hovered_outliner_element = self.find_outliner_element_under_mouse()
|
|
self.outliner_window = self.active_window
|
|
self.outliner_area = self.active_area
|
|
self.outliner_region = self.active_region
|
|
else:
|
|
# Reset outliner tracking
|
|
self.hovered_outliner_element = None
|
|
self.outliner_window = None
|
|
self.outliner_area = None
|
|
self.outliner_region = None
|
|
else:
|
|
# If no active 3D region is found, use the context region
|
|
self.active_region_pointer = context.region.as_pointer()
|
|
|
|
# are we dragging already?
|
|
if not self.drag and (
|
|
abs(self.start_mouse_x - self.mouse_x) > DRAG_THRESHOLD
|
|
or abs(self.start_mouse_y - self.mouse_y) > DRAG_THRESHOLD
|
|
):
|
|
self.drag = True
|
|
|
|
if self.drag and ui_props.assetbar_on:
|
|
# turn off asset bar here, shout start again after finishing drag drop.
|
|
ui_props.turn_off = True
|
|
|
|
if (
|
|
event.type == "ESC"
|
|
or not ui.mouse_in_region(context.region, self.mouse_x, self.mouse_y)
|
|
) and (not self.drag or self.steps < DEAD_ZONE):
|
|
# this case is for canceling from inside popup card when there's an escape attempt to close the window
|
|
return {"PASS_THROUGH"}
|
|
|
|
if event.type in {"RIGHTMOUSE", "ESC"}:
|
|
# Restore original selection if we changed it
|
|
self.restore_original_selection()
|
|
|
|
self.handlers_remove()
|
|
bpy.context.window.cursor_modal_restore()
|
|
ui_props.dragging = False
|
|
bpy.ops.view3d.blenderkit_asset_bar_widget(
|
|
"INVOKE_REGION_WIN", do_search=False
|
|
)
|
|
|
|
return {"CANCELLED"}
|
|
|
|
sprops = bpy.context.window_manager.blenderkit_models
|
|
if event.type == "WHEELUPMOUSE":
|
|
sprops.offset_rotation_amount += sprops.offset_rotation_step
|
|
elif event.type == "WHEELDOWNMOUSE":
|
|
sprops.offset_rotation_amount -= sprops.offset_rotation_step
|
|
|
|
if (
|
|
event.type == "MOUSEMOVE"
|
|
or event.type == "WHEELUPMOUSE"
|
|
or event.type == "WHEELDOWNMOUSE"
|
|
):
|
|
|
|
# sometimes active area or region can be None, so we need to check for that
|
|
if self.active_area is None or self.active_region is None:
|
|
return {"RUNNING_MODAL"}
|
|
|
|
# reset values
|
|
self.object_name = None
|
|
self.has_hit = False
|
|
|
|
# Only perform raycasting in 3D view areas
|
|
if (
|
|
self.active_region
|
|
and self.active_area
|
|
and self.active_area.type == "VIEW_3D"
|
|
):
|
|
# prefetch the drag active object info
|
|
self.drag_raycast_3d_view(
|
|
context,
|
|
event,
|
|
self.active_window,
|
|
self.active_region,
|
|
self.active_area,
|
|
)
|
|
|
|
if self.asset_data["assetType"] in ["model", "printable"]:
|
|
self.snapped_bbox_min = Vector(self.asset_data["bbox_min"])
|
|
self.snapped_bbox_max = Vector(self.asset_data["bbox_max"])
|
|
elif self.active_area.type != "VIEW_3D":
|
|
# In outliner, don't do raycasting, but keep has_hit to avoid errors
|
|
self.has_hit = False
|
|
|
|
if event.type == "LEFTMOUSE" and event.value == "RELEASE":
|
|
self.mouse_release(context) # Pass context here
|
|
self.handlers_remove()
|
|
bpy.context.window.cursor_modal_restore()
|
|
|
|
bpy.ops.view3d.run_assetbar_fix_context(keep_running=True, do_search=False)
|
|
ui_props.dragging = False
|
|
return {"FINISHED"}
|
|
|
|
self.steps += 1
|
|
|
|
# pass event to assetbar so it can close itself
|
|
if ui_props.assetbar_on and ui_props.turn_off:
|
|
return {"PASS_THROUGH"}
|
|
|
|
return {"RUNNING_MODAL"}
|
|
|
|
def invoke(self, context, event):
|
|
# Before registering callbacks, check for canceling situations: login and localdir popups, sculpt popup/switch
|
|
sr = search.get_search_results()
|
|
ui_props = bpy.context.window_manager.blenderkitUI
|
|
# Use the asset_search_index parameter passed to the operator, not the global ui_props.active_index
|
|
# This is critical for multi-window support where active_index is shared across windows
|
|
self.asset_data = dict(sr[self.asset_search_index])
|
|
# add-ons
|
|
if self.asset_data.get("assetType") == "addon" and not self.asset_data.get(
|
|
"canDownload"
|
|
):
|
|
message = "This addon is not purchased yet."
|
|
link_text = "Purchase add-on online"
|
|
url = f'{global_vars.SERVER}/get-blenderkit/{self.asset_data["id"]}/?from_addon=True'
|
|
bpy.ops.wm.blenderkit_url_dialog(
|
|
"INVOKE_REGION_WIN", url=url, message=message, link_text=link_text
|
|
)
|
|
return {"CANCELLED"}
|
|
|
|
if not self.asset_data.get("canDownload"):
|
|
|
|
message = "This asset is included in Full Plan.\nSupport asset creators & open-source by subscribing."
|
|
link_text = "Unlock All Assets"
|
|
url = f"{global_vars.SERVER}/get-blenderkit/{self.asset_data['id']}/?from_addon=True"
|
|
bpy.ops.wm.blenderkit_url_dialog(
|
|
"INVOKE_REGION_WIN", url=url, message=message, link_text=link_text
|
|
)
|
|
return {"CANCELLED"}
|
|
|
|
prefs = bpy.context.preferences.addons[__package__].preferences
|
|
|
|
dir_behaviour = prefs.directory_behaviour
|
|
|
|
if dir_behaviour == "LOCAL" and bpy.data.filepath == "":
|
|
message = "Save the project to download in local directory mode."
|
|
link_text = "See documentation"
|
|
url = "https://github.com/BlenderKit/blenderkit/wiki/BlenderKit-Preferences#use-directories"
|
|
bpy.ops.wm.blenderkit_url_dialog(
|
|
"INVOKE_REGION_WIN", url=url, message=message, link_text=link_text
|
|
)
|
|
return {"CANCELLED"}
|
|
|
|
if self.asset_data.get("assetType") == "brush":
|
|
if not (context.sculpt_object or context.image_paint_object):
|
|
# either switch to sculpt mode and layout automatically or show a popup message
|
|
if context.active_object and context.active_object.type == "MESH":
|
|
bpy.ops.object.mode_set(mode="SCULPT")
|
|
self.mouse_release(context) # does the main job with assets
|
|
|
|
if bpy.data.workspaces.get("Sculpting") is not None:
|
|
bpy.context.window.workspace = bpy.data.workspaces["Sculpting"]
|
|
reports.add_report(
|
|
"Automatically switched to sculpt mode to use brushes."
|
|
)
|
|
else:
|
|
message = "Select a mesh and switch to sculpt or image paint modes to use the brushes."
|
|
bpy.ops.wm.blenderkit_popup_dialog(
|
|
"INVOKE_REGION_WIN", message=message, width=500
|
|
)
|
|
return {"CANCELLED"}
|
|
|
|
# the arguments we pass the the callback
|
|
args = (self, context)
|
|
|
|
# Register callback for VIEW_3D spaces
|
|
self._handle_3d = bpy.types.SpaceView3D.draw_handler_add(
|
|
draw_callback_3d_dragging, args, "WINDOW", "POST_VIEW"
|
|
)
|
|
|
|
# Register callbacks for all other space types
|
|
# List of space types we want to support
|
|
space_types = [
|
|
"SpaceTextEditor",
|
|
"SpaceConsole",
|
|
"SpaceInfo",
|
|
"SpacePreferences",
|
|
"SpaceFileBrowser",
|
|
"SpaceNLA",
|
|
"SpaceDopeSheetEditor",
|
|
"SpaceGraphEditor",
|
|
"SpaceNodeEditor",
|
|
"SpaceProperties",
|
|
"SpaceSequenceEditor",
|
|
"SpaceImageEditor",
|
|
"SpaceView3D",
|
|
"SpaceOutliner",
|
|
]
|
|
|
|
# Initialize a dictionary to store handlers
|
|
self._handlers_universal = {}
|
|
|
|
# Register a handler for each space type
|
|
for space_type in space_types:
|
|
try:
|
|
space_class = getattr(bpy.types, space_type)
|
|
handler = space_class.draw_handler_add(
|
|
draw_callback_dragging, args, "WINDOW", "POST_PIXEL"
|
|
)
|
|
# we should store the handler to be able to remove it later
|
|
# but if RNA Struct fails we are not longer able to remove it, so we log an error and store None
|
|
self._handlers_universal[space_type] = handler
|
|
except (AttributeError, TypeError) as e:
|
|
bk_logger.error(f"Could not register handler for {space_type}: {e}")
|
|
self._handlers_universal[space_type] = None
|
|
|
|
self.mouse_x = 0
|
|
self.mouse_y = 0
|
|
self.mouse_screen_x = 0
|
|
self.mouse_screen_y = 0
|
|
self.steps = 0
|
|
# Store the initial active region pointer
|
|
self.active_region_pointer = context.region.as_pointer()
|
|
|
|
# Initialize outliner tracking variables
|
|
self.hovered_outliner_element = None
|
|
self.outliner_area = None
|
|
self.outliner_region = None
|
|
self.orig_selected_objects = None
|
|
self.orig_active_object = None
|
|
self.orig_active_collection = None
|
|
self.prev_area_type = context.area.type # Track previous area type
|
|
|
|
# Initialize node editor tracking
|
|
self.in_node_editor = False
|
|
self.node_editor_type = None
|
|
|
|
self.shift_pressed = False
|
|
|
|
# Initialize has_hit to False, and set other 3D properties
|
|
# We'll only use these in 3D views, not in outliner
|
|
self.has_hit = False
|
|
self.snapped_location = (0, 0, 0)
|
|
self.snapped_normal = (0, 0, 1)
|
|
self.snapped_rotation = (0, 0, 0)
|
|
self.face_index = 0
|
|
self.matrix = None
|
|
|
|
self.iname = f".{self.asset_data['thumbnail_small']}"
|
|
self.iname = (self.iname[:63]) if len(self.iname) > 63 else self.iname
|
|
|
|
bpy.context.window.cursor_modal_set("NONE")
|
|
ui_props = bpy.context.window_manager.blenderkitUI
|
|
ui_props.dragging = True
|
|
self.drag = False
|
|
context.window_manager.modal_handler_add(self)
|
|
return {"RUNNING_MODAL"}
|
|
|
|
def get_node_editor_cursor_position(
|
|
self,
|
|
) -> Tuple[float, float]:
|
|
"""Get the cursor position in the node editor space."""
|
|
|
|
# Get view2d from region
|
|
ui_scale = bpy.context.preferences.system.ui_scale
|
|
|
|
# Convert region coordinates to view coordinates using view2d
|
|
x, y = self.active_region.view2d.region_to_view(
|
|
float(self.mouse_x), float(self.mouse_y)
|
|
)
|
|
|
|
# Scale by UI scale - this ensures proper positioning
|
|
x = x / ui_scale
|
|
y = y / ui_scale
|
|
return (x, y)
|
|
|
|
|
|
class DownloadGizmoOperator(BL_UI_OT_draw_operator):
|
|
bl_idname = "view3d.blenderkit_download_gizmo_widget"
|
|
bl_label = "BlenderKit download gizmo"
|
|
bl_description = (
|
|
"BlenderKit download gizmo - draws download and enables to cancel it."
|
|
)
|
|
bl_options = {"REGISTER"}
|
|
instances = []
|
|
|
|
asset_base_id: StringProperty(name="asset base id", default="") # type: ignore
|
|
|
|
def cancel_press(self, widget: Any) -> None:
|
|
self.finish()
|
|
cancel_download = False
|
|
|
|
if self.downloader is None:
|
|
# prevent unbound
|
|
return
|
|
|
|
for key, t in download.download_tasks.items():
|
|
if key == self.task_key:
|
|
for d in t.get("downloaders", []):
|
|
if d["location"] == self.downloader["location"]:
|
|
download.download_tasks[key]["downloaders"].remove(d)
|
|
if len(download.download_tasks[key]["downloaders"]) == 0:
|
|
cancel_download = True
|
|
break
|
|
if cancel_download:
|
|
bpy.ops.scene.blenderkit_download_kill(task_id=self.task_key)
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
super().__init__(*args, **kwargs)
|
|
|
|
# get downloader
|
|
self.task = None
|
|
for key, t in download.download_tasks.items():
|
|
if t["asset_data"]["assetBaseId"] == self.asset_base_id:
|
|
self.task = t
|
|
self.task_key = key
|
|
break
|
|
|
|
if self.task is None:
|
|
self._finished = True
|
|
return
|
|
|
|
self.asset_data = self.task["asset_data"]
|
|
|
|
if self.task.get("downloaders"):
|
|
self.downloader = self.task["downloaders"][-1]
|
|
else:
|
|
self.downloader = None
|
|
|
|
ui_scale = bpy.context.preferences.view.ui_scale
|
|
|
|
text_size = int(10 * ui_scale)
|
|
margin = int(5 * ui_scale)
|
|
|
|
self.bg_color = (0.05, 0.05, 0.05, 0.3)
|
|
self.hover_bg_color = (0.05, 0.05, 0.05, 0.5)
|
|
|
|
self.text_color = (0.9, 0.9, 0.9, 1)
|
|
|
|
ui_props = bpy.context.window_manager.blenderkitUI
|
|
|
|
pix_size = ui_bgl.get_text_size(
|
|
font_id=1,
|
|
text=self.task["text"],
|
|
text_size=text_size,
|
|
dpi=int(bpy.context.preferences.system.dpi / ui_scale),
|
|
)
|
|
self.height = pix_size[1] + 2 * margin
|
|
self.button_size = int(ui_props.thumb_size)
|
|
self.width = pix_size[0] + 2 * margin # adding image and cancel button to width
|
|
|
|
if bpy.context.space_data is not None and self.downloader is not None:
|
|
loc = view3d_utils.location_3d_to_region_2d(
|
|
bpy.context.region,
|
|
bpy.context.space_data.region_3d,
|
|
self.downloader["location"],
|
|
)
|
|
if loc is None:
|
|
loc = Vector((0, 0))
|
|
else:
|
|
loc = Vector((0, 0))
|
|
|
|
self.panel = BL_UI_Drag_Panel(
|
|
loc.x, bpy.context.region.height - loc.y, self.width, self.height
|
|
)
|
|
self.panel.bg_color = (0.2, 0.2, 0.2, 0.02)
|
|
|
|
self.image = BL_UI_Image(
|
|
0, -self.button_size, self.button_size, self.button_size
|
|
)
|
|
|
|
self.label = BL_UI_Button(0, 0, pix_size[0] + 2 * margin, self.height)
|
|
self.label.text = self.task["text"]
|
|
self.label.text_size = text_size
|
|
self.label.text_color = self.text_color
|
|
|
|
self.label.bg_color = self.bg_color
|
|
self.label.hover_bg_color = self.hover_bg_color
|
|
|
|
self.button_close = BL_UI_Button(
|
|
self.button_size * 0.75,
|
|
-self.button_size * 1.25,
|
|
self.button_size / 2,
|
|
self.button_size / 2,
|
|
)
|
|
self.button_close.bg_color = self.bg_color
|
|
self.button_close.hover_bg_color = self.hover_bg_color
|
|
self.button_close.text = ""
|
|
self.button_close.set_mouse_down(self.cancel_press)
|
|
self._timer_interval = 0.04
|
|
|
|
def on_invoke(self, context: bpy.types.Context, event: bpy.types.Event):
|
|
# Add new widgets here (TODO: perhaps a better, more automated solution?)
|
|
self.context = context
|
|
self.instances.append(self)
|
|
|
|
# no task, no downloader...
|
|
if self._finished:
|
|
return False
|
|
|
|
widgets_panel = [self.label, self.image, self.button_close]
|
|
widgets = [self.panel]
|
|
|
|
widgets += widgets_panel
|
|
|
|
# assign image to the cancel button
|
|
img_fp = paths.get_addon_thumbnail_path("vs_rejected.png")
|
|
img_size = self.button_size
|
|
button_size = int(self.button_size / 2)
|
|
|
|
self.button_close.set_image(img_fp)
|
|
self.button_close.set_image_size((button_size, button_size))
|
|
self.button_close.set_image_position((0, 0))
|
|
|
|
directory = paths.get_temp_dir("%s_search" % self.asset_data["assetType"])
|
|
thumbnail_path = os.path.join(directory, self.asset_data["thumbnail_small"])
|
|
|
|
self.image.set_image(thumbnail_path)
|
|
self.image.set_image_size((img_size, img_size))
|
|
self.image.set_image_position((0, 0))
|
|
|
|
self.init_widgets(context, widgets)
|
|
self.panel.add_widgets(widgets_panel)
|
|
return True
|
|
|
|
def modal(self, context, event):
|
|
if self._finished:
|
|
return {"FINISHED"}
|
|
|
|
if self.task is None or self.task_key not in download.download_tasks:
|
|
self.finish()
|
|
return {"PASS_THROUGH"}
|
|
|
|
if not context.area:
|
|
# end if area disappears
|
|
self.finish()
|
|
return {"PASS_THROUGH"}
|
|
|
|
# if event.type == "MOUSEMOVE":
|
|
if bpy.context.space_data is not None and self.downloader is not None:
|
|
loc = view3d_utils.location_3d_to_region_2d(
|
|
bpy.context.region,
|
|
bpy.context.space_data.region_3d,
|
|
self.downloader["location"],
|
|
)
|
|
if loc is None:
|
|
loc = Vector((0, 0))
|
|
else:
|
|
loc = Vector((0, 0))
|
|
|
|
self.panel.set_location(loc.x, context.region.height - loc.y)
|
|
self.label.text = self.task["text"]
|
|
if self.handle_widget_events(event):
|
|
return {"RUNNING_MODAL"}
|
|
|
|
return {"PASS_THROUGH"}
|
|
|
|
def on_finish(self, context):
|
|
self._finished = True
|
|
|
|
@classmethod
|
|
def unregister(cls):
|
|
bk_logger.debug("unregistering class %s", cls)
|
|
instances_copy = cls.instances.copy()
|
|
for instance in instances_copy:
|
|
bk_logger.debug("- class instance %s", instance)
|
|
try:
|
|
instance.unregister_handlers(instance.context)
|
|
except Exception as e:
|
|
bk_logger.debug("-- error unregister_handlers(): %s", e)
|
|
try:
|
|
instance.on_finish(instance.context)
|
|
except Exception as e:
|
|
bk_logger.debug("-- error calling on_finish(): %s", e)
|
|
if bpy.context.region is not None:
|
|
bpy.context.region.tag_redraw()
|
|
|
|
cls.instances.remove(instance)
|
|
|
|
|
|
def analyze_gn_tree(tree, materials):
|
|
"""Recursively analyze GN tree and its node groups for Set Material nodes"""
|
|
current_mapping = {}
|
|
|
|
bk_logger.info("\nAnalyzing GN tree: %s", tree.name)
|
|
for node in tree.nodes:
|
|
bk_logger.info("Checking node: %s, type: %s", node.name, node.type)
|
|
if node.type == "SET_MATERIAL":
|
|
# Find material index in evaluated mesh
|
|
mat = node.inputs["Material"].default_value
|
|
bk_logger.info(
|
|
"Found Set Material node with material: %s", mat.name if mat else "None"
|
|
)
|
|
|
|
if mat:
|
|
for mat_idx, temp_mat in enumerate(materials):
|
|
if compare_material_names(temp_mat, mat):
|
|
bk_logger.info("Matched material to index %d", mat_idx)
|
|
current_mapping[mat_idx] = {
|
|
"type": "GN",
|
|
"node_name": node.name,
|
|
"tree_name": tree.name,
|
|
}
|
|
else:
|
|
# If no material is set, we can use this node for a new material
|
|
# Find first available index that isn't mapped
|
|
used_indices = set(current_mapping.keys())
|
|
for i in range(len(materials)):
|
|
if i not in used_indices:
|
|
bk_logger.info("Using empty Set Material node for index %d", i)
|
|
current_mapping[i] = {
|
|
"type": "GN",
|
|
"node_name": node.name,
|
|
"tree_name": tree.name,
|
|
}
|
|
break
|
|
|
|
# Check node groups recursively
|
|
elif node.type == "GROUP" and node.node_tree:
|
|
nested_mapping = analyze_gn_tree(node.node_tree, materials)
|
|
current_mapping.update(nested_mapping)
|
|
|
|
return current_mapping
|
|
|
|
|
|
def compare_material_names(mat1, mat2):
|
|
"""Compare two materials by name, but if one is None, use 'None' instead of mat1.name"""
|
|
if mat1 is None:
|
|
return mat2 is None
|
|
if mat2 is None:
|
|
return False
|
|
return mat1.name == mat2.name
|
|
|
|
|
|
def create_material_mapping(obj, temp_mesh):
|
|
"""Creates mapping between material indices and their sources (slots or GN nodes)"""
|
|
mapping = {}
|
|
|
|
bk_logger.info("\nCreating mapping for %s", obj.name)
|
|
bk_logger.info("Material slots: %d", len(obj.material_slots))
|
|
bk_logger.info("Has GN: %s", any(mod.type == "NODES" for mod in obj.modifiers))
|
|
|
|
# 1. First map regular material slots
|
|
for slot_idx, slot in enumerate(obj.material_slots):
|
|
# Find matching material in evaluated mesh
|
|
for mat_idx, mat in enumerate(temp_mesh.materials):
|
|
if compare_material_names(mat, slot.material):
|
|
mapping[mat_idx] = {"type": "SLOT", "index": slot_idx}
|
|
break # Stop after finding first match
|
|
|
|
# 2. Check Geometry Nodes
|
|
has_gn = False
|
|
for modifier in obj.modifiers:
|
|
if modifier.type == "NODES":
|
|
has_gn = True
|
|
gn_mapping = analyze_gn_tree(modifier.node_group, temp_mesh.materials)
|
|
if gn_mapping:
|
|
# Only add GN mappings for indices that aren't already mapped to slots
|
|
for idx, map_data in gn_mapping.items():
|
|
if idx not in mapping:
|
|
mapping[idx] = map_data
|
|
|
|
# 3. If no material slots and no GN, create a mapping for slot 0
|
|
if len(obj.material_slots) == 0 and not has_gn:
|
|
bk_logger.info("Creating default mapping to slot 0")
|
|
mapping[0] = {"type": "SLOT", "index": 0}
|
|
|
|
bk_logger.info("Final mapping: %s", mapping)
|
|
|
|
# Store mapping as custom property (convert to serializable format)
|
|
mapping_data = {str(k): v for k, v in mapping.items()}
|
|
obj["material_mapping"] = mapping_data
|
|
|
|
return mapping
|
|
|
|
|
|
def add_set_material_node(tree):
|
|
"""Add a Set Material node at the end of the node tree"""
|
|
# Find output node
|
|
output_node = None
|
|
for node in tree.nodes:
|
|
if node.type == "GROUP_OUTPUT":
|
|
output_node = node
|
|
break
|
|
|
|
if output_node:
|
|
# Create Set Material node
|
|
set_mat_node = tree.nodes.new("GeometryNodeSetMaterial")
|
|
# Position it before output
|
|
set_mat_node.location = (output_node.location.x - 200, output_node.location.y)
|
|
|
|
# Connect nodes
|
|
last_geometry_socket = None
|
|
for source in output_node.inputs:
|
|
if source.type == "GEOMETRY":
|
|
if source.is_linked:
|
|
last_geometry_socket = source.links[0].from_socket
|
|
break
|
|
|
|
if last_geometry_socket:
|
|
tree.links.new(last_geometry_socket, set_mat_node.inputs["Geometry"])
|
|
tree.links.new(set_mat_node.outputs["Geometry"], output_node.inputs[0])
|
|
|
|
return set_mat_node
|
|
return None
|
|
|
|
|
|
classes = (
|
|
AssetDragOperator,
|
|
DownloadGizmoOperator,
|
|
)
|
|
|
|
|
|
def register():
|
|
# register the classes
|
|
global handler_2d, handler_3d
|
|
|
|
for c in classes:
|
|
bpy.utils.register_class(c)
|
|
|
|
args = (None, bpy.context)
|
|
|
|
handler_2d = bpy.types.SpaceView3D.draw_handler_add(
|
|
draw_callback_2d_progress, args, "WINDOW", "POST_PIXEL"
|
|
)
|
|
handler_3d = bpy.types.SpaceView3D.draw_handler_add(
|
|
draw_callback_3d_progress, args, "WINDOW", "POST_VIEW"
|
|
)
|
|
|
|
|
|
def unregister():
|
|
global handler_2d, handler_3d
|
|
|
|
bpy.types.SpaceView3D.draw_handler_remove(handler_2d, "WINDOW")
|
|
bpy.types.SpaceView3D.draw_handler_remove(handler_3d, "WINDOW")
|
|
|
|
# unregister the classes
|
|
for c in classes:
|
|
bpy.utils.unregister_class(c)
|