|
| 1 | +# Copyright (C) 2023 Daniel Boxer |
| 2 | + |
| 3 | +import bpy |
| 4 | +from mathutils import Vector, Matrix, geometry |
| 5 | +from bpy_extras import view3d_utils |
| 6 | +from . import line_draw |
| 7 | + |
| 8 | + |
| 9 | +class POLYBLOCKER_OT_quick_mirror(bpy.types.Operator): |
| 10 | + bl_idname = "polyblocker.quick_mirror" |
| 11 | + bl_label = "Quick Mirror" |
| 12 | + bl_description = "Quick mirror" |
| 13 | + bl_options = {"UNDO", "BLOCKING"} |
| 14 | + |
| 15 | + axis_map = {0: "X", 1: "Y", 2: "Z"} |
| 16 | + |
| 17 | + @classmethod |
| 18 | + def poll(cls, context): |
| 19 | + obj = context.object |
| 20 | + return obj is not None and obj.type == "MESH" |
| 21 | + |
| 22 | + def invoke(self, context, event): |
| 23 | + self.input = [] |
| 24 | + self.mirror_objs = [] |
| 25 | + self.hover_axis = None |
| 26 | + self.holding = False |
| 27 | + self.empty = None |
| 28 | + |
| 29 | + prefs = context.preferences.addons[__package__].preferences |
| 30 | + selected = context.selected_objects |
| 31 | + target = context.object |
| 32 | + is_single_obj = len(selected) == 1 |
| 33 | + is_no_target = not context.object in selected |
| 34 | + if is_single_obj or is_no_target: |
| 35 | + if prefs.origin_method == "EMPTY": |
| 36 | + # make empty for mirror target |
| 37 | + self.empty = bpy.data.objects.new("Quick Mirror", None) |
| 38 | + target = self.empty |
| 39 | + context.scene.collection.objects.link(target) |
| 40 | + else: |
| 41 | + target = None |
| 42 | + for obj in selected: |
| 43 | + # set origin to 0 |
| 44 | + mw = obj.matrix_world |
| 45 | + obj.data.transform(Matrix.Translation(-(mw.inverted() @ Vector()))) |
| 46 | + mw.translation -= mw.translation |
| 47 | + |
| 48 | + # setup mirror mod |
| 49 | + for obj in selected: |
| 50 | + if target != obj or is_single_obj: |
| 51 | + m = obj.modifiers.new("Mirror", "MIRROR") |
| 52 | + m.use_axis[0] = False |
| 53 | + m.mirror_object = target |
| 54 | + self.mirror_objs.append({"obj": obj, "mod": m}) |
| 55 | + |
| 56 | + # choose position of axis guide |
| 57 | + center_objs = [target] |
| 58 | + if is_single_obj: |
| 59 | + center_objs = [selected[0]] |
| 60 | + elif is_no_target: |
| 61 | + center_objs = selected |
| 62 | + center_sum = Vector() |
| 63 | + for obj in center_objs: |
| 64 | + local_center = sum((Vector(co) for co in obj.bound_box), Vector()) / 8 |
| 65 | + center_sum += obj.matrix_world @ local_center |
| 66 | + center = center_sum / len(center_objs) |
| 67 | + |
| 68 | + self.axis_lines = [None] * 3 |
| 69 | + self.axis_lines_2d = [None] * 3 |
| 70 | + for axis in range(3): |
| 71 | + # get point offset from center |
| 72 | + def axis_point(dist, direction): |
| 73 | + p = center.copy() |
| 74 | + p[axis] += dist * direction |
| 75 | + return p |
| 76 | + |
| 77 | + # 10000 or any large number works |
| 78 | + self.axis_lines[axis] = (axis_point(10000, 1), axis_point(10000, -1)) |
| 79 | + |
| 80 | + r = context.region |
| 81 | + r3d = context.space_data.region_3d |
| 82 | + # use axis of length 1 for 2d to fit in viewport better |
| 83 | + l1_2d = view3d_utils.location_3d_to_region_2d(r, r3d, axis_point(1, 1)) |
| 84 | + l2_2d = view3d_utils.location_3d_to_region_2d(r, r3d, axis_point(1, -1)) |
| 85 | + if l1_2d is None or l2_2d is None: |
| 86 | + self.report({"ERROR"}, "Selected object is not visible") |
| 87 | + self.finish(context, revert=True) |
| 88 | + return {"CANCELLED"} |
| 89 | + self.axis_lines_2d[axis] = (l1_2d, l2_2d) |
| 90 | + |
| 91 | + a_str = self.axis_map[axis] |
| 92 | + line_draw.draw_axis(a_str, line_draw.COLOURS[a_str], self.axis_lines[axis]) |
| 93 | + |
| 94 | + # redraw fixes bug with single obj |
| 95 | + self.redraw_v3d(context) |
| 96 | + context.window.cursor_modal_set("SCROLL_XY") |
| 97 | + context.area.header_text_set(f"Axes: [ ]") |
| 98 | + context.workspace.status_text_set( |
| 99 | + "Left Click/Hold: Select Axes and Confirm Right Click/Esc: Cancel" |
| 100 | + " Scroll Up: Remove Axis" |
| 101 | + ) |
| 102 | + context.window_manager.modal_handler_add(self) |
| 103 | + return {"RUNNING_MODAL"} |
| 104 | + |
| 105 | + def modal(self, context, event): |
| 106 | + try: |
| 107 | + if event.type == "MOUSEMOVE": |
| 108 | + m_pos = Vector((event.mouse_region_x, event.mouse_region_y)) |
| 109 | + |
| 110 | + # find closest axis to mouse |
| 111 | + min_dist = float("inf") |
| 112 | + axis = None |
| 113 | + for a in range(3): |
| 114 | + l1, l2 = self.axis_lines_2d[a] |
| 115 | + inter = geometry.intersect_point_line(m_pos, l1, l2)[0] - m_pos |
| 116 | + dist = inter.length |
| 117 | + if dist < min_dist: |
| 118 | + min_dist = dist |
| 119 | + axis = self.axis_map[a] |
| 120 | + |
| 121 | + old_axis = self.hover_axis |
| 122 | + if old_axis != axis: |
| 123 | + # remove mirror preview or if dragging backwards |
| 124 | + if not self.holding or axis in self.input: |
| 125 | + self.remove(context) |
| 126 | + |
| 127 | + line_draw.draw_axis(axis, (1, 1, 1, 1)) |
| 128 | + self.redraw_v3d(context) |
| 129 | + self.add(context, axis) |
| 130 | + self.hover_axis = axis |
| 131 | + |
| 132 | + elif event.type == "WHEELDOWNMOUSE" and event.value == "PRESS": |
| 133 | + self.remove(context) |
| 134 | + elif event.type == "LEFTMOUSE" and event.value == "PRESS": |
| 135 | + self.holding = True |
| 136 | + elif event.type == "LEFTMOUSE" and event.value == "RELEASE": |
| 137 | + self.finish(context) |
| 138 | + return {"FINISHED"} |
| 139 | + elif event.type in {"RIGHTMOUSE", "ESC"}: |
| 140 | + self.finish(context, revert=True) |
| 141 | + return {"FINISHED"} |
| 142 | + except Exception as e: |
| 143 | + self.report({"ERROR"}, f"Error: {str(e)}") |
| 144 | + self.finish(context, revert=True) |
| 145 | + return {"CANCELLED"} |
| 146 | + return {"RUNNING_MODAL"} |
| 147 | + |
| 148 | + def update(self, context): |
| 149 | + axes = [ |
| 150 | + True if "X" in self.input else False, |
| 151 | + True if "Y" in self.input else False, |
| 152 | + True if "Z" in self.input else False, |
| 153 | + ] |
| 154 | + for m_obj in self.mirror_objs: |
| 155 | + m_obj["mod"].use_axis = axes |
| 156 | + s = f"{'X' if axes[0] else ''}{'Y' if axes[1] else ''}{'Z' if axes[2] else ''}" |
| 157 | + context.area.header_text_set(f"Axes: [ {s} ]") |
| 158 | + |
| 159 | + def redraw_v3d(self, context): |
| 160 | + context.view_layer.objects.active = context.view_layer.objects.active |
| 161 | + |
| 162 | + def add(self, context, axis): |
| 163 | + if axis not in self.input: |
| 164 | + self.input.append(axis) |
| 165 | + self.update(context) |
| 166 | + |
| 167 | + def remove(self, context): |
| 168 | + if len(self.input) > 0: |
| 169 | + axis = self.input.pop() |
| 170 | + # set old axis colour |
| 171 | + line_draw.draw_axis(axis, line_draw.COLOURS[axis]) |
| 172 | + self.update(context) |
| 173 | + |
| 174 | + def finish(self, context, revert=False): |
| 175 | + if revert: |
| 176 | + for m_obj in self.mirror_objs: |
| 177 | + m_obj["obj"].modifiers.remove(m_obj["mod"]) |
| 178 | + if self.empty is not None: |
| 179 | + bpy.data.objects.remove(self.empty, do_unlink=True) |
| 180 | + context.area.header_text_set(None) |
| 181 | + context.workspace.status_text_set(None) |
| 182 | + context.window.cursor_modal_restore() |
| 183 | + for axis in range(3): |
| 184 | + line_draw.remove(self.axis_map[axis]) |
| 185 | + self.redraw_v3d(context) |
0 commit comments