# ##### 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 os import logging from collections.abc import Mapping from contextlib import contextmanager from typing import Optional, Tuple, Union import math from mathutils import Matrix import blf import bpy from bpy import app import gpu from gpu_extras.batch import batch_for_shader from .image_utils import IMG bk_logger = logging.getLogger(__name__) cached_images = {} cached_gpu_textures = {} _cached_image_shader: Optional[gpu.types.GPUShader] = None SEGMENTS_DEFAULT = 4 def _resolve_region_dimensions(region) -> Tuple[float, float]: width = getattr(region, "width", None) if region else None height = getattr(region, "height", None) if region else None if width is None or width <= 0 or height is None or height <= 0: ctx_region = getattr(bpy.context, "region", None) if ctx_region is not None: width = width or getattr(ctx_region, "width", None) height = height or getattr(ctx_region, "height", None) width = float(width or 1.0) height = float(height or 1.0) return width, height @contextmanager def overlay_matrix_guard(region=None, *args, **kwargs): """Ensure viewport overlays draw in screen space regardless of other handlers.""" pushed = False try: try: gpu.matrix.push() pushed = True gpu.matrix.load_identity() width, height = _resolve_region_dimensions(region) _set_overlay_projection(width, height) except Exception: # noqa: BLE001 bk_logger.exception("Failed to prepare overlay matrix state") yield finally: if pushed: try: gpu.matrix.pop() except Exception: # noqa: BLE001 bk_logger.exception("Failed to restore overlay matrix state") def _ortho_projection_matrix(width: float, height: float, *, near=-100.0, far=100.0): """Return a pixel-aligned orthographic projection matrix.""" if width <= 0.0: width = 1.0 if height <= 0.0: height = 1.0 if far == near: far = near + 0.001 sx = 2.0 / width sy = 2.0 / height sz = -2.0 / (far - near) tx = -1.0 ty = -1.0 tz = -(far + near) / (far - near) return Matrix( ( (sx, 0.0, 0.0, tx), (0.0, sy, 0.0, ty), (0.0, 0.0, sz, tz), (0.0, 0.0, 0.0, 1.0), ) ) def _set_overlay_projection(width: float, height: float): """Set the 2D projection matrix used by BlenderKit overlays.""" try: projection = _ortho_projection_matrix(width, height) gpu.matrix.load_projection_matrix(projection) except Exception: # noqa: BLE001 bk_logger.exception("overlay_matrix_guard: Failed to load projection matrix") VERTEX_SHADER_LEGACY = """ uniform mat4 ModelViewProjectionMatrix; in vec2 pos; in vec2 texCoord; out vec2 uv; void main() { uv = texCoord; gl_Position = ModelViewProjectionMatrix * vec4(pos.xy, 0.0, 1.0); } """ FRAGMENT_SHADER_LEGACY = """ in vec2 uv; out vec4 fragColor; uniform sampler2D image; uniform float transparency; uniform int color_space_mode; vec3 linear_to_srgb(vec3 linear_color) { vec3 cutoff = vec3(0.0031308); vec3 lower = linear_color * 12.92; vec3 higher = 1.055 * pow(max(linear_color, vec3(0.0)), vec3(1.0 / 2.4)) - 0.055; return mix(lower, higher, step(cutoff, linear_color)); } void main() { vec4 color = texture(image, uv); if (color_space_mode == 1) { color.rgb = linear_to_srgb(color.rgb); } color.a *= transparency; fragColor = color; } """ def create_image_shader_info(): """Return GPU shader info for the runtime image shader.""" shader_info = gpu.types.GPUShaderCreateInfo() shader_info.vertex_in(0, "VEC2", "pos") shader_info.vertex_in(1, "VEC2", "texCoord") stage_iface = gpu.types.GPUStageInterfaceInfo("uv_iface") stage_iface.smooth("VEC2", "uv") shader_info.vertex_out(stage_iface) shader_info.push_constant("MAT4", "ModelViewProjectionMatrix") shader_info.push_constant("FLOAT", "transparency") shader_info.push_constant("INT", "color_space_mode") shader_info.sampler(0, "FLOAT_2D", "image") shader_info.fragment_out(0, "VEC4", "fragColor") shader_info.vertex_source( """ void main() { uv = texCoord; gl_Position = ModelViewProjectionMatrix * vec4(pos.xy, 0.0, 1.0); } """ ) shader_info.fragment_source( """ void main() { vec4 color = texture(image, uv); if (color_space_mode == 1) { vec3 cutoff = vec3(0.0031308); vec3 lower = color.rgb * 12.92; vec3 higher = 1.055 * pow(max(color.rgb, vec3(0.0)), vec3(1.0 / 2.4)) - 0.055; color.rgb = mix(lower, higher, step(cutoff, color.rgb)); } color.a *= transparency; fragColor = color; } """ ) return shader_info def create_image_shader(): """Return a cached shader that supports transparency across Blender versions. Features: - sRGB conversion for UI overlays - transparency """ global _cached_image_shader if _cached_image_shader is not None: return _cached_image_shader shader = None create_info_supported = ( hasattr(gpu, "shader") and hasattr(gpu.shader, "create_from_info") and hasattr(gpu.types, "GPUShaderCreateInfo") ) if create_info_supported: try: shader_info = create_image_shader_info() shader = gpu.shader.create_from_info(shader_info) except Exception: # noqa: BLE001 bk_logger.exception("Failed to create image shader") shader = None if shader is None: try: shader = gpu.types.GPUShader(VERTEX_SHADER_LEGACY, FRAGMENT_SHADER_LEGACY) except Exception: # noqa: BLE001 bk_logger.exception("Failed to create image shader") if shader is None: # fallback to builtin shader # mainly for MacOS builds that have issues with custom shaders if app.version < (4, 0, 0): shader = gpu.shader.from_builtin("2D_IMAGE") else: shader = gpu.shader.from_builtin("IMAGE") _cached_image_shader = shader return shader def get_ui_scale() -> float: """Get the UI scale Returns: The UI scale factor as a float. """ return bpy.context.preferences.system.dpi / 96.0 # sources: # "sys" values are from system, # and for current monitor on which the blender is running sys_ps = bpy.context.preferences.system.pixel_size # dpi scaling 72 =-> 1.0, 126 -> 1.75 (dpi -> sys_ui_scale) # 96 / dpi gives correct scale factor sys_dpi = bpy.context.preferences.system.dpi # global scaler sys_sc = bpy.context.preferences.system.ui_scale # local ui scaler (can be set by user per blender window) view_sc = bpy.context.preferences.view.ui_scale def _get_flat_shader_2d(): if app.version < (4, 0, 0): shader_name = "2D_UNIFORM_COLOR" else: shader_name = "UNIFORM_COLOR" return gpu.shader.from_builtin(shader_name) def draw_rect(x, y, width, height, color): """Used for drawing 2D rectangle backgrounds.""" xmax = x + width ymax = y + height points = ( (x, y), # (x, y) (x, ymax), # (x, y) (xmax, ymax), # (x, y) (xmax, y), # (x, y) ) indices = ((0, 1, 2), (2, 3, 0)) shader = _get_flat_shader_2d() batch = batch_for_shader(shader, "TRIS", {"pos": points}, indices=indices) gpu.state.blend_set("ALPHA") shader.bind() shader.uniform_float("color", color) batch.draw(shader) def draw_rect_outline(x, y, width, height, color, line_width=1.0): """Used for drawing 2D rectangle outlines.""" xmax = x + width ymax = y + height coords = ( (x, y), # (x, y) (x, ymax), # (x, y) (xmax, ymax), # (x, y) (xmax, y), # (x, y) ) indices = ((0, 1), (1, 2), (2, 3), (3, 0)) shader = _get_flat_shader_2d() batch = batch_for_shader(shader, "LINES", {"pos": coords}, indices=indices) gpu.state.blend_set("ALPHA") gpu.state.line_width_set(line_width) shader.bind() shader.uniform_float("color", color) batch.draw(shader) def draw_line2d(x1, y1, x2, y2, width, color): """Used for drawing line from dragged thumbnail to the 3D bounding box.""" coords = ((x1, y1), (x2, y2)) indices = ((0, 1),) shader = _get_flat_shader_2d() batch = batch_for_shader(shader, "LINES", {"pos": coords}, indices=indices) gpu.state.blend_set("ALPHA") gpu.state.line_width_set(max(1.0, width)) shader.bind() shader.uniform_float("color", color) batch.draw(shader) gpu.state.line_width_set(1.0) def _parse_radius_value( value, *, max_radius: float, min_dimension: float, ui_scale: float = 1.0, ) -> float: """Return a clamped radius in pixels. Accepts raw pixel values, strings with percentages (e.g. "50%"), mapping types containing ``percent``/``pct``/``ratio`` or ``px`` keys, and falls back to treating anything else as raw pixels. """ def clamp_radius(radius: float) -> float: return max(0.0, min(radius, max_radius)) effective_scale = ui_scale if ui_scale > 0.0 else 1.0 def scale_px_value(px_value: float) -> float: return clamp_radius(px_value * effective_scale) if isinstance(value, str): text = value.strip() if text.endswith("%"): number = text[:-1].strip() try: pct = float(number) / 100.0 except ValueError: return 0.0 radius_px = pct * min_dimension return clamp_radius(radius_px) # plain numeric string interpreted as pixels try: value = float(text) except ValueError: return 0.0 return scale_px_value(value) if isinstance(value, Mapping): if "percent" in value: try: pct = float(value["percent"]) / 100.0 except (TypeError, ValueError): pct = 0.0 radius_px = pct * min_dimension return clamp_radius(radius_px) if "pct" in value: try: pct = float(value["pct"]) / 100.0 except (TypeError, ValueError): pct = 0.0 radius_px = pct * min_dimension return clamp_radius(radius_px) if "ratio" in value: try: ratio = float(value["ratio"]) except (TypeError, ValueError): ratio = 0.0 radius_px = ratio * min_dimension return clamp_radius(radius_px) if "px" in value: try: px_value = float(value["px"]) except (TypeError, ValueError): px_value = 0.0 return scale_px_value(px_value) try: numeric_value = float(value) # type: ignore except (TypeError, ValueError): numeric_value = 0.0 return scale_px_value(numeric_value) def _rounded_rect_outline( x: float, y: float, width: float, height: float, radius: Union[tuple[Union[str, float], ...], str, float] = (0.0,), segments: int = SEGMENTS_DEFAULT, ): if width <= 0 or height <= 0: return [] min_dimension = min(width, height) max_radius = max(0.0, min_dimension / 2.0) if isinstance(radius, (tuple, list)): raw_radii = list(radius) else: raw_radii = [radius] if not raw_radii: raw_radii = [0.0] ui_scale = get_ui_scale() parsed_radii = [ _parse_radius_value( value, max_radius=max_radius, min_dimension=min_dimension, ui_scale=ui_scale, ) for value in raw_radii ] while len(parsed_radii) < 4: parsed_radii.append(parsed_radii[-1]) radii = parsed_radii[:4] r_tl, r_tr, r_br, r_bl = radii if all(r == 0.0 for r in radii): outline = [ (x, y), (x + width, y), (x + width, y + height), (x, y + height), ] outline.append(outline[0]) return outline steps = max(1, int(segments)) outline = [] steps = max(1, int(segments)) outline = [] def emit_corner(cx, cy, start_angle, end_angle, radius_value, fallback_point): if radius_value <= 0.0: outline.append(fallback_point) return for step in range(steps + 1): t = step / steps angle = start_angle + (end_angle - start_angle) * t outline.append( ( cx + math.cos(angle) * radius_value, cy + math.sin(angle) * radius_value, ) ) emit_corner( x + r_tl, y + height - r_tl, math.pi, math.pi / 2.0, r_tl, (x, y + height), ) emit_corner( x + width - r_tr, y + height - r_tr, math.pi / 2.0, 0.0, r_tr, (x + width, y + height), ) emit_corner( x + width - r_br, y + r_br, 0.0, -math.pi / 2.0, r_br, (x + width, y), ) emit_corner( x + r_bl, y + r_bl, -math.pi / 2.0, -math.pi, r_bl, (x, y), ) if outline and outline[0] != outline[-1]: outline.append(outline[0]) return outline def _rounded_rect_mesh( x: float, y: float, width: float, height: float, radius: Union[tuple[Union[str, float], ...], str, float], crop: Tuple[float, float, float, float], segments: int = SEGMENTS_DEFAULT, ): if width <= 0.0 or height <= 0.0: return None outline = _rounded_rect_outline( x, y, width, height, radius, segments=segments, ) if not outline: return None loop = outline[:-1] if len(outline) > 1 and outline[0] == outline[-1] else outline if len(loop) < 3: return None crop_u0, crop_v0, crop_u1, crop_v1 = crop u_span = crop_u1 - crop_u0 v_span = crop_v1 - crop_v0 if u_span == 0.0: u_span = 1.0 if v_span == 0.0: v_span = 1.0 coords = list(loop) try: inv_width = 1.0 / width except ZeroDivisionError: inv_width = 0.0 try: inv_height = 1.0 / height except ZeroDivisionError: inv_height = 0.0 uvs = [] for vx, vy in coords: rel_x = (vx - x) * inv_width rel_y = (vy - y) * inv_height u = crop_u0 + rel_x * u_span v = crop_v0 + rel_y * v_span uvs.append((u, v)) indices = [(0, idx, idx + 1) for idx in range(1, len(coords) - 1)] return coords, uvs, indices def draw_rounded_rect_with_border( x: float, y: float, width: float, height: float, radius: Union[tuple[Union[str, float], ...], str, float] = (0.0,), fill_color: Tuple[float, float, float, float] = (0.0, 0.0, 0.0, 1.0), border_color: Optional[Tuple[float, float, float, float]] = None, border_thickness: float = 1.0, ): if width <= 0 or height <= 0: return outline = _rounded_rect_outline(x, y, width, height, radius) if not outline: return loop = outline[:-1] if len(outline) > 1 and outline[0] == outline[-1] else outline if len(loop) < 3: return shader = _get_flat_shader_2d() indices = [(0, idx, idx + 1) for idx in range(1, len(loop) - 1)] batch = batch_for_shader(shader, "TRIS", {"pos": loop}, indices=indices) gpu.state.blend_set("ALPHA") shader.bind() shader.uniform_float("color", fill_color) batch.draw(shader) if border_color and border_thickness > 0: gpu.state.line_width_set(border_thickness) if outline[0] == outline[-1]: line_points = outline else: line_points = outline + [outline[0]] line_batch = batch_for_shader(shader, "LINE_STRIP", {"pos": line_points}) shader.uniform_float("color", border_color) line_batch.draw(shader) gpu.state.line_width_set(1.0) def draw_strikethrough_line(x_start, x_end, y, color, thickness): if x_end <= x_start: return draw_line2d(x_start, y, x_end, y, thickness, color) def create_shader_info(): """Added for Blender 4.5+ in which the gpu.shader.from_builtin("UNIFORM_COLOR") silently stopped working. Interestingly for draw_rect_3d UNIFORM_COLOR still works just fine. https://github.com/BlenderKit/BlenderKit/issues/1574 """ if app.version < (4, 5, 0): return bk_logger.warning("Unexpected call to create_shader_info()!") shader_info = gpu.types.GPUShaderCreateInfo() shader_info.vertex_in(0, "VEC3", "pos") shader_info.push_constant("MAT4", "ModelViewProjectionMatrix") shader_info.push_constant("VEC4", "color") shader_info.fragment_out(0, "VEC4", "fragColor") shader_info.vertex_source( """ void main() { gl_Position = ModelViewProjectionMatrix * vec4(pos, 1.0); } """ ) shader_info.fragment_source( """ void main() { fragColor = color; } """ ) return shader_info def draw_lines(vertices, indices, color): """Used for drawing 3D bounding box.""" if app.version < (4, 0, 0): shader = gpu.shader.from_builtin("3D_UNIFORM_COLOR") elif app.version < (4, 5, 0): shader = gpu.shader.from_builtin("UNIFORM_COLOR") else: shader_info = create_shader_info() shader = gpu.shader.create_from_info(shader_info) batch = batch_for_shader(shader, "LINES", {"pos": vertices}, indices=indices) gpu.state.blend_set("ALPHA") shader.bind() shader.uniform_float("color", color) batch.draw(shader) def draw_rect_3d(coords, color): """Used for drawing 3D rectangle backgrounds.""" indices = [(0, 1, 2), (2, 3, 0)] if app.version < (4, 0, 0): shader = gpu.shader.from_builtin("3D_UNIFORM_COLOR") else: shader = gpu.shader.from_builtin("UNIFORM_COLOR") batch = batch_for_shader(shader, "TRIS", {"pos": coords}, indices=indices) gpu.state.blend_set("ALPHA") shader.bind() shader.uniform_float("color", color) batch.draw(shader) def _resolve_color_space_mode() -> int: """Return shader color conversion mode for the current drawing context. area over non-3D means UI overlay, so we need to apply sRGB conversion.""" area = getattr(bpy.context, "area", None) if area is None: return 0 # Blender 5.0+ node editors already expect linear data, so avoid extra conversion there node_editor_types = {"NODE_EDITOR", "VIEW_3D"} if area.type in node_editor_types: return 0 return 1 def draw_image_runtime( x: float, y: float, width: float, height: float, image: Union[bpy.types.Image, IMG], transparency: Optional[float] = 1.0, crop: Tuple[float, float, float, float] = (0, 0, 1, 1), batch: Optional[gpu.types.GPUBatch] = None, corner_radius: Optional[Union[tuple[Union[str, float], ...], str, float]] = None, corner_segments: int = SEGMENTS_DEFAULT, ) -> Optional[gpu.types.GPUBatch]: """Draws an image at given location with given size. Supports optional rounded corner clipping by supplying ``corner_radius``. Returns: The batch object if successful, or None if the image is invalid. """ if width <= 0.0 or height <= 0.0 or not image.name or not image.filepath: return None image_shader = create_image_shader() rounded_segments = max(1, int(corner_segments)) cache_key = ( image.filepath, float(x), float(y), float(width), float(height), tuple(float(component) for component in crop), repr(corner_radius) if corner_radius is not None else None, rounded_segments, ) texture = None if batch is None: ci = cached_images.get(cache_key) if ci is not None: batch = ci["batch"] image_shader = ci["image_shader"] texture = ci["texture"] if batch is None: coords = None uvs = None indices = None if corner_radius is not None: mesh_data = _rounded_rect_mesh( x, y, width, height, corner_radius, crop, rounded_segments, ) if mesh_data: coords, uvs, indices = mesh_data if coords is None or uvs is None or indices is None: coords = [(x, y), (x + width, y), (x, y + height), (x + width, y + height)] uvs = [ (crop[0], crop[1]), (crop[2], crop[1]), (crop[0], crop[3]), (crop[2], crop[3]), ] indices = [(0, 1, 2), (2, 1, 3)] batch = batch_for_shader( image_shader, "TRIS", {"pos": coords, "texCoord": uvs}, indices=indices ) texture = path_to_gpu_texture(image.filepath) cached_images[cache_key] = { "batch": batch, "image_shader": image_shader, "texture": texture, } if batch is None or image_shader is None: return None if texture is None: texture = path_to_gpu_texture(image.filepath) if texture is None: return None color_space_mode = _resolve_color_space_mode() gpu.state.blend_set("ALPHA") image_shader.bind() image_shader.uniform_sampler("image", texture) # may not be available in simple shader try: # set floats image_shader.uniform_float("transparency", transparency) # set color space mode image_shader.uniform_int("color_space_mode", color_space_mode) batch.draw(image_shader) except Exception: pass return batch def path_to_gpu_texture(path: str) -> Optional[gpu.types.GPUTexture]: """Convert a Blender image to a GPU texture. Returns: The GPU texture if successful, or None if the image is invalid. """ # check if exists and is file [prevent exception for missing files] if path in cached_gpu_textures: return cached_gpu_textures[path] if not os.path.exists(path) or not os.path.isfile(path): # do not spam log with warnings, just return None return None img = bpy.data.images.load(path, check_existing=False) img.gl_load() tex = gpu.texture.from_image(img) cached_gpu_textures[path] = tex # # Clean up Blender image bpy.data.images.remove(img) return tex def get_text_size( font_id: int = 0, text: str = "", text_size: float = 16, dpi: int = 72 ): if app.version < (4, 0, 0): blf.size(font_id, text_size, dpi) else: blf.size(font_id, text_size) return blf.dimensions(font_id, text) def draw_text(text, x, y, size, color=(1, 1, 1, 0.5), halign="LEFT", valign="TOP"): font_id = 1 if type(text) != str: text = str(text) blf.color(font_id, color[0], color[1], color[2], color[3]) if app.version < (4, 0, 0): blf.size(font_id, size, 72) else: blf.size(font_id, size) if halign != "LEFT": width, height = blf.dimensions(font_id, text) if halign == "RIGHT": x -= width elif halign == "CENTER": x -= width // 2 if valign == "CENTER": y -= height // 2 # bottom could be here but there's no reason for it blf.position(font_id, x, y, 0) blf.draw(font_id, text)