GPencil Tools: Optimize Undo for Rotate Canvas
[blender-addons.git] / greasepencil_tools / rotate_canvas.py
blob3a45f6b3379ed55297906234b900fece3c044837
1 # SPDX-FileCopyrightText: 2020-2023 Blender Foundation
3 # SPDX-License-Identifier: GPL-2.0-or-later
5 from .prefs import get_addon_prefs
7 import bpy
8 import math
9 import mathutils
10 from bpy_extras.view3d_utils import location_3d_to_region_2d
11 from time import time
12 ## draw utils
13 import gpu
14 import blf
15 from gpu_extras.batch import batch_for_shader
17 def step_value(value, step):
18 '''return the step closer to the passed value'''
19 abs_angle = abs(value)
20 diff = abs_angle % step
21 lower_step = abs_angle - diff
22 higher_step = lower_step + step
23 if abs_angle - lower_step < higher_step - abs_angle:
24 return math.copysign(lower_step, value)
25 else:
26 return math.copysign(higher_step, value)
28 def draw_callback_px(self, context):
29 # 50% alpha, 2 pixel width line
30 if context.area != self.current_area:
31 return
32 shader = gpu.shader.from_builtin('UNIFORM_COLOR')
33 gpu.state.blend_set('ALPHA')
34 gpu.state.line_width_set(2.0)
36 # init
37 batch = batch_for_shader(shader, 'LINE_STRIP', {"pos": [self.center, self.initial_pos]})#self.vector_initial
38 shader.bind()
39 shader.uniform_float("color", (0.5, 0.5, 0.8, 0.6))
40 batch.draw(shader)
42 batch = batch_for_shader(shader, 'LINE_STRIP', {"pos": [self.center, self.pos_current]})
43 shader.bind()
44 shader.uniform_float("color", (0.3, 0.7, 0.2, 0.5))
45 batch.draw(shader)
47 ## debug lines
48 # batch = batch_for_shader(shader, 'LINES', {"pos": [
49 # (0,0), (context.area.width, context.area.height),
50 # (context.area.width, 0), (0, context.area.height)
51 # ]})
52 # shader.bind()
53 # shader.uniform_float("color", (0.8, 0.1, 0.1, 0.5))
54 # batch.draw(shader)
56 # restore opengl defaults
57 gpu.state.line_width_set(1.0)
58 gpu.state.blend_set('NONE')
60 ## text
61 font_id = 0
62 ## draw text debug infos
63 blf.position(font_id, 15, 30, 0)
64 blf.size(font_id, 20.0)
65 blf.draw(font_id, f'angle: {math.degrees(self.angle):.1f}')
68 class RC_OT_RotateCanvas(bpy.types.Operator):
69 bl_idname = 'view3d.rotate_canvas'
70 bl_label = 'Rotate Canvas'
71 bl_options = {"REGISTER"}
73 def get_center_view(self, context, cam):
74 '''
75 https://blender.stackexchange.com/questions/6377/coordinates-of-corners-of-camera-view-border
76 Thanks to ideasman42
77 '''
79 frame = cam.data.view_frame()
80 mat = cam.matrix_world
81 frame = [mat @ v for v in frame]
82 frame_px = [location_3d_to_region_2d(context.region, context.space_data.region_3d, v) for v in frame]
83 center_x = frame_px[2].x + (frame_px[0].x - frame_px[2].x)/2
84 center_y = frame_px[1].y + (frame_px[0].y - frame_px[1].y)/2
86 return mathutils.Vector((center_x, center_y))
88 def set_cam_view_offset_from_angle(self, context, angle):
89 '''apply inverse of the rotation on view offset in cam rotate from view center'''
90 neg = -angle
91 rot_mat2d = mathutils.Matrix([[math.cos(neg), -math.sin(neg)], [math.sin(neg), math.cos(neg)]])
93 # scale_mat = mathutils.Matrix([[1.0, 0.0], [0.0, self.ratio]])
94 new_cam_offset = self.view_cam_offset.copy()
96 ## area deformation correction
97 new_cam_offset = mathutils.Vector((new_cam_offset[0], new_cam_offset[1] * self.ratio))
99 ## rotate by matrix
100 new_cam_offset.rotate(rot_mat2d)
102 ## area deformation restore
103 new_cam_offset = mathutils.Vector((new_cam_offset[0], new_cam_offset[1] * self.ratio_inv))
105 context.space_data.region_3d.view_camera_offset = new_cam_offset
107 def execute(self, context):
108 if self.hud:
109 bpy.types.SpaceView3D.draw_handler_remove(self._handle, 'WINDOW')
110 context.area.tag_redraw()
111 if self.in_cam:
112 self.cam.rotation_mode = self.org_rotation_mode
113 # Undo step is only needed if used within camera
114 bpy.ops.ed.undo_push(message='Rotate Canvas')
115 return {'FINISHED'}
118 def modal(self, context, event):
119 if event.type in {'MOUSEMOVE'}:#,'INBETWEEN_MOUSEMOVE'
120 # Get current mouse coordination (region)
121 self.pos_current = mathutils.Vector((event.mouse_region_x, event.mouse_region_y))
122 # Get current vector
123 self.vector_current = (self.pos_current - self.center).normalized()
124 # Calculates the angle between initial and current vectors
125 self.angle = self.vector_initial.angle_signed(self.vector_current)#radian
126 # print (math.degrees(self.angle), self.vector_initial, self.vector_current)
129 ## handle snap key
130 snap = False
131 if self.snap_ctrl and event.ctrl:
132 snap = True
133 if self.snap_shift and event.shift:
134 snap = True
135 if self.snap_alt and event.alt:
136 snap = True
137 ## Snapping to specific degrees angle
138 if snap:
139 self.angle = step_value(self.angle, self.snap_step)
141 if self.in_cam:
142 self.cam.matrix_world = self.cam_matrix
143 self.cam.rotation_euler.rotate_axis("Z", self.angle)
145 if self.use_view_center:
146 ## apply inverse rotation on view offset
147 self.set_cam_view_offset_from_angle(context, self.angle)
149 else: # free view
150 context.space_data.region_3d.view_rotation = self._rotation
151 rot = context.space_data.region_3d.view_rotation
152 rot = rot.to_euler()
153 rot.rotate_axis("Z", self.angle)
154 context.space_data.region_3d.view_rotation = rot.to_quaternion()
156 if event.type in {'RIGHTMOUSE', 'LEFTMOUSE', 'MIDDLEMOUSE'} and event.value == 'RELEASE':
157 # Trigger reset : Less than 150ms and less than 2 degrees move
158 if time() - self.timer < 0.15 and abs(math.degrees(self.angle)) < 2:
159 # self.report({'INFO'}, 'Reset')
160 aim = context.space_data.region_3d.view_rotation @ mathutils.Vector((0.0, 0.0, 1.0)) # view vector
161 z_up_quat = aim.to_track_quat('Z','Y') # track Z, up Y
162 if self.in_cam:
164 q = self.cam.matrix_world.to_quaternion() # store current rotation
166 if self.cam.parent:
167 q = self.cam.parent.matrix_world.inverted().to_quaternion() @ q
168 cam_quat = self.cam.parent.matrix_world.inverted().to_quaternion() @ z_up_quat
169 else:
170 cam_quat = z_up_quat
171 self.cam.rotation_euler = cam_quat.to_euler('XYZ')
173 # get diff angle (might be better way to get view axis rot diff)
174 diff_angle = q.rotation_difference(cam_quat).to_euler('ZXY').z
175 # print('diff_angle: ', math.degrees(diff_angle))
176 self.set_cam_view_offset_from_angle(context, diff_angle)
178 else:
179 context.space_data.region_3d.view_rotation = z_up_quat
180 self.execute(context)
181 return {'FINISHED'}
183 if event.type == 'ESC':#Cancel
184 self.execute(context)
185 if self.in_cam:
186 self.cam.matrix_world = self.cam_matrix
187 context.space_data.region_3d.view_camera_offset = self.view_cam_offset
188 else:
189 context.space_data.region_3d.view_rotation = self._rotation
190 return {'CANCELLED'}
192 return {'RUNNING_MODAL'}
194 def invoke(self, context, event):
195 self.current_area = context.area
196 prefs = get_addon_prefs()
197 self.hud = prefs.canvas_use_hud
198 self.use_view_center = prefs.canvas_use_view_center
199 self.angle = 0.0
200 ## Check if scene camera or local camera exists ?
201 # if (context.space_data.use_local_camera and context.space_data.camera) or context.scene.camera
202 self.in_cam = context.region_data.view_perspective == 'CAMERA'
204 ## store ratio for view rotate correction
206 # CORRECT UI OVERLAP FROM HEADER TOOLBAR
207 regs = context.area.regions
208 if context.preferences.system.use_region_overlap:
209 w = context.area.width
210 # minus tool header
211 h = context.area.height - regs[0].height
212 else:
213 # minus tool leftbar + sidebar right
214 w = context.area.width - regs[2].width - regs[3].width
215 # minus tool header + header
216 h = context.area.height - regs[0].height - regs[1].height
218 self.ratio = h / w
219 self.ratio_inv = w / h
221 if self.in_cam:
222 # Get camera from scene
223 if context.space_data.use_local_camera and context.space_data.camera:
224 self.cam = context.space_data.camera
225 else:
226 self.cam = context.scene.camera
228 #return if one element is locked (else bypass location)
229 if self.cam.lock_rotation[:] != (False, False, False):
230 self.report({'WARNING'}, 'Camera rotation is locked')
231 return {'CANCELLED'}
233 if self.use_view_center:
234 self.center = mathutils.Vector((w/2, h/2))
235 else:
236 self.center = self.get_center_view(context, self.cam)
238 # store original rotation mode
239 self.org_rotation_mode = self.cam.rotation_mode
240 # set to euler to works with quaternions, restored at finish
241 self.cam.rotation_mode = 'XYZ'
242 # store camera matrix world
243 self.cam_matrix = self.cam.matrix_world.copy()
244 # self.cam_init_euler = self.cam.rotation_euler.copy()
246 ## initialize current view_offset in camera
247 self.view_cam_offset = mathutils.Vector(context.space_data.region_3d.view_camera_offset)
249 else:
250 self.center = mathutils.Vector((w/2, h/2))
251 # self.center = mathutils.Vector((context.area.width/2, context.area.height/2))
253 # store current rotation
254 self._rotation = context.space_data.region_3d.view_rotation.copy()
256 # Get current mouse coordination
257 self.pos_current = mathutils.Vector((event.mouse_region_x, event.mouse_region_y))
259 self.initial_pos = self.pos_current# for draw debug, else no need
260 # Calculate initial vector
261 self.vector_initial = self.pos_current - self.center
262 self.vector_initial.normalize()
264 # Initializes the current vector with the same initial vector.
265 self.vector_current = self.vector_initial.copy()
268 #Snap keys
269 self.snap_ctrl = not prefs.use_ctrl
270 self.snap_shift = not prefs.use_shift
271 self.snap_alt = not prefs.use_alt
272 # round to closer degree and convert back to radians
273 self.snap_step = math.radians(round(math.degrees(prefs.rc_angle_step)))
275 self.timer = time()
276 args = (self, context)
277 if self.hud:
278 self._handle = bpy.types.SpaceView3D.draw_handler_add(draw_callback_px, args, 'WINDOW', 'POST_PIXEL')
279 context.window_manager.modal_handler_add(self)
280 return {'RUNNING_MODAL'}
283 ## -- Set / Reset rotation buttons
285 class RC_OT_Set_rotation(bpy.types.Operator):
286 bl_idname = 'view3d.rotate_canvas_set'
287 bl_label = 'Save Rotation'
288 bl_description = 'Save active camera rotation (per camera property)'
289 bl_options = {"REGISTER"}
291 @classmethod
292 def poll(cls, context):
293 return context.area.type == 'VIEW_3D' \
294 and context.space_data.region_3d.view_perspective == 'CAMERA'
296 def execute(self, context):
297 cam_ob = context.scene.camera
298 cam_ob['stored_rotation'] = cam_ob.rotation_euler
299 if not cam_ob.get('_RNA_UI'):
300 cam_ob['_RNA_UI'] = {}
301 cam_ob['_RNA_UI']["stored_rotation"] = {
302 "description":"Stored camera rotation (Gpencil tools > rotate canvas operator)",
303 "subtype":'EULER',
304 # "is_overridable_library":0,
307 return {'FINISHED'}
309 class RC_OT_Reset_rotation(bpy.types.Operator):
310 bl_idname = 'view3d.rotate_canvas_reset'
311 bl_label = 'Restore Rotation'
312 bl_description = 'Restore active camera rotation from previously saved state'
313 bl_options = {"REGISTER", "UNDO"}
315 @classmethod
316 def poll(cls, context):
317 return context.area.type == 'VIEW_3D' \
318 and context.space_data.region_3d.view_perspective == 'CAMERA' \
319 and context.scene.camera.get('stored_rotation')
321 def execute(self, context):
322 cam_ob = context.scene.camera
323 cam_ob.rotation_euler = cam_ob['stored_rotation']
324 return {'FINISHED'}
327 ### --- REGISTER
329 classes = (
330 RC_OT_RotateCanvas,
331 RC_OT_Set_rotation,
332 RC_OT_Reset_rotation,
335 def register():
336 for cls in classes:
337 bpy.utils.register_class(cls)
339 def unregister():
340 for cls in reversed(classes):
341 bpy.utils.unregister_class(cls)