# SPDX-License-Identifier: GPL-2.0-or-later __author__ = "Nutti " __status__ = "production" __version__ = "6.6" __date__ = "22 Apr 2022" from enum import IntEnum import math import bpy import mathutils import bmesh from bpy.props import BoolProperty, EnumProperty from .. import common from ..utils.bl_class_registry import BlClassRegistry from ..utils.property_class_registry import PropertyClassRegistry from ..utils import compatibility as compat if compat.check_version(2, 80, 0) >= 0: from ..lib import bglx as bgl else: import bgl MAX_VALUE = 100000.0 def _is_valid_context(context): # 'IMAGE_EDITOR' and 'VIEW_3D' space is allowed to execute. # If 'View_3D' space is not allowed, you can't find option in Tool-Shelf # after the execution if not common.is_valid_space(context, ['IMAGE_EDITOR', 'VIEW_3D']): return False obj = context.object # only edit mode is allowed to execute if obj is None: return False if obj.type != 'MESH': return False if context.object.mode != 'EDIT': return False return True @PropertyClassRegistry() class _Properties: idname = "uv_bounding_box" @classmethod def init_props(cls, scene): class Props(): uv_info_ini = [] ctrl_points_ini = [] ctrl_points = [] scene.muv_props.uv_bounding_box = Props() def get_func(_): return MUV_OT_UVBoundingBox.is_running(bpy.context) def set_func(_, __): pass def update_func(_, __): bpy.ops.uv.muv_uv_bounding_box('INVOKE_REGION_WIN') scene.muv_uv_bounding_box_enabled = BoolProperty( name="UV Bounding Box Enabled", description="UV Bounding Box is enabled", default=False ) scene.muv_uv_bounding_box_show = BoolProperty( name="UV Bounding Box Showed", description="UV Bounding Box is showed", default=False, get=get_func, set=set_func, update=update_func ) scene.muv_uv_bounding_box_uniform_scaling = BoolProperty( name="Uniform Scaling", description="Enable Uniform Scaling", default=False ) scene.muv_uv_bounding_box_boundary = EnumProperty( name="Boundary", description="Boundary", default='UV_SEL', items=[ ('UV', "UV", "Boundary is decided by UV"), ('UV_SEL', "UV (Selected)", "Boundary is decided by Selected UV") ] ) @classmethod def del_props(cls, scene): del scene.muv_props.uv_bounding_box del scene.muv_uv_bounding_box_enabled del scene.muv_uv_bounding_box_show del scene.muv_uv_bounding_box_uniform_scaling del scene.muv_uv_bounding_box_boundary class CommandBase: """ Custom class: Base class of command """ def __init__(self): self.op = 'NONE' # operation def to_matrix(self): # mat = I mat = mathutils.Matrix() mat.identity() return mat class TranslationCommand(CommandBase): """ Custom class: Translation operation """ def __init__(self, ix, iy): super().__init__() self.op = 'TRANSLATION' self.__x = ix # current x self.__y = iy # current y self.__ix = ix # initial x self.__iy = iy # initial y def to_matrix(self): # mat = Mt dx = self.__x - self.__ix dy = self.__y - self.__iy return mathutils.Matrix.Translation((dx, dy, 0)) def set(self, x, y): self.__x = x self.__y = y class RotationCommand(CommandBase): """ Custom class: Rotation operation """ def __init__(self, ix, iy, cx, cy): super().__init__() self.op = 'ROTATION' self.__x = ix # current x self.__y = iy # current y self.__cx = cx # center of rotation x self.__cy = cy # center of rotation y dx = self.__x - self.__cx dy = self.__y - self.__cy self.__iangle = math.atan2(dy, dx) # initial rotation angle def to_matrix(self): # mat = Mt * Mr * Mt^-1 dx = self.__x - self.__cx dy = self.__y - self.__cy angle = math.atan2(dy, dx) - self.__iangle mti = mathutils.Matrix.Translation((-self.__cx, -self.__cy, 0.0)) mr = mathutils.Matrix.Rotation(angle, 4, 'Z') mt = mathutils.Matrix.Translation((self.__cx, self.__cy, 0.0)) return compat.matmul(compat.matmul(mt, mr), mti) def set(self, x, y): self.__x = x self.__y = y class ScalingCommand(CommandBase): """ Custom class: Scaling operation """ def __init__(self, ix, iy, ox, oy, dir_x, dir_y, mat): super().__init__() self.op = 'SCALING' self.__ix = ix # initial x self.__iy = iy # initial y self.__x = ix # current x self.__y = iy # current y self.__ox = ox # origin of scaling x self.__oy = oy # origin of scaling y self.__dir_x = dir_x # direction of scaling x self.__dir_y = dir_y # direction of scaling y self.__mat = mat # initial origin of scaling = M(to original transform) * (ox, oy) iov = compat.matmul(mat, mathutils.Vector((ox, oy, 0.0))) self.__iox = iov.x # initial origin of scaling X self.__ioy = iov.y # initial origin of scaling y def to_matrix(self): """ mat = M(to original transform)^-1 * Mt(to origin) * Ms * Mt(to origin)^-1 * M(to original transform) """ m = self.__mat mi = self.__mat.inverted() mtoi = mathutils.Matrix.Translation((-self.__iox, -self.__ioy, 0.0)) mto = mathutils.Matrix.Translation((self.__iox, self.__ioy, 0.0)) # every point must be transformed to origin t = compat.matmul(m, mathutils.Vector((self.__ix, self.__iy, 0.0))) tix, tiy = t.x, t.y t = compat.matmul(m, mathutils.Vector((self.__ox, self.__oy, 0.0))) tox, toy = t.x, t.y t = compat.matmul(m, mathutils.Vector((self.__x, self.__y, 0.0))) tx, ty = t.x, t.y ms = mathutils.Matrix() ms.identity() if self.__dir_x == 1: ms[0][0] = (tx - tox) * self.__dir_x / (tix - tox) if self.__dir_y == 1: ms[1][1] = (ty - toy) * self.__dir_y / (tiy - toy) return compat.matmul(compat.matmul(compat.matmul( compat.matmul(mi, mto), ms), mtoi), m) def set(self, x, y): self.__x = x self.__y = y class UniformScalingCommand(CommandBase): """ Custom class: Uniform Scaling operation """ def __init__(self, ix, iy, ox, oy, mat): super().__init__() self.op = 'SCALING' self.__ix = ix # initial x self.__iy = iy # initial y self.__x = ix # current x self.__y = iy # current y self.__ox = ox # origin of scaling x self.__oy = oy # origin of scaling y self.__mat = mat # initial origin of scaling = M(to original transform) * (ox, oy) iov = compat.matmul(mat, mathutils.Vector((ox, oy, 0.0))) self.__iox = iov.x # initial origin of scaling x self.__ioy = iov.y # initial origin of scaling y self.__dir_x = 1 self.__dir_y = 1 def to_matrix(self): """ mat = M(to original transform)^-1 * Mt(to origin) * Ms * Mt(to origin)^-1 * M(to original transform) """ m = self.__mat mi = self.__mat.inverted() mtoi = mathutils.Matrix.Translation((-self.__iox, -self.__ioy, 0.0)) mto = mathutils.Matrix.Translation((self.__iox, self.__ioy, 0.0)) # every point must be transformed to origin t = compat.matmul(m, mathutils.Vector((self.__ix, self.__iy, 0.0))) tix, tiy = t.x, t.y t = compat.matmul(m, mathutils.Vector((self.__ox, self.__oy, 0.0))) tox, toy = t.x, t.y t = compat.matmul(m, mathutils.Vector((self.__x, self.__y, 0.0))) tx, ty = t.x, t.y ms = mathutils.Matrix() ms.identity() tir = math.sqrt((tix - tox) * (tix - tox) + (tiy - toy) * (tiy - toy)) tr = math.sqrt((tx - tox) * (tx - tox) + (ty - toy) * (ty - toy)) sr = tr / tir if ((tx - tox) * (tix - tox)) > 0: self.__dir_x = 1 else: self.__dir_x = -1 if ((ty - toy) * (tiy - toy)) > 0: self.__dir_y = 1 else: self.__dir_y = -1 ms[0][0] = sr * self.__dir_x ms[1][1] = sr * self.__dir_y return compat.matmul(compat.matmul(compat.matmul( compat.matmul(mi, mto), ms), mtoi), m) def set(self, x, y): self.__x = x self.__y = y class CommandExecuter: """ Custom class: manage command history and execute command """ def __init__(self): self.__cmd_list = [] # history self.__cmd_list_redo = [] # redo list def execute(self, begin=0, end=-1): """ create matrix from history """ mat = mathutils.Matrix() mat.identity() for i, cmd in enumerate(self.__cmd_list): if begin <= i and (end == -1 or i <= end): mat = compat.matmul(cmd.to_matrix(), mat) return mat def undo_size(self): """ get history size """ return len(self.__cmd_list) def top(self): """ get top of history """ if len(self.__cmd_list) <= 0: return None return self.__cmd_list[-1] def append(self, cmd): """ append command """ self.__cmd_list.append(cmd) self.__cmd_list_redo = [] def undo(self): """ undo command """ if len(self.__cmd_list) <= 0: return self.__cmd_list_redo.append(self.__cmd_list.pop()) def redo(self): """ redo command """ if len(self.__cmd_list_redo) <= 0: return self.__cmd_list.append(self.__cmd_list_redo.pop()) def pop(self): if len(self.__cmd_list) <= 0: return None return self.__cmd_list.pop() def push(self, cmd): self.__cmd_list.append(cmd) class State(IntEnum): """ Enum: State definition used by MUV_UVBBStateMgr """ NONE = 0 TRANSLATING = 1 SCALING_1 = 2 SCALING_2 = 3 SCALING_3 = 4 SCALING_4 = 5 SCALING_5 = 6 SCALING_6 = 7 SCALING_7 = 8 SCALING_8 = 9 ROTATING = 10 UNIFORM_SCALING_1 = 11 UNIFORM_SCALING_2 = 12 UNIFORM_SCALING_3 = 13 UNIFORM_SCALING_4 = 14 class StateBase: """ Custom class: Base class of state """ def __init__(self): pass def update(self, context, event, ctrl_points, mouse_view): raise NotImplementedError class StateNone(StateBase): """ Custom class: No state Wait for event from mouse """ def __init__(self, cmd_exec): super().__init__() self.__cmd_exec = cmd_exec def update(self, context, event, ctrl_points, mouse_view): """ Update state """ user_prefs = compat.get_user_preferences(context) prefs = user_prefs.addons["magic_uv"].preferences cp_react_size = prefs.uv_bounding_box_cp_react_size is_uscaling = context.scene.muv_uv_bounding_box_uniform_scaling if (event.type == 'LEFTMOUSE') and (event.value == 'PRESS'): x, y = context.region.view2d.view_to_region( mouse_view.x, mouse_view.y) for i, p in enumerate(ctrl_points): px, py = context.region.view2d.view_to_region(p.x, p.y) in_cp_x = px - cp_react_size < x < px + cp_react_size in_cp_y = py - cp_react_size < y < py + cp_react_size if in_cp_x and in_cp_y: if is_uscaling: arr = [1, 3, 6, 8] if i in arr: return ( State.UNIFORM_SCALING_1 + arr.index(i) ) else: return State.TRANSLATING + i return State.NONE class StateTranslating(StateBase): """ Custom class: Translating state """ def __init__(self, cmd_exec, ctrl_points): super().__init__() self.__cmd_exec = cmd_exec ix, iy = ctrl_points[0].x, ctrl_points[0].y self.__cmd_exec.append(TranslationCommand(ix, iy)) def update(self, context, event, ctrl_points, mouse_view): if event.type == 'LEFTMOUSE': if event.value == 'RELEASE': return State.NONE if event.type == 'MOUSEMOVE': x, y = mouse_view.x, mouse_view.y self.__cmd_exec.top().set(x, y) return State.TRANSLATING class StateScaling(StateBase): """ Custom class: Scaling state """ def __init__(self, cmd_exec, state, ctrl_points): super().__init__() self.__state = state self.__cmd_exec = cmd_exec dir_x_list = [1, 1, 1, 0, 0, 1, 1, 1] dir_y_list = [1, 0, 1, 1, 1, 1, 0, 1] idx = state - 2 ix, iy = ctrl_points[idx + 1].x, ctrl_points[idx + 1].y ox, oy = ctrl_points[8 - idx].x, ctrl_points[8 - idx].y dir_x, dir_y = dir_x_list[idx], dir_y_list[idx] mat = self.__cmd_exec.execute(end=self.__cmd_exec.undo_size()) self.__cmd_exec.append( ScalingCommand(ix, iy, ox, oy, dir_x, dir_y, mat.inverted())) def update(self, context, event, ctrl_points, mouse_view): if event.type == 'LEFTMOUSE': if event.value == 'RELEASE': return State.NONE if event.type == 'MOUSEMOVE': x, y = mouse_view.x, mouse_view.y self.__cmd_exec.top().set(x, y) return self.__state class StateUniformScaling(StateBase): """ Custom class: Uniform Scaling state """ def __init__(self, cmd_exec, state, ctrl_points): super().__init__() self.__state = state self.__cmd_exec = cmd_exec icp_idx = [1, 3, 6, 8] ocp_idx = [8, 6, 3, 1] idx = state - State.UNIFORM_SCALING_1 ix, iy = ctrl_points[icp_idx[idx]].x, ctrl_points[icp_idx[idx]].y ox, oy = ctrl_points[ocp_idx[idx]].x, ctrl_points[ocp_idx[idx]].y mat = self.__cmd_exec.execute(end=self.__cmd_exec.undo_size()) self.__cmd_exec.append(UniformScalingCommand( ix, iy, ox, oy, mat.inverted())) def update(self, context, event, ctrl_points, mouse_view): if event.type == 'LEFTMOUSE': if event.value == 'RELEASE': return State.NONE if event.type == 'MOUSEMOVE': x, y = mouse_view.x, mouse_view.y self.__cmd_exec.top().set(x, y) return self.__state class StateRotating(StateBase): """ Custom class: Rotating state """ def __init__(self, cmd_exec, ctrl_points): super().__init__() self.__cmd_exec = cmd_exec ix, iy = ctrl_points[9].x, ctrl_points[9].y ox, oy = ctrl_points[0].x, ctrl_points[0].y self.__cmd_exec.append(RotationCommand(ix, iy, ox, oy)) def update(self, context, event, ctrl_points, mouse_view): if event.type == 'LEFTMOUSE': if event.value == 'RELEASE': return State.NONE if event.type == 'MOUSEMOVE': x, y = mouse_view.x, mouse_view.y self.__cmd_exec.top().set(x, y) return State.ROTATING class StateManager: """ Custom class: Manage state about this feature """ def __init__(self, cmd_exec): self.__cmd_exec = cmd_exec # command executer self.__state = State.NONE # current state self.__state_obj = StateNone(self.__cmd_exec) def __update_state(self, next_state, ctrl_points): """ Update state """ if next_state == self.__state: return obj = None if next_state == State.TRANSLATING: obj = StateTranslating(self.__cmd_exec, ctrl_points) elif State.SCALING_1 <= next_state <= State.SCALING_8: obj = StateScaling( self.__cmd_exec, next_state, ctrl_points) elif next_state == State.ROTATING: obj = StateRotating(self.__cmd_exec, ctrl_points) elif next_state == State.NONE: obj = StateNone(self.__cmd_exec) elif (State.UNIFORM_SCALING_1 <= next_state <= State.UNIFORM_SCALING_4): obj = StateUniformScaling( self.__cmd_exec, next_state, ctrl_points) if obj is not None: self.__state_obj = obj self.__state = next_state def update(self, context, ctrl_points, event): mouse_region = mathutils.Vector(( event.mouse_region_x, event.mouse_region_y)) mouse_view = mathutils.Vector((context.region.view2d.region_to_view( mouse_region.x, mouse_region.y))) next_state = self.__state_obj.update( context, event, ctrl_points, mouse_view) self.__update_state(next_state, ctrl_points) return self.__state @BlClassRegistry() class MUV_OT_UVBoundingBox(bpy.types.Operator): """ Operation class: UV Bounding Box """ bl_idname = "uv.muv_uv_bounding_box" bl_label = "UV Bounding Box" bl_description = "Internal operation for UV Bounding Box" bl_options = {'REGISTER', 'UNDO'} def __init__(self): self.__timer = None self.__cmd_exec = CommandExecuter() # Command executor self.__state_mgr = StateManager(self.__cmd_exec) # State Manager __handle = None __timer = None @classmethod def poll(cls, context): # we can not get area/space/region from console if common.is_console_mode(): return False return _is_valid_context(context) @classmethod def is_running(cls, _): return 1 if cls.__handle else 0 @classmethod def handle_add(cls, obj, context): if cls.__handle is None: sie = bpy.types.SpaceImageEditor cls.__handle = sie.draw_handler_add( cls.draw_bb, (obj, context), "WINDOW", "POST_PIXEL") if cls.__timer is None: cls.__timer = context.window_manager.event_timer_add( 0.1, window=context.window) context.window_manager.modal_handler_add(obj) @classmethod def handle_remove(cls, context): if cls.__handle is not None: sie = bpy.types.SpaceImageEditor sie.draw_handler_remove(cls.__handle, "WINDOW") cls.__handle = None if cls.__timer is not None: context.window_manager.event_timer_remove(cls.__timer) cls.__timer = None @classmethod def __draw_ctrl_point(cls, context, pos): """ Draw control point """ user_prefs = compat.get_user_preferences(context) prefs = user_prefs.addons["magic_uv"].preferences cp_size = prefs.uv_bounding_box_cp_size offset = cp_size / 2 verts = [ [pos.x - offset, pos.y - offset], [pos.x - offset, pos.y + offset], [pos.x + offset, pos.y + offset], [pos.x + offset, pos.y - offset] ] bgl.glEnable(bgl.GL_BLEND) bgl.glBegin(bgl.GL_QUADS) bgl.glColor4f(1.0, 1.0, 1.0, 1.0) for (x, y) in verts: bgl.glVertex2f(x, y) bgl.glEnd() @classmethod def draw_bb(cls, _, context): """ Draw bounding box """ props = context.scene.muv_props.uv_bounding_box if not MUV_OT_UVBoundingBox.is_running(context): return if not _is_valid_context(context): return for cp in props.ctrl_points: cls.__draw_ctrl_point( context, mathutils.Vector( context.region.view2d.view_to_region(cp.x, cp.y))) def __get_uv_info(self, context): """ Get UV coordinate """ sc = context.scene objs = common.get_uv_editable_objects(context) uv_info = [] for obj in objs: bm = bmesh.from_edit_mesh(obj.data) if common.check_version(2, 73, 0) >= 0: bm.faces.ensure_lookup_table() if not bm.loops.layers.uv: continue uv_layer = bm.loops.layers.uv.verify() for f in bm.faces: if not f.select: continue for i, l in enumerate(f.loops): if sc.muv_uv_bounding_box_boundary == 'UV_SEL': if l[uv_layer].select: uv_info.append({ "bmesh": bm, "fidx": f.index, "lidx": i, "uv": l[uv_layer].uv.copy() }) elif sc.muv_uv_bounding_box_boundary == 'UV': uv_info.append({ "bmesh": bm, "fidx": f.index, "lidx": i, "uv": l[uv_layer].uv.copy() }) if not uv_info: return None return uv_info def __get_ctrl_point(self, uv_info_ini): """ Get control point """ left = MAX_VALUE right = -MAX_VALUE top = -MAX_VALUE bottom = MAX_VALUE for info in uv_info_ini: uv = info["uv"] if uv.x < left: left = uv.x if uv.x > right: right = uv.x if uv.y < bottom: bottom = uv.y if uv.y > top: top = uv.y points = [ mathutils.Vector(( (left + right) * 0.5, (top + bottom) * 0.5, 0.0 )), mathutils.Vector((left, top, 0.0)), mathutils.Vector((left, (top + bottom) * 0.5, 0.0)), mathutils.Vector((left, bottom, 0.0)), mathutils.Vector(((left + right) * 0.5, top, 0.0)), mathutils.Vector(((left + right) * 0.5, bottom, 0.0)), mathutils.Vector((right, top, 0.0)), mathutils.Vector((right, (top + bottom) * 0.5, 0.0)), mathutils.Vector((right, bottom, 0.0)), mathutils.Vector(((left + right) * 0.5, top + 0.03, 0.0)) ] return points def __update_uvs(self, context, uv_info_ini, trans_mat): """ Update UV coordinate """ for info in uv_info_ini: bm = info["bmesh"] uv_layer = bm.loops.layers.uv.verify() fidx = info["fidx"] lidx = info["lidx"] uv = info["uv"] v = mathutils.Vector((uv.x, uv.y, 0.0)) av = compat.matmul(trans_mat, v) bm.faces[fidx].loops[lidx][uv_layer].uv = mathutils.Vector( (av.x, av.y)) objs = common.get_uv_editable_objects(context) for obj in objs: bmesh.update_edit_mesh(obj.data) def __update_ctrl_point(self, ctrl_points_ini, trans_mat): """ Update control point """ return [compat.matmul(trans_mat, cp) for cp in ctrl_points_ini] def modal(self, context, event): props = context.scene.muv_props.uv_bounding_box common.redraw_all_areas() if not MUV_OT_UVBoundingBox.is_running(context): return {'FINISHED'} if not _is_valid_context(context): MUV_OT_UVBoundingBox.handle_remove(context) return {'FINISHED'} region_types = [ 'HEADER', 'UI', 'TOOLS', ] if not common.mouse_on_area(event, 'IMAGE_EDITOR') or \ common.mouse_on_regions(event, 'IMAGE_EDITOR', region_types): return {'PASS_THROUGH'} if event.type == 'TIMER': trans_mat = self.__cmd_exec.execute() self.__update_uvs(context, props.uv_info_ini, trans_mat) props.ctrl_points = self.__update_ctrl_point( props.ctrl_points_ini, trans_mat) state = self.__state_mgr.update(context, props.ctrl_points, event) if state == State.NONE: return {'PASS_THROUGH'} return {'RUNNING_MODAL'} def invoke(self, context, _): props = context.scene.muv_props.uv_bounding_box if MUV_OT_UVBoundingBox.is_running(context): MUV_OT_UVBoundingBox.handle_remove(context) return {'FINISHED'} props.uv_info_ini = self.__get_uv_info(context) if props.uv_info_ini is None: return {'CANCELLED'} MUV_OT_UVBoundingBox.handle_add(self, context) props.ctrl_points_ini = self.__get_ctrl_point(props.uv_info_ini) trans_mat = self.__cmd_exec.execute() # Update is needed in order to display control point self.__update_uvs(context, props.uv_info_ini, trans_mat) props.ctrl_points = self.__update_ctrl_point( props.ctrl_points_ini, trans_mat) return {'RUNNING_MODAL'}