├── utils.py ├── README.md ├── __init__.py ├── panels.py └── operators.py /utils.py: -------------------------------------------------------------------------------- 1 | # utils.py 2 | import os 3 | import tempfile 4 | from datetime import datetime 5 | import bpy 6 | import re 7 | 8 | def ensure_output_directory(directory): 9 | """ 10 | Ensure the output directory exists, creating it if necessary. 11 | Returns the absolute path to the directory. 12 | Raises OSError if directory cannot be created. 13 | """ 14 | try: 15 | output_dir = bpy.path.abspath(directory) if directory else tempfile.gettempdir() 16 | # Normalize the path 17 | output_dir = os.path.abspath(output_dir) 18 | os.makedirs(output_dir, exist_ok=True) 19 | return output_dir 20 | except (OSError, PermissionError) as e: 21 | raise OSError(f"Failed to create output directory '{output_dir}': {str(e)}") 22 | 23 | def sanitize_filename(filename): 24 | """ 25 | Sanitize a filename to prevent path traversal attacks and invalid characters. 26 | Returns a safe filename. 27 | """ 28 | # Remove path separators and dangerous characters 29 | # Allow alphanumeric, spaces, hyphens, underscores, and periods 30 | sanitized = re.sub(r'[^\w\s\-.]', '', filename) 31 | # Remove leading/trailing whitespace and periods 32 | sanitized = sanitized.strip('. ') 33 | # Prevent empty filenames 34 | if not sanitized: 35 | sanitized = "untitled" 36 | # Limit length to reasonable size 37 | if len(sanitized) > 200: 38 | sanitized = sanitized[:200] 39 | return sanitized 40 | 41 | def generate_output_filename(directory, suffix="output"): 42 | """ 43 | Generate a safe output filename with timestamp. 44 | Returns the full path to the output file. 45 | """ 46 | blend_name = os.path.splitext(bpy.path.basename(bpy.data.filepath))[0] or "untitled" 47 | # Sanitize the blend file name to prevent path traversal 48 | safe_blend_name = sanitize_filename(blend_name) 49 | safe_suffix = sanitize_filename(suffix) 50 | timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") 51 | return os.path.join(directory, f"{safe_blend_name}_{safe_suffix}_{timestamp}.png") 52 | 53 | def report_error(operator, message): 54 | operator.report({'ERROR'}, message) 55 | print(f"ERROR: {message}") 56 | 57 | # rendertoolsprop 58 | 59 | def draw_property(layout, data, property_name, label, icon=None): 60 | """ 61 | Draw a property with an optional icon in a consistent style. 62 | """ 63 | row = layout.row() 64 | if icon: 65 | row.prop(data, property_name, text=label, icon=icon) 66 | else: 67 | row.prop(data, property_name, text=label) 68 | 69 | def draw_operator(layout, operator_id, label, icon=None): 70 | """ 71 | Draw an operator button with an optional icon. 72 | """ 73 | row = layout.row() 74 | if icon: 75 | row.operator(operator_id, text=label, icon=icon) 76 | else: 77 | row.operator(operator_id, text=label) 78 | 79 | # get active collection 80 | 81 | def get_active_collection(context, operator): 82 | """ 83 | Retrieve the active collection from the scene. If none is found, report a warning. 84 | Returns the active collection or None if not found. 85 | """ 86 | active_collection = context.scene.custom_name_props.get_active_collection(context) 87 | if not active_collection: 88 | operator.report({'WARNING'}, "No active collection found. Please select or create a collection.") 89 | return active_collection 90 | 91 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Kyokaz's Toolbox 2 | A set of animation tools for Blender, originally made for my own animation project, and decided to share them here. 3 | 4 | Quick Overview Video: 5 | https://www.youtube.com/watch?v=Ig7vOTFnr5c 6 | 7 | ![ezgif-5-ed39fafd2b](https://github.com/user-attachments/assets/88b79a26-b34f-48ba-9d97-dbb7c3f28efe) 8 | 9 | # Quick Camera 10 | Sets of new operators to add cameras based on your viewport, with a few useful features. 11 | 12 | ![image](https://github.com/user-attachments/assets/23050fdd-6200-4b63-bbdf-f47609f49353) 13 | 14 | ### Add Camera: 15 | - Instantly add a camera based on the viewport 16 | - An added camera will be inserted into its custom collection. 17 | ### Add Shot: 18 | - Instantly add and bind a camera in the current frame (useful for camera switching in animation) 19 | - An added camera will be inserted into its custom collection with custom naming conventions. 20 | ### Copy Camera & Copy Shot 21 | - Instantly copy current active camera attributes/properties to a new camera. (Copy Shot works the same, but with the bind/marker added) 22 | ### Camera Status: 23 | Shows useful information for each camera like frame range and total frames. 24 | 25 | ![blender_zuI8Q4WKNh](https://github.com/user-attachments/assets/74e796a3-9362-485e-8971-9dda9e7e1a40) 26 | 27 | ### Quick Pie Menu: 28 | ![blender_ZBvnQXZwms](https://github.com/Kyokaz/Kyokaz-s-Toolbox/assets/84836314/5431e41d-263d-47fa-94d9-4de1251019cb) 29 | 30 | Quickly add a new camera using the pie menu (key bind "V" by default, customizable in the Preferences setting). 31 | 32 | ### Set Preview Range 33 | ![blender_z9NF6xBQRT](https://github.com/user-attachments/assets/86ac2c1c-deb7-4db3-b98d-63e19bac43f7) 34 | 35 | Set a preview range for a specific camera shot. 36 | 37 | ### Set Favorites 38 | 39 | ![blender_QlrWHNcDdM](https://github.com/user-attachments/assets/81385212-d520-4985-83d9-8033f8948ffb) 40 | 41 | Set a favorite up to 8 cameras that can be accessed through the pie menu. 42 | 43 | 44 | ## Notes/Annotation Overlay 45 | ![blender_qnxi0X80ac (2)](https://github.com/user-attachments/assets/a4c92a6e-69aa-4db4-9b9a-0e7216cfefda) 46 | 47 | Added a feature to add notes or annotations on camera/shot with the option to change font & background color and size. 48 | (Scroll to change scale, shift+scroll to change font color, and ctrl+scroll to change background opacity) 49 | 50 | ![blender_ksESpK2tdG (2)](https://github.com/user-attachments/assets/e59c41e1-5b61-4ac9-aaff-33979574e4ca) 51 | 52 | Added a toggleable camera Info overlay 53 | 54 | # Animation Tools 55 | ![image](https://github.com/Kyokaz/Kyokaz-s-Toolbox/assets/84836314/a5fdacc8-5380-400b-986b-53476bb34082) 56 | 57 | ### Toggle Default Interpolation 58 | Allows users to toggle the default interpolation between Constant and Bezier without going into the preference settings. 59 | ### Bake Per Steps 60 | Allows the user to quickly Bake selected keyframe range with custom steps using [_bpy.ops.nla.bak_](https://docs.blender.org/api/current/bpy.ops.nla.html#bpy.ops.nla.bake) operator. 61 | ### Add Per Steps 62 | Similar to Bake Per Steps, this one only adds keyframe(s) instead of replacing them. 63 | ### Delete Per Steps 64 | Deletes keyframe with custom steps. 65 | 66 | # Rendering Tools 67 | 68 | ![blender_gOaPnGPe4O](https://github.com/user-attachments/assets/12a2396e-86f2-4319-831d-34de4bb2b671) 69 | 70 | ### Disable Render for Hidden Objects: 71 | Automatically disables rendering for all hidden objects in the viewport in case you forgot to disable them manually for a render. Excluded Collection is added to prevent specific objects from being applied. 72 | 73 | ### Render Preset 74 | ![image](https://github.com/user-attachments/assets/49d3eaa8-5107-4a30-8f8e-76afc1154c82) 75 | ![image](https://github.com/user-attachments/assets/ccdbc89d-c8fc-4621-8d67-4754b4dce22c) 76 | 77 | Easily create your own preset for render settings, and import and export them as a JSON file. 78 | 79 | ### Viewport Render Animation 80 | ![image](https://github.com/Kyokaz/Kyokaz-s-Toolbox/assets/84836314/3daecc1e-fc17-465e-92c2-c79bfcd35c5a) 81 | 82 | Viewport Render Animation is more accessible now with the option to turn on a customizable timecode and the ability to preview the video after render. 83 | 84 | **Current known issue:** 85 | - Hidden collection will not be applied if the objects inside still have their viewport render turned on (Working on fixing this). 86 | - Hidden objects inside another collection in the excluded collection might not work properly, resulting in hidden objects inside the custom collection still being applied even though they're in the excluded collection. To temporarily prevent this, disable the viewport render for the collection instead of the objects inside it. 87 | 88 | # How to Install 89 | 1. Download the [latest release](https://github.com/Kyokaz/toggle_default_interpolation/releases) 90 | 2. In Blender, go to Edit > Preferences > Add-ons > Install 91 | 3. Select the Python file and enable the add-on 92 | 93 | # How to Use 94 | The toggle button should appear on the side panel (N-Panel) in Timeline Editor, Action Editor, Graph Editor, Dope Sheet Editor, and Viewport Editor 'Toolbox' Panel. 95 | Animation Tools can be found in animation-related quick panels (Timeline Editor, Action Editor, Graph Editor, Dope Sheet Editor). 96 | Quick Camera and Render Tools can be found in the Toolbox Panel (Can be turned off) or in Scene Properties. 97 | 98 | ## Disclaimer 99 | This code was written with the assistance of Claude.AI. I'm not fully familiar with Python coding yet, as I'm still learning, so if you have any suggestions on how to make this better, please let me know! 100 | -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- 1 | bl_info = { 2 | "name": "KyokazToolbox", 3 | "author": "Kyokaz", 4 | "version": (2, 6, 0), 5 | "blender": (3, 0, 0), 6 | "location": "", 7 | "description": "Animation Toolbox", 8 | "category": "Animation" 9 | } 10 | 11 | import bpy 12 | from bpy.types import AddonPreferences 13 | from bpy.props import StringProperty, BoolProperty, PointerProperty 14 | from . import operators 15 | from . import panels 16 | from . import utils 17 | 18 | addon_keymaps = [] 19 | 20 | def _redraw_viewports(context): 21 | """Helper function to redraw all 3D viewports.""" 22 | for window in context.window_manager.windows: 23 | for area in window.screen.areas: 24 | if area.type == 'VIEW_3D': 25 | area.tag_redraw() 26 | 27 | class MyAddonPreferences(AddonPreferences): 28 | bl_idname = __package__ 29 | 30 | # Quick Camera Pie Menu 31 | 32 | quick_camera_key: StringProperty( 33 | name="Quick Camera Key", 34 | default='V', 35 | update=lambda self, context: update_keymap(self, context) 36 | ) 37 | quick_camera_ctrl: BoolProperty( 38 | name="Ctrl", 39 | default=False, 40 | update=lambda self, context: update_keymap(self, context) 41 | ) 42 | quick_camera_alt: BoolProperty( 43 | name="Alt", 44 | default=False, 45 | update=lambda self, context: update_keymap(self, context) 46 | ) 47 | quick_camera_shift: BoolProperty( 48 | name="Shift", 49 | default=False, 50 | update=lambda self, context: update_keymap(self, context) 51 | ) 52 | 53 | # Camera Controls 54 | 55 | camera_controls_key: StringProperty( 56 | name="Camera Controls Key", 57 | default='V', 58 | update=lambda self, context: update_keymap(self, context) 59 | ) 60 | camera_controls_ctrl: BoolProperty( 61 | name="Ctrl", 62 | default=False, 63 | update=lambda self, context: update_keymap(self, context) 64 | ) 65 | camera_controls_alt: BoolProperty( 66 | name="Alt", 67 | default=False, 68 | update=lambda self, context: update_keymap(self, context) 69 | ) 70 | camera_controls_shift: BoolProperty( 71 | name="Shift", 72 | default=True, 73 | update=lambda self, context: update_keymap(self, context) 74 | ) 75 | 76 | capture_key: BoolProperty( 77 | name="Capture Key", 78 | default=False 79 | ) 80 | 81 | # Favorite Camera 82 | 83 | favorite_camera_key: StringProperty( 84 | name="Favorite Camera Key", 85 | default='V', 86 | update=lambda self, context: update_keymap(self, context) 87 | ) 88 | favorite_camera_ctrl: BoolProperty( 89 | name="Ctrl", 90 | default=False, 91 | update=lambda self, context: update_keymap(self, context) 92 | ) 93 | favorite_camera_alt: BoolProperty( 94 | name="Alt", 95 | default=True, 96 | update=lambda self, context: update_keymap(self, context) 97 | ) 98 | favorite_camera_shift: BoolProperty( 99 | name="Shift", 100 | default=False, 101 | update=lambda self, context: update_keymap(self, context) 102 | ) 103 | 104 | show_viewport_button: BoolProperty( 105 | name="Show the Viewport Render button in the 3D Viewport header", 106 | description="Show the Viewport Render button in the 3D Viewport header", 107 | default=True 108 | ) 109 | 110 | show_pin_button: BoolProperty( 111 | name="Show the Pin button in the 3D Viewport header", 112 | description="Show the Pin button in the 3D Viewport header", 113 | default=True 114 | ) 115 | 116 | show_render_tools_n_panel: BoolProperty( 117 | name="Show Render Tools in N-panel", 118 | description="Show the Render Tools panel in the N-panel", 119 | default=True 120 | ) 121 | show_quick_camera_n_panel: BoolProperty( 122 | name="Show Quick Camera in N-panel", 123 | description="Show the Quick Camera panel in the N-panel", 124 | default=True 125 | ) 126 | show_shot_list_n_panel: BoolProperty( 127 | name="Show Shot List in N-panel", 128 | description="Show the Shot List panel in the N-panel", 129 | default=True 130 | ) 131 | show_camera_list_n_panel: BoolProperty( 132 | name="Show Camera List in N-panel", 133 | description="Show the Camera List panel in the N-panel", 134 | default=True 135 | ) 136 | 137 | show_camera_info_overlay: BoolProperty( 138 | name="Show Camera Info Overlay", 139 | description="Show camera information overlay in viewport when in camera view", 140 | default=True, 141 | update=lambda self, context: operators.toggle_camera_info_overlay(self.show_camera_info_overlay) 142 | ) 143 | 144 | show_camera_notes: BoolProperty( 145 | name="Show Camera Notes", 146 | description="Show camera notes overlay in viewport when in camera view", 147 | default=True, 148 | update=lambda self, context: operators.toggle_camera_notes_overlay(self.show_camera_notes) 149 | ) 150 | 151 | # Camera Info Overlay Settings 152 | camera_info_position_x: bpy.props.IntProperty( 153 | name="Position X", 154 | description="Horizontal position from left of the camera info overlay", 155 | default=30, 156 | min=0, 157 | max=4000, 158 | update=lambda self, context: _redraw_viewports(context) 159 | ) 160 | 161 | camera_info_position_y: bpy.props.IntProperty( 162 | name="Position Y", 163 | description="Vertical position from bottom of the camera info overlay", 164 | default=100, 165 | min=0, 166 | max=4000, 167 | update=lambda self, context: _redraw_viewports(context) 168 | ) 169 | 170 | camera_info_font_size: bpy.props.IntProperty( 171 | name="Font Size", 172 | description="Font size for camera info text", 173 | default=15, 174 | min=8, 175 | max=72, 176 | update=lambda self, context: _redraw_viewports(context) 177 | ) 178 | 179 | camera_info_single_line: BoolProperty( 180 | name="Single Line Layout", 181 | description="Display all info in a single line instead of multiple lines", 182 | default=False, 183 | update=lambda self, context: _redraw_viewports(context) 184 | ) 185 | 186 | camera_info_separator: StringProperty( 187 | name="Separator", 188 | description="Separator character for single line layout", 189 | default=" | ", 190 | maxlen=10, 191 | update=lambda self, context: _redraw_viewports(context) 192 | ) 193 | 194 | # Info display toggles 195 | camera_info_show_name: BoolProperty( 196 | name="Show Camera/Shot Name", 197 | description="Display camera or shot name", 198 | default=True, 199 | update=lambda self, context: _redraw_viewports(context) 200 | ) 201 | 202 | camera_info_show_frames: BoolProperty( 203 | name="Show Frame Range", 204 | description="Display frame range for shots", 205 | default=True, 206 | update=lambda self, context: _redraw_viewports(context) 207 | ) 208 | 209 | camera_info_show_focal: BoolProperty( 210 | name="Show Focal Length", 211 | description="Display focal length or ortho scale", 212 | default=True, 213 | update=lambda self, context: _redraw_viewports(context) 214 | ) 215 | 216 | camera_info_show_focus: BoolProperty( 217 | name="Show Focus Distance", 218 | description="Display focus distance when DoF is enabled", 219 | default=True, 220 | update=lambda self, context: _redraw_viewports(context) 221 | ) 222 | 223 | camera_info_show_fstop: BoolProperty( 224 | name="Show F-Stop", 225 | description="Display F-Stop when DoF is enabled", 226 | default=True, 227 | update=lambda self, context: _redraw_viewports(context) 228 | ) 229 | 230 | camera_info_background_color: bpy.props.FloatVectorProperty( 231 | name="Background Color", 232 | description="Color and opacity of the background", 233 | subtype='COLOR', 234 | size=4, 235 | min=0.0, 236 | max=1.0, 237 | default=(0.0, 0.0, 0.0, 0.6), 238 | update=lambda self, context: _redraw_viewports(context) 239 | ) 240 | 241 | camera_info_font_color: bpy.props.FloatVectorProperty( 242 | name="Font Color", 243 | description="Color and opacity of the text", 244 | subtype='COLOR', 245 | size=4, 246 | min=0.0, 247 | max=1.0, 248 | default=(1.0, 1.0, 1.0, 1.0), 249 | update=lambda self, context: _redraw_viewports(context) 250 | ) 251 | 252 | def draw(self, context): 253 | layout = self.layout 254 | 255 | box = layout.box() 256 | box.label(text="N-panel Settings:") 257 | box.prop(self, "show_render_tools_n_panel") 258 | box.prop(self, "show_quick_camera_n_panel") 259 | box.prop(self, "show_shot_list_n_panel") 260 | box.prop(self, "show_camera_list_n_panel") 261 | 262 | box = layout.box() 263 | row = box.row() 264 | row.label(text="Viewport Settings:") 265 | row = box.row() 266 | row.prop(self, "show_camera_info_overlay") 267 | 268 | # Camera info overlay settings (only show if enabled) 269 | if self.show_camera_info_overlay: 270 | sub_box = box.box() 271 | sub_box.label(text="Camera Info Overlay Settings:", icon='PREFERENCES') 272 | 273 | # Layout options 274 | col = sub_box.column(align=True) 275 | col.label(text="Layout:") 276 | col.prop(self, "camera_info_single_line") 277 | if self.camera_info_single_line: 278 | col.prop(self, "camera_info_separator", text="Separator") 279 | 280 | # Position and appearance 281 | col = sub_box.column(align=True) 282 | col.label(text="Position & Appearance:") 283 | row = col.row(align=True) 284 | row.prop(self, "camera_info_position_x") 285 | row.prop(self, "camera_info_position_y") 286 | col.prop(self, "camera_info_font_size") 287 | col.prop(self, "camera_info_font_color", text="Font Color") 288 | col.prop(self, "camera_info_background_color", text="Background") 289 | 290 | # Info display options 291 | col = sub_box.column(align=True) 292 | col.label(text="Display Options:") 293 | col.prop(self, "camera_info_show_name") 294 | col.prop(self, "camera_info_show_frames") 295 | col.prop(self, "camera_info_show_focal") 296 | col.prop(self, "camera_info_show_focus") 297 | col.prop(self, "camera_info_show_fstop") 298 | 299 | box = layout.box() 300 | row = box.row() 301 | row.label(text="Render Tools Settings:") 302 | row = box.row() 303 | row.prop(self, "show_viewport_button") 304 | row = box.row() 305 | row.prop(self, "show_pin_button") 306 | row = box.row() 307 | 308 | box = layout.box() 309 | row = box.row() 310 | row.label(text="Camera Tools Settings:") 311 | 312 | # Quick Camera Pie Menu keybind 313 | row = box.row() 314 | row.label(text="Quick Camera Pie Menu Keybind:") 315 | if self.capture_key: 316 | row.operator("wm.capture_keymap", text="Press a Key", icon="KEYINGSET").pie_menu = "quick_camera" 317 | else: 318 | key_combination = f"{'Ctrl+' if self.quick_camera_ctrl else ''}{'Alt+' if self.quick_camera_alt else ''}{'Shift+' if self.quick_camera_shift else ''}{self.quick_camera_key or '(None)'}" 319 | row.operator("wm.capture_keymap", text=key_combination, icon="KEYINGSET").pie_menu = "quick_camera" 320 | row.operator("wm.remove_keymap", text="", icon="X").pie_menu = "quick_camera" 321 | row = box.row() 322 | row.prop(self, "quick_camera_ctrl") 323 | row.prop(self, "quick_camera_alt") 324 | row.prop(self, "quick_camera_shift") 325 | 326 | # Camera Controls Pie Menu keybind 327 | row = box.row() 328 | row.label(text="Camera Controls Pie Menu Keybind:") 329 | if self.capture_key: 330 | row.operator("wm.capture_keymap", text="Press a Key", icon="KEYINGSET").pie_menu = "camera_controls" 331 | else: 332 | key_combination = f"{'Ctrl+' if self.camera_controls_ctrl else ''}{'Alt+' if self.camera_controls_alt else ''}{'Shift+' if self.camera_controls_shift else ''}{self.camera_controls_key or '(None)'}" 333 | row.operator("wm.capture_keymap", text=key_combination, icon="KEYINGSET").pie_menu = "camera_controls" 334 | row.operator("wm.remove_keymap", text="", icon="X").pie_menu = "camera_controls" 335 | row = box.row() 336 | row.prop(self, "camera_controls_ctrl") 337 | row.prop(self, "camera_controls_alt") 338 | row.prop(self, "camera_controls_shift") 339 | 340 | # Favorite Camera keybind 341 | row = box.row() 342 | row.label(text="Favorite Camera Pie Menu Keybind:") 343 | if self.capture_key: 344 | row.operator("wm.capture_keymap", text="Press a Key", icon="KEYINGSET").pie_menu = "favorite_camera" 345 | else: 346 | key_combination = f"{'Ctrl+' if self.favorite_camera_ctrl else ''}{'Alt+' if self.favorite_camera_alt else ''}{'Shift+' if self.favorite_camera_shift else ''}{self.favorite_camera_key or '(None)'}" 347 | row.operator("wm.capture_keymap", text=key_combination, icon="KEYINGSET").pie_menu = "favorite_camera" 348 | row.operator("wm.remove_keymap", text="", icon="X").pie_menu = "favorite_camera" 349 | row = box.row() 350 | row.prop(self, "favorite_camera_ctrl") 351 | row.prop(self, "favorite_camera_alt") 352 | row.prop(self, "favorite_camera_shift") 353 | 354 | box = layout.box() 355 | row = box.row() 356 | row.label(text="This code was written with the help of Claude.AI, if you know how to improve it please let me know!") 357 | row = box.row() 358 | row.operator("wm.url_open", text="Visit GitHub", icon="URL").url = "https://github.com/Kyokaz/Kyokaz-s-Toolbox" 359 | 360 | def draw_viewport_header(self, context): 361 | """Draw viewport render buttons in the 3D View header.""" 362 | try: 363 | preferences = context.preferences.addons[__package__].preferences 364 | layout = self.layout 365 | if preferences.show_viewport_button: 366 | layout = layout.row(align=True) 367 | layout.operator("object.viewport_render_confirm", text="Viewport", icon='RENDER_STILL') 368 | layout.operator("object.viewport_render_settings", text="", icon='PREFERENCES') 369 | layout.operator("object.snapshot_render", text="Snapshot", icon='RENDER_RESULT') 370 | layout.operator("object.snapshot_render_settings", text="", icon='PREFERENCES') 371 | except (KeyError, AttributeError): 372 | # Silently fail if preferences not available 373 | pass 374 | 375 | def draw_local_camera_button(self, context): 376 | """Draw local camera pin button in the 3D View header.""" 377 | try: 378 | preferences = context.preferences.addons[__package__].preferences 379 | if preferences.show_pin_button: 380 | if hasattr(context, 'space_data') and hasattr(context.space_data, 'use_local_camera'): 381 | icon = 'PINNED' if context.space_data.use_local_camera else 'UNPINNED' 382 | self.layout.operator("object.toggle_local_camera", text="", icon=icon) 383 | except (KeyError, AttributeError): 384 | # Silently fail if preferences not available 385 | pass 386 | 387 | def draw_set_frame_buttons(self, context): 388 | layout = self.layout 389 | row = layout.row(align=True) 390 | row.operator("scene.set_frame", text="Start", icon='TRIA_LEFT_BAR').frame_type = 'START' 391 | row.operator("scene.set_frame", text="End", icon='TRIA_RIGHT_BAR').frame_type = 'END' 392 | 393 | def register_keymap(): 394 | """Register keymap items for pie menus with error handling.""" 395 | try: 396 | wm = bpy.context.window_manager 397 | if not wm or not wm.keyconfigs or not wm.keyconfigs.addon: 398 | print("Warning: Cannot register keymaps - window manager not available") 399 | return 400 | 401 | addon_prefs = bpy.context.preferences.addons[__package__].preferences 402 | 403 | km = wm.keyconfigs.addon.keymaps.new(name='3D View', space_type='VIEW_3D') 404 | 405 | # Quick Camera Pie Menu 406 | if addon_prefs.quick_camera_key: 407 | try: 408 | kmi = km.keymap_items.new('wm.call_menu_pie', addon_prefs.quick_camera_key, "PRESS", 409 | ctrl=addon_prefs.quick_camera_ctrl, 410 | alt=addon_prefs.quick_camera_alt, 411 | shift=addon_prefs.quick_camera_shift) 412 | kmi.properties.name = "VIEW3D_MT_PIE_QuickCamera" 413 | addon_keymaps.append((km, kmi)) 414 | except Exception as e: 415 | print(f"Warning: Failed to register Quick Camera keymap: {e}") 416 | 417 | # Camera Controls Pie Menu 418 | if addon_prefs.camera_controls_key: 419 | try: 420 | kmi = km.keymap_items.new('wm.call_menu_pie', addon_prefs.camera_controls_key, "PRESS", 421 | ctrl=addon_prefs.camera_controls_ctrl, 422 | alt=addon_prefs.camera_controls_alt, 423 | shift=addon_prefs.camera_controls_shift) 424 | kmi.properties.name = "VIEW3D_MT_PIE_camera_controls" 425 | addon_keymaps.append((km, kmi)) 426 | except Exception as e: 427 | print(f"Warning: Failed to register Camera Controls keymap: {e}") 428 | 429 | # Favorite Camera Pie Menu 430 | if addon_prefs.favorite_camera_key: 431 | try: 432 | kmi = km.keymap_items.new('wm.call_menu_pie', addon_prefs.favorite_camera_key, "PRESS", 433 | ctrl=addon_prefs.favorite_camera_ctrl, 434 | alt=addon_prefs.favorite_camera_alt, 435 | shift=addon_prefs.favorite_camera_shift) 436 | kmi.properties.name = "VIEW3D_MT_PIE_favorite_camera" 437 | addon_keymaps.append((km, kmi)) 438 | except Exception as e: 439 | print(f"Warning: Failed to register Favorite Camera keymap: {e}") 440 | except (KeyError, AttributeError) as e: 441 | print(f"Warning: Cannot register keymaps - addon preferences not available: {e}") 442 | 443 | def unregister_keymap(): 444 | for km, kmi in addon_keymaps: 445 | km.keymap_items.remove(kmi) 446 | addon_keymaps.clear() 447 | 448 | def update_keymap(self, context): 449 | unregister_keymap() 450 | register_keymap() 451 | 452 | classes = ( 453 | MyAddonPreferences, 454 | *operators.classes, 455 | *panels.classes, 456 | ) 457 | 458 | def register(): 459 | for cls in classes: 460 | bpy.utils.register_class(cls) 461 | 462 | bpy.types.Scene.custom_name_props = bpy.props.PointerProperty(type=operators.CustomNameProperties) 463 | bpy.types.VIEW3D_HT_header.append(draw_viewport_header) 464 | bpy.types.VIEW3D_HT_header.append(draw_local_camera_button) 465 | bpy.types.TIME_MT_editor_menus.append(draw_set_frame_buttons) 466 | bpy.types.Scene.camera_index = bpy.props.IntProperty() 467 | bpy.types.Scene.active_marker_index = bpy.props.IntProperty() 468 | bpy.types.Scene.snapshot_settings = bpy.props.PointerProperty(type=panels.SnapshotSettings) 469 | bpy.types.Scene.render_tools_settings = bpy.props.PointerProperty(type=operators.RenderToolsSettings) 470 | bpy.types.Scene.viewport_render_settings = bpy.props.PointerProperty(type=panels.ViewportRenderSettings) 471 | bpy.types.Scene.favorite_cameras = bpy.props.CollectionProperty(type=bpy.types.PropertyGroup) 472 | bpy.types.Scene.render_presets = PointerProperty(type=operators.RenderPresetsCollection) 473 | bpy.types.Scene.collection_index = bpy.props.IntProperty( 474 | name="Selected Collection Index", 475 | update=operators.update_collection_index 476 | ) 477 | bpy.types.Scene.camera_notes = bpy.props.CollectionProperty(type=operators.CameraNoteItem) 478 | bpy.types.Scene.active_note_index = bpy.props.IntProperty(name="Active Note Index", default=0) 479 | 480 | # Panel-specific scene properties 481 | bpy.types.Scene.shot_list_color = bpy.props.FloatVectorProperty( 482 | name="Shot List Color", 483 | subtype='COLOR', 484 | default=(0.2, 0.6, 1.0, 1.0), 485 | size=4, 486 | min=0.0, 487 | max=1.0 488 | ) 489 | bpy.types.Scene.camera_list_color = bpy.props.FloatVectorProperty( 490 | name="Camera List Color", 491 | subtype='COLOR', 492 | default=(1.0, 0.6, 0.2, 1.0), 493 | size=4, 494 | min=0.0, 495 | max=1.0 496 | ) 497 | bpy.types.Scene.show_camera_details = bpy.props.BoolProperty( 498 | name="Show Camera Details", 499 | default=True 500 | ) 501 | 502 | register_keymap() 503 | 504 | # Register camera info overlay if enabled 505 | try: 506 | preferences = bpy.context.preferences.addons[__package__].preferences 507 | if preferences.show_camera_info_overlay: 508 | operators.register_camera_info_overlay() 509 | if preferences.show_camera_notes: 510 | operators.register_camera_notes_overlay() 511 | except (KeyError, AttributeError): 512 | # Default to enabled if preferences not available 513 | operators.register_camera_info_overlay() 514 | operators.register_camera_notes_overlay() 515 | 516 | def unregister(): 517 | unregister_keymap() 518 | 519 | # Unregister camera overlays 520 | operators.unregister_camera_info_overlay() 521 | operators.unregister_camera_notes_overlay() 522 | 523 | for cls in reversed(classes): 524 | bpy.utils.unregister_class(cls) 525 | 526 | bpy.types.VIEW3D_HT_header.remove(draw_viewport_header) 527 | bpy.types.VIEW3D_HT_header.remove(draw_local_camera_button) 528 | bpy.types.TIME_MT_editor_menus.remove(draw_set_frame_buttons) 529 | del bpy.types.Scene.render_presets 530 | del bpy.types.Scene.active_marker_index 531 | del bpy.types.Scene.camera_index 532 | del bpy.types.Scene.custom_name_props 533 | del bpy.types.Scene.render_tools_settings 534 | del bpy.types.Scene.viewport_render_settings 535 | del bpy.types.Scene.snapshot_settings 536 | del bpy.types.Scene.collection_index 537 | del bpy.types.Scene.favorite_cameras 538 | del bpy.types.Scene.camera_notes 539 | del bpy.types.Scene.active_note_index 540 | del bpy.types.Scene.shot_list_color 541 | del bpy.types.Scene.camera_list_color 542 | del bpy.types.Scene.show_camera_details 543 | 544 | if __package__ == "__main__": 545 | register() 546 | 547 | 548 | -------------------------------------------------------------------------------- /panels.py: -------------------------------------------------------------------------------- 1 | import bpy 2 | from bpy.types import Panel, UIList, PropertyGroup, Operator 3 | from bpy.props import BoolProperty, StringProperty, IntProperty, FloatVectorProperty, EnumProperty 4 | from .utils import draw_property, draw_operator 5 | from . import operators 6 | 7 | class OBJECT_PT_BasePanel(Panel): 8 | bl_space_type = 'DOPESHEET_EDITOR' 9 | bl_region_type = 'UI' 10 | bl_category = 'Kyokaz Toolbox' 11 | 12 | @classmethod 13 | def poll(cls, context): 14 | return context.area.type in {'GRAPH_EDITOR', 'DOPESHEET_EDITOR', 'TIMELINE', 'ACTION_EDITOR', 'FCURVES'} 15 | 16 | class OBJECT_UL_CollectionList(bpy.types.UIList): 17 | def draw_item(self, context, layout, data, item, icon, active_data, active_propname): 18 | if self.layout_type in {'DEFAULT', 'COMPACT'}: 19 | layout.prop(item, "name", text="", emboss=False, icon='OUTLINER_COLLECTION') 20 | elif self.layout_type in {'GRID'}: 21 | layout.alignment = 'CENTER' 22 | layout.label(text="", icon='OUTLINER_COLLECTION') 23 | 24 | class OBJECT_PT_toggle_interpolation_panel(OBJECT_PT_BasePanel): 25 | bl_label = "Animation Tools" 26 | bl_idname = "OBJECT_PT_toggle_interpolation_panel" 27 | 28 | def draw_header(self, context): 29 | self.layout.label(icon='RENDER_ANIMATION') 30 | 31 | def draw(self, context): 32 | layout = self.layout 33 | layout.operator("object.toggle_auto_keying", 34 | text="Auto Keying: " + ("On" if context.scene.tool_settings.use_keyframe_insert_auto else "Off"), 35 | icon='AUTO') 36 | layout.separator() 37 | 38 | class OBJECT_PT_interpolation_tools_panel(OBJECT_PT_BasePanel): 39 | bl_label = "Interpolation Tools" 40 | bl_parent_id = "OBJECT_PT_toggle_interpolation_panel" 41 | 42 | def draw(self, context): 43 | layout = self.layout 44 | interpolation_type = context.preferences.edit.keyframe_new_interpolation_type 45 | interpolation_icon = {'CONSTANT': 'IPO_CONSTANT', 'LINEAR': 'IPO_LINEAR'}.get(interpolation_type, 'IPO_BEZIER') 46 | interpolation_text = interpolation_type.capitalize() 47 | 48 | layout.label(text="Toggle Default Interpolation:") 49 | layout.operator("object.toggle_default_interpolation", text=interpolation_text, icon=interpolation_icon) 50 | layout.separator() 51 | 52 | layout.label(text="Toggle to Selected:") 53 | row = layout.row() 54 | row.operator("object.toggle_interpolation_selected", text="Keyframe", icon='KEY_HLT') 55 | row.operator("object.toggle_interpolation_all", text="Object", icon='CONSTRAINT') 56 | layout.separator() 57 | 58 | layout.label(text="Apply to Selected Object:") 59 | row = layout.row() 60 | row.operator("object.apply_all_constant", icon='IPO_CONSTANT') 61 | row.operator("object.apply_all_bezier", icon='IPO_BEZIER') 62 | row.operator("object.apply_all_linear", icon='IPO_LINEAR') 63 | layout.separator() 64 | 65 | layout.label(text="Apply to Selected Keyframe:") 66 | row = layout.row() 67 | row.operator("object.apply_selected_constant", icon='IPO_CONSTANT') 68 | row.operator("object.apply_selected_bezier", icon='IPO_BEZIER') 69 | row.operator("object.apply_selected_linear", icon='IPO_LINEAR') 70 | 71 | class OBJECT_PT_bake_tools_panel(OBJECT_PT_BasePanel): 72 | bl_label = "Bake Tools" 73 | bl_parent_id = "OBJECT_PT_toggle_interpolation_panel" 74 | 75 | def draw(self, context): 76 | layout = self.layout 77 | layout.label(text="Bake Selected Keyframes:") 78 | layout.operator("object.bake_keyframes_per_steps", icon='ANIM_DATA') 79 | layout.operator("object.add_keyframes_operator", icon='KEY_HLT') 80 | layout.operator("object.delete_keyframes_per_steps", icon='KEY_DEHLT') 81 | layout.separator() 82 | 83 | 84 | class OBJECT_PT_RenderToolsPanel(bpy.types.Panel): 85 | bl_label = "Render Tools" 86 | bl_idname = "OBJECT_PT_render_tools" 87 | bl_space_type = 'VIEW_3D' 88 | bl_region_type = 'UI' 89 | bl_category = 'Toolbox' 90 | bl_options = {'DEFAULT_CLOSED'} 91 | 92 | @classmethod 93 | def poll(cls, context): 94 | try: 95 | preferences = context.preferences.addons[__package__].preferences 96 | return preferences.show_render_tools_n_panel 97 | except (KeyError, AttributeError): 98 | return True # Default to showing if preferences not found 99 | 100 | def draw_header(self, context): 101 | layout = self.layout 102 | layout.label(icon='RENDER_STILL') 103 | 104 | def draw(self, context): 105 | layout = self.layout 106 | settings = context.scene.render_tools_settings 107 | 108 | draw_property(layout, settings, "output_directory", "Output Directory", icon='FILE_FOLDER') 109 | 110 | box = layout.box() 111 | box.label(text="Render Settings:", icon="RENDER_STILL") 112 | # Main operator and Affect Children option 113 | row = box.row(align=True) 114 | row.operator("object.disable_render_for_hidden", text="Disable Render for Hidden", icon='HIDE_ON') 115 | row.prop(settings, "affect_children", text="", icon='OUTLINER_OB_ARMATURE') 116 | 117 | # Exception Collection 118 | row = box.row(align=True) 119 | row.prop(settings, "exception_collection", text="") 120 | row.operator("object.create_exception_collection", text="", icon='COLLECTION_NEW') 121 | row.operator("object.add_selected_to_exceptions", text="Add Selected", icon='HAND') 122 | 123 | # Tooltip for the Exception Collection 124 | row = box.row(align=True) 125 | row.label(text="Exception: Objects in this collection will be ignored", icon='INFO') 126 | 127 | # Viewport Render 128 | box = layout.box() 129 | box.label(text="Viewport Render:", icon="RENDER_STILL") 130 | row = box.row(align=True) 131 | row.operator("object.viewport_render_confirm", text="Viewport Render", icon='RENDER_STILL') 132 | row.operator("object.viewport_render_settings", text="", icon='PREFERENCES') 133 | 134 | row = box.row(align=True) 135 | row.operator("object.snapshot_render", text="Snapshot", icon='RENDER_RESULT') 136 | row.operator("object.snapshot_render_settings", text="", icon='PREFERENCES') 137 | 138 | # Add render presets UI 139 | operators.draw_render_presets(self, context, layout) 140 | 141 | 142 | class OBJECT_PT_CameraTools(Panel): 143 | bl_label = "Quick Camera" 144 | bl_idname = "OBJECT_PT_CameraTools" 145 | bl_space_type = 'VIEW_3D' 146 | bl_region_type = 'UI' 147 | bl_category = 'Toolbox' 148 | 149 | @classmethod 150 | def poll(cls, context): 151 | try: 152 | preferences = context.preferences.addons[__package__].preferences 153 | return preferences.show_quick_camera_n_panel 154 | except (KeyError, AttributeError): 155 | return True # Default to showing if preferences not found 156 | 157 | def draw_header(self, context): 158 | layout = self.layout 159 | layout.label(icon='CAMERA_DATA') 160 | 161 | def draw(self, context): 162 | layout = self.layout 163 | scene = context.scene 164 | props = scene.custom_name_props 165 | 166 | # Camera Collection Selection 167 | box = layout.box() 168 | box.label(text="Camera Collection:", icon="OUTLINER_COLLECTION") 169 | row = box.row(align=True) 170 | row.prop(props, "camera_collection", text="") 171 | 172 | # New Collection Creation 173 | row = box.row(align=True) 174 | row.prop(props, "collection_name", text="") 175 | row.operator("object.create_camera_collection", text="", icon="PLUS") 176 | 177 | # Camera Creation 178 | box = layout.box() 179 | box.label(text="Add New Camera:", icon="ADD") 180 | 181 | row = box.row() 182 | row.prop(props, "camera_name") 183 | row.operator("object.add_camera", text="", icon="ADD") 184 | row = box.row() 185 | row.prop(props, "shot_name") 186 | row.operator("object.add_camera_with_marker", text="", icon="ADD") 187 | 188 | row = box.row(align=True) 189 | row.operator("object.add_camera_copy_properties", text="Copy Camera", icon="CAMERA_DATA") 190 | row.operator("object.add_camera_shot_copy_properties", text="Copy Shot", icon="VIEW_CAMERA") 191 | 192 | class OBJECT_PT_DefaultCameraSettings(Panel): 193 | bl_label = "Default Camera Settings" 194 | bl_parent_id = "OBJECT_PT_CameraTools" 195 | bl_space_type = 'VIEW_3D' 196 | bl_region_type = 'UI' 197 | bl_category = 'Toolbox' 198 | bl_options = {'DEFAULT_CLOSED'} 199 | 200 | def draw(self, context): 201 | layout = self.layout 202 | props = context.scene.custom_name_props 203 | 204 | layout.prop(props, "default_type") 205 | 206 | row = layout.row(align=True) 207 | row.prop(props, "default_passepartout") 208 | row.operator("object.apply_passepartout_to_all_cameras", text="", icon='CHECKMARK') 209 | 210 | row = layout.row() 211 | if props.default_type == 'ORTHO': 212 | row.prop(props, "default_ortho_scale") 213 | else: 214 | row.prop(props, "default_lens") 215 | 216 | row = layout.row() 217 | row.prop(props, "default_clip_start") 218 | row.prop(props, "default_clip_end") 219 | 220 | class OBJECT_PT_ActiveCameraSettings(Panel): 221 | bl_label = "Active Camera Settings" 222 | bl_parent_id = "OBJECT_PT_CameraTools" 223 | bl_space_type = 'VIEW_3D' 224 | bl_region_type = 'UI' 225 | bl_category = 'Toolbox' 226 | bl_options = {'DEFAULT_CLOSED'} 227 | 228 | @classmethod 229 | def poll(cls, context): 230 | return context.active_object and context.active_object.type == 'CAMERA' 231 | 232 | def draw(self, context): 233 | layout = self.layout 234 | active_object = context.active_object 235 | 236 | row = layout.row(align=True) 237 | if active_object.data.type == 'ORTHO': 238 | row.prop(active_object.data, "ortho_scale", text="Ortho Scale") 239 | else: 240 | row.prop(active_object.data, "lens", text="Focal Length") 241 | 242 | row = layout.row() 243 | row.prop(active_object.data, "type") 244 | 245 | row = layout.row() 246 | row.prop(active_object.data, "passepartout_alpha", text="Passepartout") 247 | 248 | row = layout.row() 249 | row.prop(active_object.data, "clip_start") 250 | row.prop(active_object.data, "clip_end") 251 | 252 | layout.prop(active_object.data.dof, "use_dof", text="Depth of Field") 253 | if active_object.data.dof.use_dof: 254 | row = layout.row(align=True) 255 | row.prop(active_object.data.dof, "focus_distance", text="Focus Distance") 256 | row.prop(active_object.data.dof, "aperture_fstop", text="F-Stop") 257 | 258 | 259 | class OBJECT_PT_CameraInfoOverlay(Panel): 260 | bl_label = "Camera Info Overlay" 261 | bl_parent_id = "OBJECT_PT_CameraTools" 262 | bl_space_type = 'VIEW_3D' 263 | bl_region_type = 'UI' 264 | bl_category = 'Toolbox' 265 | bl_options = {'DEFAULT_CLOSED'} 266 | 267 | def draw_header(self, context): 268 | layout = self.layout 269 | try: 270 | preferences = context.preferences.addons[__package__].preferences 271 | row = layout.row(align=True) 272 | row.prop(preferences, "show_camera_info_overlay", text="") 273 | row.label(text="", icon='CAMERA_DATA') 274 | row.prop(preferences, "show_camera_notes", text="", icon='TEXT') 275 | except (KeyError, AttributeError): 276 | pass 277 | 278 | def draw(self, context): 279 | layout = self.layout 280 | 281 | try: 282 | preferences = context.preferences.addons[__package__].preferences 283 | 284 | # Enable/disable layout based on overlay state 285 | layout.enabled = preferences.show_camera_info_overlay 286 | 287 | # Layout options 288 | box = layout.box() 289 | box.label(text="Layout:", icon='ALIGN_JUSTIFY') 290 | box.prop(preferences, "camera_info_single_line") 291 | if preferences.camera_info_single_line: 292 | box.prop(preferences, "camera_info_separator", text="Separator") 293 | 294 | # Position and appearance 295 | box = layout.box() 296 | box.label(text="Position & Appearance:", icon='ORIENTATION_VIEW') 297 | col = box.column(align=True) 298 | col.prop(preferences, "camera_info_position_x", text="X Position") 299 | col.prop(preferences, "camera_info_position_y", text="Y Position") 300 | col.prop(preferences, "camera_info_font_size", text="Font Size") 301 | col.prop(preferences, "camera_info_font_color", text="Font Color") 302 | col.prop(preferences, "camera_info_background_color", text="Background") 303 | 304 | # Info display options 305 | box = layout.box() 306 | box.label(text="Display Options:", icon='PREFERENCES') 307 | col = box.column(align=True) 308 | col.prop(preferences, "camera_info_show_name") 309 | col.prop(preferences, "camera_info_show_frames") 310 | col.prop(preferences, "camera_info_show_focal") 311 | col.prop(preferences, "camera_info_show_focus") 312 | col.prop(preferences, "camera_info_show_fstop") 313 | 314 | except (KeyError, AttributeError): 315 | layout.label(text="Preferences not available", icon='ERROR') 316 | 317 | 318 | class OBJECT_UL_ShotList(UIList): 319 | def draw_item(self, context, layout, data, item, icon, active_data, active_propname): 320 | scene = context.scene 321 | props = scene.custom_name_props 322 | selected_collection = props.get_shot_list_collection(context) 323 | 324 | if selected_collection and item.camera and item.camera.name in selected_collection.objects: 325 | row = layout.row(align=True) 326 | 327 | is_current_camera = scene.camera and scene.camera.name == item.camera.name 328 | is_preview_range = scene.use_preview_range and scene.frame_preview_start == item.frame 329 | 330 | # Jump to marker 331 | op = row.operator("scene.jump_to_marker", text="", icon='MARKER') 332 | op.marker_name = item.name 333 | 334 | # Select camera 335 | op_select = row.operator("scene.select_camera", text="", icon='RESTRICT_SELECT_OFF') 336 | op_select.camera_name = item.camera.name 337 | 338 | # Shot/Marker name with note indicator 339 | name_row = row.row(align=True) 340 | name_row.prop(item, "name", text="", emboss=False) 341 | 342 | # Check if shot's camera has notes 343 | has_notes = any(note.camera_name == item.camera.name for note in scene.camera_notes) 344 | if has_notes: 345 | name_row.label(text="", icon='TEXT') 346 | 347 | # Preview range 348 | preview_row = row.row() 349 | preview_row.alert = is_preview_range 350 | op_preview = preview_row.operator("scene.set_preview_range", text="", icon='PREVIEW_RANGE') 351 | op_preview.start_frame = item.frame 352 | 353 | # Calculate end frame safely 354 | marker_index = scene.timeline_markers.find(item.name) 355 | if marker_index >= 0 and marker_index < len(scene.timeline_markers) - 1: 356 | end_frame = scene.timeline_markers[marker_index + 1].frame 357 | else: 358 | end_frame = scene.frame_end 359 | 360 | op_preview.end_frame = end_frame 361 | op_preview.toggle = not is_preview_range 362 | 363 | # Frame range and duration 364 | shot_frames = end_frame - item.frame 365 | 366 | frame_row = row.row() 367 | frame_row.label(text=f"{item.frame} - {end_frame} ({shot_frames} frames)") 368 | 369 | # Remove marker and camera 370 | op_remove = row.operator("scene.remove_marker_and_camera", text="", icon='X') 371 | op_remove.marker_name = item.name 372 | 373 | def filter_items(self, context, data, propname): 374 | helpers = bpy.types.UI_UL_list 375 | markers = getattr(data, propname) 376 | props = context.scene.custom_name_props 377 | selected_collection = props.get_shot_list_collection(context) 378 | 379 | # Initialize filter flags and order 380 | flt_flags = [self.bitflag_filter_item] * len(markers) 381 | flt_neworder = [] 382 | 383 | # Filter by name 384 | if self.filter_name: 385 | flt_flags = helpers.filter_items_by_name(self.filter_name, self.bitflag_filter_item, markers, "name") 386 | 387 | # Filter by selected collection 388 | if selected_collection: 389 | for idx, marker in enumerate(markers): 390 | if not marker.camera or marker.camera.name not in selected_collection.objects: 391 | flt_flags[idx] &= ~self.bitflag_filter_item 392 | 393 | # Sort 394 | if self.use_filter_sort_alpha: 395 | flt_neworder = helpers.sort_items_by_name(markers, "name") 396 | 397 | return flt_flags, flt_neworder 398 | 399 | class OBJECT_PT_ShotList(Panel): 400 | bl_label = "Shot List" 401 | bl_idname = "OBJECT_PT_ShotList" 402 | bl_space_type = 'VIEW_3D' 403 | bl_region_type = 'UI' 404 | bl_category = 'Toolbox' 405 | 406 | @classmethod 407 | def poll(cls, context): 408 | try: 409 | preferences = context.preferences.addons[__package__].preferences 410 | return preferences.show_shot_list_n_panel 411 | except (KeyError, AttributeError): 412 | return True # Default to showing if preferences not found 413 | 414 | def draw_header(self, context): 415 | self.layout.label(icon='VIEW_CAMERA') 416 | 417 | def draw(self, context): 418 | layout = self.layout 419 | scene = context.scene 420 | props = scene.custom_name_props 421 | 422 | # Camera Collection Selection 423 | box = layout.box() 424 | box.label(text="Camera Collection:", icon="OUTLINER_COLLECTION") 425 | row = box.row(align=True) 426 | row.prop(props, "shot_list_collection", text="") 427 | 428 | selected_collection = props.get_shot_list_collection(context) 429 | 430 | if selected_collection: 431 | layout.template_list("OBJECT_UL_ShotList", "", scene, "timeline_markers", scene, "active_marker_index", rows=5) 432 | 433 | valid_markers = [marker for marker in scene.timeline_markers 434 | if marker.camera and marker.camera.name in selected_collection.objects] 435 | 436 | if valid_markers: 437 | # Calculate total frames safely 438 | if len(valid_markers) > 1: 439 | total_frames = sum(valid_markers[i+1].frame - valid_markers[i].frame for i in range(len(valid_markers)-1)) 440 | total_frames += scene.frame_end - valid_markers[-1].frame 441 | else: 442 | # Only one marker 443 | total_frames = scene.frame_end - valid_markers[0].frame 444 | layout.label(text=f"Total Shot Duration: {total_frames} frames") 445 | else: 446 | layout.label(text="No shots in the selected collection") 447 | 448 | col = layout.column(align=True) 449 | col.operator("scene.remove_all_shot_cameras", text="Remove All Shots and Markers", icon='TRASH') 450 | col.operator("scene.remove_all_markers", text="Remove All Markers", icon='MARKER') 451 | col.operator("scene.clean_up_markers", text="Clean Up Markers", icon='BRUSH_DATA') 452 | else: 453 | layout.label(text="No collection selected") 454 | 455 | 456 | class OBJECT_UL_CameraList(UIList): 457 | def draw_item(self, context, layout, data, item, icon, active_data, active_propname): 458 | if item.type == 'CAMERA': 459 | row = layout.row(align=True) 460 | 461 | # Select camera 462 | op_select = row.operator("scene.select_camera", text="", icon='RESTRICT_SELECT_OFF') 463 | op_select.camera_name = item.name 464 | 465 | # Set active camera 466 | op_set_active = row.operator("scene.set_active_camera", text="", icon='OUTLINER_OB_CAMERA') 467 | op_set_active.camera_name = item.name 468 | 469 | # Favorite toggle 470 | props = context.scene.custom_name_props 471 | is_favorite = item in [fc.camera for fc in props.favorite_cameras if fc.camera] 472 | icon = 'SOLO_ON' if is_favorite else 'SOLO_OFF' 473 | op = row.operator("object.toggle_favorite_camera", text="", icon=icon, emboss=False) 474 | op.camera_name = item.name 475 | 476 | # Camera name with note indicator 477 | name_row = row.row(align=True) 478 | name_row.prop(item, "name", text="", emboss=False) 479 | 480 | # Check if camera has notes 481 | scene = context.scene 482 | has_notes = any(note.camera_name == item.name for note in scene.camera_notes) 483 | if has_notes: 484 | name_row.label(text="", icon='TEXT') 485 | 486 | # Camera settings 487 | if item.data.type == 'ORTHO': 488 | row.prop(item.data, "ortho_scale", text="Ortho Scale", emboss=True) 489 | else: 490 | row.prop(item.data, "lens", text="Lens", emboss=True) 491 | 492 | # DOF toggle 493 | dof_row = row.row(align=True) 494 | 495 | if item.data.dof.use_dof: 496 | dof_row.prop(item.data.dof, "focus_distance", text="Focus", emboss=True) 497 | dof_row.prop(item.data.dof, "focus_object", text="", emboss=True) 498 | 499 | row.prop(item.data.dof, "use_dof", text="", icon='PROP_ON', toggle=True) 500 | 501 | # Remove camera 502 | op_remove = row.operator("object.delete_camera", text="", icon='X') 503 | op_remove.camera_name = item.name 504 | 505 | def filter_items(self, context, data, propname): 506 | helpers = bpy.types.UI_UL_list 507 | objects = context.scene.objects 508 | props = context.scene.custom_name_props 509 | selected_collection = props.get_camera_list_collection(context) 510 | 511 | # Initialize filter flags and order 512 | flt_flags = [self.bitflag_filter_item] * len(objects) 513 | flt_neworder = [] 514 | 515 | # Filter by name 516 | if self.filter_name: 517 | flt_flags = helpers.filter_items_by_name(self.filter_name, self.bitflag_filter_item, objects, "name") 518 | 519 | # Filter by camera type and selected collection 520 | for idx, obj in enumerate(objects): 521 | if obj.type != 'CAMERA' or (selected_collection and obj.name not in selected_collection.objects): 522 | flt_flags[idx] &= ~self.bitflag_filter_item 523 | 524 | # Sort 525 | if self.use_filter_sort_alpha: 526 | flt_neworder = helpers.sort_items_by_name(objects, "name") 527 | 528 | return flt_flags, flt_neworder 529 | 530 | class OBJECT_PT_CameraList(Panel): 531 | bl_label = "Camera List" 532 | bl_idname = "OBJECT_PT_CameraList" 533 | bl_space_type = 'VIEW_3D' 534 | bl_region_type = 'UI' 535 | bl_category = 'Toolbox' 536 | 537 | @classmethod 538 | def poll(cls, context): 539 | try: 540 | preferences = context.preferences.addons[__package__].preferences 541 | return preferences.show_camera_list_n_panel 542 | except (KeyError, AttributeError): 543 | return True # Default to showing if preferences not found 544 | 545 | def draw_header(self, context): 546 | self.layout.label(icon='OUTLINER_OB_CAMERA') 547 | 548 | def draw(self, context): 549 | layout = self.layout 550 | scene = context.scene 551 | props = scene.custom_name_props 552 | 553 | # Camera Collection Selection 554 | box = layout.box() 555 | box.label(text="Camera Collection:", icon="OUTLINER_COLLECTION") 556 | row = box.row(align=True) 557 | row.prop(props, "camera_list_collection", text="") 558 | 559 | selected_collection = props.get_camera_list_collection(context) 560 | 561 | if selected_collection: 562 | layout.template_list("OBJECT_UL_CameraList", "", selected_collection, "objects", scene, "camera_index", rows=5) 563 | 564 | col = layout.column(align=True) 565 | col.operator("scene.remove_all_cameras", text="Remove All Cameras", icon='TRASH') 566 | col.operator("object.add_camera", text="Add New Camera", icon='ADD') 567 | 568 | # Add information about favorite cameras 569 | favorite_count = len([fc for fc in props.favorite_cameras if fc.camera]) 570 | layout.label(text=f"Favorite Cameras: {favorite_count}/8") 571 | else: 572 | layout.label(text="No collection selected") 573 | 574 | class OBJECT_UL_camera_notes(UIList): 575 | """UIList for camera notes""" 576 | 577 | def draw_item(self, context, layout, data, item, icon, active_data, active_propname): 578 | if self.layout_type in {'DEFAULT', 'COMPACT'}: 579 | row = layout.row(align=True) 580 | row.prop(item, "enabled", text="", icon='HIDE_OFF' if item.enabled else 'HIDE_ON', emboss=False) 581 | row.prop(item, "text", text="", emboss=False, icon='TEXT') 582 | elif self.layout_type == 'GRID': 583 | layout.alignment = 'CENTER' 584 | layout.prop(item, "enabled", text="", icon='HIDE_OFF' if item.enabled else 'HIDE_ON') 585 | 586 | def filter_items(self, context, data, propname): 587 | """Filter items to show only notes for the active camera""" 588 | notes = getattr(data, propname) 589 | flt_flags = [] 590 | flt_neworder = [] 591 | 592 | scene = context.scene 593 | camera_name = None 594 | 595 | # Use the active scene camera 596 | if scene.camera and scene.camera.type == 'CAMERA': 597 | camera_name = scene.camera.name 598 | 599 | if camera_name: 600 | # Filter: show only notes matching the active camera 601 | flt_flags = [self.bitflag_filter_item if note.camera_name == camera_name else 0 602 | for note in notes] 603 | else: 604 | # Show all notes if no active camera 605 | flt_flags = [self.bitflag_filter_item for _ in notes] 606 | 607 | return flt_flags, flt_neworder 608 | 609 | class OBJECT_OT_move_note(bpy.types.Operator): 610 | """Move note up or down in the list""" 611 | bl_idname = "object.move_note" 612 | bl_label = "Move Note" 613 | bl_options = {'REGISTER', 'UNDO'} 614 | 615 | direction: bpy.props.EnumProperty( 616 | items=[ 617 | ('UP', 'Up', 'Move note up'), 618 | ('DOWN', 'Down', 'Move note down'), 619 | ] 620 | ) 621 | note_index: bpy.props.IntProperty() 622 | 623 | def execute(self, context): 624 | scene = context.scene 625 | notes = scene.camera_notes 626 | 627 | if self.direction == 'UP' and self.note_index > 0: 628 | notes.move(self.note_index, self.note_index - 1) 629 | scene.active_note_index -= 1 630 | elif self.direction == 'DOWN' and self.note_index < len(notes) - 1: 631 | notes.move(self.note_index, self.note_index + 1) 632 | scene.active_note_index += 1 633 | 634 | # Force viewport redraw 635 | for area in context.screen.areas: 636 | if area.type == 'VIEW_3D': 637 | area.tag_redraw() 638 | 639 | return {'FINISHED'} 640 | 641 | class OBJECT_PT_Notes(Panel): 642 | """Unified notes panel that works in both Camera List and Shot List contexts""" 643 | bl_label = "Notes" 644 | bl_idname = "OBJECT_PT_Notes" 645 | bl_space_type = 'VIEW_3D' 646 | bl_region_type = 'UI' 647 | bl_category = 'Toolbox' 648 | bl_options = {'DEFAULT_CLOSED'} 649 | 650 | @classmethod 651 | def poll(cls, context): 652 | # Show panel if there's an active camera in the scene 653 | return context.scene.camera and context.scene.camera.type == 'CAMERA' 654 | 655 | def draw_header(self, context): 656 | layout = self.layout 657 | try: 658 | preferences = context.preferences.addons[__package__].preferences 659 | layout.prop(preferences, "show_camera_notes", text="") 660 | except (KeyError, AttributeError): 661 | layout.label(icon='TEXT') 662 | 663 | def draw(self, context): 664 | layout = self.layout 665 | scene = context.scene 666 | 667 | # Use the active scene camera 668 | camera = scene.camera 669 | 670 | if camera and camera.type == 'CAMERA': 671 | box = layout.box() 672 | box.label(text=f"Active Camera: {camera.name}", icon='CAMERA_DATA') 673 | 674 | # Use template_list for scrollable list (filter is handled in the UIList) 675 | row = box.row() 676 | row.template_list("OBJECT_UL_camera_notes", "", scene, "camera_notes", scene, "active_note_index", rows=5) 677 | 678 | # Add/Remove/Move buttons 679 | col = row.column(align=True) 680 | op = col.operator("object.add_camera_note", text="", icon='ADD') 681 | op.camera_name = camera.name 682 | 683 | # Check if active note belongs to this camera 684 | active_note_valid = False 685 | if 0 <= scene.active_note_index < len(scene.camera_notes): 686 | active_note = scene.camera_notes[scene.active_note_index] 687 | if active_note.camera_name == camera.name: 688 | active_note_valid = True 689 | op = col.operator("object.remove_camera_note", text="", icon='REMOVE') 690 | op.note_index = scene.active_note_index 691 | 692 | col.separator() 693 | 694 | if active_note_valid: 695 | op = col.operator("object.move_note", text="", icon='TRIA_UP') 696 | op.direction = 'UP' 697 | op.note_index = scene.active_note_index 698 | 699 | op = col.operator("object.move_note", text="", icon='TRIA_DOWN') 700 | op.direction = 'DOWN' 701 | op.note_index = scene.active_note_index 702 | 703 | # Settings for selected note 704 | if active_note_valid: 705 | active_note = scene.camera_notes[scene.active_note_index] 706 | settings_box = layout.box() 707 | settings_box.label(text="Note Settings:", icon='PREFERENCES') 708 | 709 | col = settings_box.column(align=True) 710 | col.prop(active_note, "text", text="Text") 711 | 712 | row = col.row(align=True) 713 | row.prop(active_note, "position_x", text="X") 714 | row.prop(active_note, "position_y", text="Y") 715 | 716 | col.prop(active_note, "font_size", text="Size") 717 | col.prop(active_note, "font_color", text="Color") 718 | 719 | col.separator() 720 | col.prop(active_note, "show_background", text="Show Background") 721 | if active_note.show_background: 722 | col.prop(active_note, "background_color", text="BG Color") 723 | 724 | # Count notes for this camera 725 | camera_notes_count = sum(1 for note in scene.camera_notes if note.camera_name == camera.name) 726 | layout.label(text=f"Total: {camera_notes_count} notes") 727 | 728 | # Create parent panel instances for Camera List and Shot List 729 | class OBJECT_PT_CameraNotes(OBJECT_PT_Notes): 730 | bl_idname = "OBJECT_PT_CameraNotes" 731 | bl_parent_id = "OBJECT_PT_CameraList" 732 | 733 | class OBJECT_PT_ShotNotes(OBJECT_PT_Notes): 734 | bl_idname = "OBJECT_PT_ShotNotes" 735 | bl_parent_id = "OBJECT_PT_ShotList" 736 | 737 | class ViewportRenderSettings(PropertyGroup): 738 | output_directory: StringProperty( 739 | name="Output Directory", 740 | description="Directory to save the viewport render", 741 | subtype='DIR_PATH', 742 | default="" 743 | ) 744 | preview_render: BoolProperty( 745 | name="Preview", 746 | description="Preview the rendered animation after rendering", 747 | default=True 748 | ) 749 | save_file: BoolProperty( 750 | name="Save File", 751 | description="Save the rendered animation to file", 752 | default=True 753 | ) 754 | filename_suffix: StringProperty( 755 | name="Name Suffix", 756 | description="Suffix to add to the viewport render filename", 757 | default="viewport_render" 758 | ) 759 | include_timecode: BoolProperty( 760 | name="Include Timecode", 761 | description="Include a timecode in the viewport render", 762 | default=False 763 | ) 764 | stamp_background: FloatVectorProperty( 765 | name="Stamp Background", 766 | description="Background color of the stamp", 767 | subtype='COLOR', 768 | size=4, 769 | min=0.0, 770 | max=1.0, 771 | default=(0.0, 0.0, 0.0, 0.5) 772 | ) 773 | stamp_foreground: FloatVectorProperty( 774 | name="Stamp Foreground", 775 | description="Foreground color of the stamp", 776 | subtype='COLOR', 777 | size=4, 778 | min=0.0, 779 | max=1.0, 780 | default=(1.0, 1.0, 1.0, 1.0) 781 | ) 782 | stamp_font_size: IntProperty( 783 | name="Stamp Font Size", 784 | description="Font size of the stamp text", 785 | default=20, 786 | min=10, 787 | max=100 788 | ) 789 | use_stamp_camera: BoolProperty(name="Stamp Camera", default=True) 790 | use_stamp_frame: BoolProperty(name="Stamp Frame", default=True) 791 | use_stamp_time: BoolProperty(name="Stamp Time", default=True) 792 | use_stamp_filename: BoolProperty(name="Stamp Filename", default=True) 793 | use_stamp_date: BoolProperty(name="Stamp Date", default=True) 794 | use_stamp_frame_range: BoolProperty(name="Stamp Frame Range", default=True) 795 | use_stamp_scene: BoolProperty(name="Stamp Scene", default=False) 796 | use_stamp_note: BoolProperty(name="Stamp Note", default=False) 797 | use_stamp_marker: BoolProperty(name="Stamp Marker", default=False) 798 | use_stamp_sequencer_strip: BoolProperty(name="Stamp Sequencer Strip", default=False) 799 | use_stamp_render_time: BoolProperty(name="Stamp Render Time", default=False) 800 | 801 | class SnapshotSettings(PropertyGroup): 802 | output_directory: StringProperty( 803 | name="Output Directory", 804 | description="Directory to save the snapshot", 805 | subtype='DIR_PATH', 806 | default="" 807 | ) 808 | preview_render: BoolProperty( 809 | name="Preview", 810 | description="Preview the rendered image after snapshot", 811 | default=True 812 | ) 813 | save_file: BoolProperty( 814 | name="Save File", 815 | description="Save the rendered snapshot to file", 816 | default=True 817 | ) 818 | filename_suffix: StringProperty( 819 | name="Name Suffix", 820 | description="Suffix to add to the snapshot filename", 821 | default="snapshot" 822 | ) 823 | 824 | class KYOKAZ_PT_ToolboxScenePanel(bpy.types.Panel): 825 | bl_label = "Kyokaz's Toolbox" 826 | bl_idname = "KYOKAZ_PT_ToolboxScenePanel" 827 | bl_space_type = 'PROPERTIES' 828 | bl_region_type = 'WINDOW' 829 | bl_context = "scene" 830 | 831 | def draw_header(self, context): 832 | self.layout.label(icon='EVENT_K') 833 | 834 | def draw(self, context): 835 | layout = self.layout 836 | layout.label(text="Kyokaz's Toolbox version 2.6", icon="INFO") 837 | 838 | class KYOKAZ_PT_RenderToolsScenePanel(bpy.types.Panel): 839 | bl_label = "Render Tools" 840 | bl_parent_id = "KYOKAZ_PT_ToolboxScenePanel" 841 | bl_space_type = 'PROPERTIES' 842 | bl_region_type = 'WINDOW' 843 | bl_context = "scene" 844 | 845 | def draw_header(self, context): 846 | layout = self.layout 847 | layout.label(icon='RENDER_STILL') 848 | 849 | def draw(self, context): 850 | layout = self.layout 851 | # Copy the content from OBJECT_PT_RenderToolsPanel 852 | OBJECT_PT_RenderToolsPanel.draw(self, context) 853 | 854 | class KYOKAZ_PT_QuickCameraScenePanel(bpy.types.Panel): 855 | bl_label = "Quick Camera" 856 | bl_parent_id = "KYOKAZ_PT_ToolboxScenePanel" 857 | bl_space_type = 'PROPERTIES' 858 | bl_region_type = 'WINDOW' 859 | bl_context = "scene" 860 | 861 | def draw_header(self, context): 862 | layout = self.layout 863 | layout.label(icon='CAMERA_DATA') 864 | 865 | def draw(self, context): 866 | layout = self.layout 867 | # Copy the content from OBJECT_PT_CameraTools 868 | OBJECT_PT_CameraTools.draw(self, context) 869 | 870 | class KYOKAZ_PT_CameraListScenePanel(bpy.types.Panel): 871 | bl_label = "Camera List" 872 | bl_parent_id = "KYOKAZ_PT_QuickCameraScenePanel" 873 | bl_space_type = 'PROPERTIES' 874 | bl_region_type = 'WINDOW' 875 | bl_context = "scene" 876 | 877 | def draw_header(self, context): 878 | self.layout.label(icon='OUTLINER_OB_CAMERA') 879 | 880 | def draw(self, context): 881 | layout = self.layout 882 | # Copy the content from OBJECT_PT_CameraList 883 | OBJECT_PT_CameraList.draw(self, context) 884 | 885 | class KYOKAZ_PT_ShotListScenePanel(bpy.types.Panel): 886 | bl_label = "Shot List" 887 | bl_parent_id = "KYOKAZ_PT_QuickCameraScenePanel" 888 | bl_space_type = 'PROPERTIES' 889 | bl_region_type = 'WINDOW' 890 | bl_context = "scene" 891 | 892 | def draw_header(self, context): 893 | self.layout.label(icon='VIEW_CAMERA') 894 | 895 | def draw(self, context): 896 | layout = self.layout 897 | # Copy the content from OBJECT_PT_ShotList 898 | OBJECT_PT_ShotList.draw(self, context) 899 | 900 | class KYOKAZ_PT_CameraInfoOverlayScenePanel(bpy.types.Panel): 901 | bl_label = "Camera Info Overlay" 902 | bl_parent_id = "KYOKAZ_PT_QuickCameraScenePanel" 903 | bl_space_type = 'PROPERTIES' 904 | bl_region_type = 'WINDOW' 905 | bl_context = "scene" 906 | bl_options = {'DEFAULT_CLOSED'} 907 | 908 | def draw_header(self, context): 909 | layout = self.layout 910 | try: 911 | preferences = context.preferences.addons[__package__].preferences 912 | row = layout.row(align=True) 913 | row.prop(preferences, "show_camera_info_overlay", text="") 914 | row.label(text="", icon='CAMERA_DATA') 915 | row.prop(preferences, "show_camera_notes", text="", icon='TEXT') 916 | except (KeyError, AttributeError): 917 | pass 918 | 919 | def draw(self, context): 920 | layout = self.layout 921 | # Copy the content from OBJECT_PT_CameraInfoOverlay 922 | OBJECT_PT_CameraInfoOverlay.draw(self, context) 923 | 924 | class KYOKAZ_PT_CameraNotesScenePanel(bpy.types.Panel): 925 | bl_label = "Camera Notes" 926 | bl_parent_id = "KYOKAZ_PT_CameraListScenePanel" 927 | bl_space_type = 'PROPERTIES' 928 | bl_region_type = 'WINDOW' 929 | bl_context = "scene" 930 | bl_options = {'DEFAULT_CLOSED'} 931 | 932 | def draw_header(self, context): 933 | layout = self.layout 934 | try: 935 | preferences = context.preferences.addons[__package__].preferences 936 | layout.prop(preferences, "show_camera_notes", text="") 937 | except (KeyError, AttributeError): 938 | layout.label(icon='TEXT') 939 | 940 | def draw(self, context): 941 | layout = self.layout 942 | # Copy the content from OBJECT_PT_CameraNotes 943 | OBJECT_PT_CameraNotes.draw(self, context) 944 | 945 | class KYOKAZ_PT_ShotNotesScenePanel(bpy.types.Panel): 946 | bl_label = "Shot Notes" 947 | bl_parent_id = "KYOKAZ_PT_ShotListScenePanel" 948 | bl_space_type = 'PROPERTIES' 949 | bl_region_type = 'WINDOW' 950 | bl_context = "scene" 951 | bl_options = {'DEFAULT_CLOSED'} 952 | 953 | def draw_header(self, context): 954 | layout = self.layout 955 | try: 956 | preferences = context.preferences.addons[__package__].preferences 957 | layout.prop(preferences, "show_camera_notes", text="") 958 | except (KeyError, AttributeError): 959 | layout.label(icon='TEXT') 960 | 961 | def draw(self, context): 962 | layout = self.layout 963 | # Copy the content from OBJECT_PT_ShotNotes 964 | OBJECT_PT_ShotNotes.draw(self, context) 965 | 966 | classes = ( 967 | KYOKAZ_PT_ToolboxScenePanel, 968 | KYOKAZ_PT_RenderToolsScenePanel, 969 | KYOKAZ_PT_QuickCameraScenePanel, 970 | KYOKAZ_PT_CameraListScenePanel, 971 | KYOKAZ_PT_ShotListScenePanel, 972 | KYOKAZ_PT_CameraInfoOverlayScenePanel, 973 | KYOKAZ_PT_CameraNotesScenePanel, 974 | KYOKAZ_PT_ShotNotesScenePanel, 975 | OBJECT_UL_CollectionList, 976 | OBJECT_PT_toggle_interpolation_panel, 977 | OBJECT_PT_interpolation_tools_panel, 978 | OBJECT_PT_bake_tools_panel, 979 | OBJECT_PT_RenderToolsPanel, 980 | OBJECT_PT_ShotList, 981 | OBJECT_PT_CameraList, 982 | OBJECT_PT_CameraTools, 983 | OBJECT_PT_DefaultCameraSettings, 984 | OBJECT_PT_ActiveCameraSettings, 985 | OBJECT_PT_CameraInfoOverlay, 986 | OBJECT_PT_Notes, 987 | OBJECT_PT_CameraNotes, 988 | OBJECT_PT_ShotNotes, 989 | OBJECT_OT_move_note, 990 | OBJECT_UL_camera_notes, 991 | OBJECT_UL_CameraList, 992 | OBJECT_UL_ShotList, 993 | ViewportRenderSettings, 994 | SnapshotSettings, 995 | ) 996 | 997 | # Scene properties are registered in __init__.py along with the classes 998 | # to avoid double registration issues 999 | -------------------------------------------------------------------------------- /operators.py: -------------------------------------------------------------------------------- 1 | import bpy 2 | import blf 3 | import gpu 4 | from gpu_extras.batch import batch_for_shader 5 | from bpy.types import Operator, PropertyGroup, Menu, UIList 6 | from bpy.props import IntProperty, BoolProperty, EnumProperty, FloatVectorProperty, StringProperty, PointerProperty, FloatProperty, CollectionProperty 7 | from bpy_extras.view3d_utils import region_2d_to_location_3d, region_2d_to_origin_3d, region_2d_to_vector_3d, location_3d_to_region_2d 8 | from .utils import ensure_output_directory, generate_output_filename, report_error, get_active_collection 9 | from mathutils import Vector 10 | import os 11 | import subprocess 12 | import sys 13 | import platform 14 | import tempfile 15 | import mathutils 16 | import json 17 | from datetime import datetime 18 | import re 19 | 20 | # Global variable to store the draw handler 21 | _camera_info_draw_handler = None 22 | _camera_notes_draw_handler = None 23 | 24 | def get_view_matrix_from_context(context): 25 | """Retrieve the 3D view matrix from the current context.""" 26 | for area in context.screen.areas: 27 | if area.type == 'VIEW_3D': 28 | for space in area.spaces: 29 | if space.type == 'VIEW_3D' and space.region_3d: 30 | return space.region_3d.view_matrix 31 | return mathutils.Matrix.Identity(4) # Default to identity if no VIEW_3D found 32 | 33 | def sanitize_filename(filename): 34 | """Sanitize a filename to prevent path traversal attacks and invalid characters.""" 35 | # Remove path separators and dangerous characters 36 | # Allow alphanumeric, spaces, hyphens, underscores, and periods 37 | sanitized = re.sub(r'[^\w\s\-.]', '', filename) 38 | # Remove leading/trailing whitespace and periods 39 | sanitized = sanitized.strip('. ') 40 | # Prevent empty filenames 41 | if not sanitized: 42 | sanitized = "untitled" 43 | # Limit length to reasonable size (255 is typical max filename length) 44 | if len(sanitized) > 200: 45 | sanitized = sanitized[:200] 46 | return sanitized 47 | 48 | def safe_open_directory(directory_path): 49 | """Safely open a directory in the system file explorer with path validation.""" 50 | # Normalize and validate the path 51 | abs_path = os.path.abspath(directory_path) 52 | 53 | # Verify it's actually a directory and exists 54 | if not os.path.isdir(abs_path): 55 | return False, f"Directory does not exist: {abs_path}" 56 | 57 | # Security check: ensure the path is real and not a symlink to somewhere dangerous 58 | try: 59 | real_path = os.path.realpath(abs_path) 60 | if not os.path.isdir(real_path): 61 | return False, f"Invalid directory path: {abs_path}" 62 | except (OSError, ValueError) as e: 63 | return False, f"Path validation failed: {str(e)}" 64 | 65 | # Open the directory using platform-specific commands 66 | try: 67 | if platform.system() == "Windows": 68 | os.startfile(real_path) 69 | elif platform.system() == "Darwin": 70 | subprocess.Popen(["open", real_path]) 71 | else: # Linux and other Unix-like systems 72 | subprocess.Popen(["xdg-open", real_path]) 73 | return True, f"Opened directory: {real_path}" 74 | except Exception as e: 75 | return False, f"Failed to open directory: {str(e)}" 76 | 77 | def draw_camera_info_overlay(): 78 | """Draw camera information overlay in the viewport when in camera view.""" 79 | context = bpy.context 80 | 81 | # Check if we're in camera view 82 | if not context.space_data or context.space_data.type != 'VIEW_3D': 83 | return 84 | 85 | region_3d = context.space_data.region_3d 86 | if not region_3d or region_3d.view_perspective != 'CAMERA': 87 | return 88 | 89 | scene = context.scene 90 | camera = scene.camera 91 | 92 | if not camera or camera.type != 'CAMERA': 93 | return 94 | 95 | # Get preferences 96 | try: 97 | preferences = context.preferences.addons[__package__].preferences 98 | except (KeyError, AttributeError): 99 | return 100 | 101 | # Get camera info 102 | camera_data = camera.data 103 | camera_name = camera.name 104 | 105 | # Get frame range info for shots 106 | props = scene.custom_name_props 107 | shot_info = None 108 | 109 | # Check if camera is in a shot (timeline marker) 110 | for marker in scene.timeline_markers: 111 | if marker.camera and marker.camera == camera: 112 | # Find the shot duration 113 | marker_index = list(scene.timeline_markers).index(marker) 114 | if marker_index < len(scene.timeline_markers) - 1: 115 | next_marker = list(scene.timeline_markers)[marker_index + 1] 116 | end_frame = next_marker.frame 117 | else: 118 | end_frame = scene.frame_end 119 | 120 | shot_frames = end_frame - marker.frame 121 | shot_info = { 122 | 'name': marker.name, 123 | 'start': marker.frame, 124 | 'end': end_frame, 125 | 'frames': shot_frames 126 | } 127 | break 128 | 129 | # Prepare text information based on user preferences 130 | info_parts = [] 131 | 132 | # Camera/Shot name 133 | if preferences.camera_info_show_name: 134 | if shot_info: 135 | info_parts.append(f"Shot: {shot_info['name']}") 136 | else: 137 | info_parts.append(f"Camera: {camera_name}") 138 | 139 | # Frame range (only for shots) 140 | if preferences.camera_info_show_frames and shot_info: 141 | info_parts.append(f"Frames: {shot_info['start']}-{shot_info['end']} ({shot_info['frames']}f)") 142 | 143 | # Focal Length 144 | if preferences.camera_info_show_focal: 145 | if camera_data.type == 'ORTHO': 146 | info_parts.append(f"Ortho Scale: {camera_data.ortho_scale:.2f}") 147 | else: 148 | info_parts.append(f"Focal Length: {camera_data.lens:.1f}mm") 149 | 150 | # Focus Distance (if DoF is enabled) 151 | if camera_data.dof.use_dof: 152 | if preferences.camera_info_show_focus: 153 | info_parts.append(f"Focus: {camera_data.dof.focus_distance:.2f}m") 154 | if preferences.camera_info_show_fstop: 155 | info_parts.append(f"F-Stop: f/{camera_data.dof.aperture_fstop:.1f}") 156 | 157 | # Don't draw if no info to display 158 | if not info_parts: 159 | return 160 | 161 | # Combine info based on layout preference 162 | if preferences.camera_info_single_line: 163 | info_lines = [preferences.camera_info_separator.join(info_parts)] 164 | else: 165 | info_lines = info_parts 166 | 167 | # Draw the text 168 | font_id = 0 169 | font_color = preferences.camera_info_font_color 170 | blf.color(font_id, font_color[0], font_color[1], font_color[2], font_color[3]) 171 | blf.size(font_id, preferences.camera_info_font_size) 172 | 173 | region = context.region 174 | 175 | # Position based on user preferences (absolute coordinates from bottom-left) 176 | x_offset = preferences.camera_info_position_x 177 | y_start = preferences.camera_info_position_y 178 | line_height = preferences.camera_info_font_size + 6 179 | 180 | # Draw background rectangle 181 | import gpu 182 | from gpu_extras.batch import batch_for_shader 183 | 184 | # Calculate max text width for background 185 | max_width = 0 186 | for line in info_lines: 187 | text_width, text_height = blf.dimensions(font_id, line) 188 | if text_width > max_width: 189 | max_width = text_width 190 | 191 | # Draw semi-transparent background 192 | padding = 10 193 | bg_x = x_offset - padding 194 | bg_y = y_start - padding 195 | bg_width = max_width + padding * 2 196 | bg_height = len(info_lines) * line_height + padding * 2 197 | 198 | # Enable proper alpha blending 199 | gpu.state.blend_set('ALPHA') 200 | 201 | shader = gpu.shader.from_builtin('UNIFORM_COLOR') 202 | batch = batch_for_shader( 203 | shader, 'TRI_FAN', 204 | {"pos": [ 205 | (bg_x, bg_y), 206 | (bg_x + bg_width, bg_y), 207 | (bg_x + bg_width, bg_y + bg_height), 208 | (bg_x, bg_y + bg_height) 209 | ]}, 210 | ) 211 | 212 | shader.bind() 213 | # Use the color from preferences (RGBA) 214 | bg_color = preferences.camera_info_background_color 215 | shader.uniform_float("color", (bg_color[0], bg_color[1], bg_color[2], bg_color[3])) 216 | batch.draw(shader) 217 | 218 | # Restore default blend state 219 | gpu.state.blend_set('NONE') 220 | 221 | # Draw text lines 222 | for i, line in enumerate(info_lines): 223 | y_pos = y_start + (len(info_lines) - 1 - i) * line_height 224 | blf.position(font_id, x_offset, y_pos, 0) 225 | blf.draw(font_id, line) 226 | 227 | def register_camera_info_overlay(): 228 | """Register the camera info overlay draw handler.""" 229 | global _camera_info_draw_handler 230 | 231 | if _camera_info_draw_handler is None: 232 | _camera_info_draw_handler = bpy.types.SpaceView3D.draw_handler_add( 233 | draw_camera_info_overlay, (), 'WINDOW', 'POST_PIXEL' 234 | ) 235 | 236 | def unregister_camera_info_overlay(): 237 | """Unregister the camera info overlay draw handler.""" 238 | global _camera_info_draw_handler 239 | 240 | if _camera_info_draw_handler is not None: 241 | bpy.types.SpaceView3D.draw_handler_remove(_camera_info_draw_handler, 'WINDOW') 242 | _camera_info_draw_handler = None 243 | 244 | def toggle_camera_info_overlay(enable): 245 | """Toggle the camera info overlay on or off.""" 246 | if enable: 247 | register_camera_info_overlay() 248 | else: 249 | unregister_camera_info_overlay() 250 | 251 | def toggle_camera_notes_overlay(enable): 252 | """Toggle the camera notes overlay on or off.""" 253 | if enable: 254 | register_camera_notes_overlay() 255 | else: 256 | unregister_camera_notes_overlay() 257 | 258 | def draw_camera_notes_overlay(): 259 | """Draw camera notes overlay in the viewport when in camera view.""" 260 | context = bpy.context 261 | 262 | # Check if we're in camera view 263 | if not context.space_data or context.space_data.type != 'VIEW_3D': 264 | return 265 | 266 | region_3d = context.space_data.region_3d 267 | if not region_3d or region_3d.view_perspective != 'CAMERA': 268 | return 269 | 270 | scene = context.scene 271 | camera = scene.camera 272 | 273 | if not camera or camera.type != 'CAMERA': 274 | return 275 | 276 | # Get notes for this camera 277 | camera_notes = [note for note in scene.camera_notes 278 | if note.camera_name == camera.name and note.enabled] 279 | 280 | if not camera_notes: 281 | return 282 | 283 | region = context.region 284 | rv3d = context.space_data.region_3d 285 | font_id = 0 286 | 287 | # Precompute camera frame corners in world space so note positions 288 | # are anchored to the camera image plane (like Blender annotations) 289 | try: 290 | frame_local = camera.data.view_frame() 291 | # Pre-transform to world space 292 | frame_world = [camera.matrix_world @ v for v in frame_local] 293 | except Exception: 294 | frame_local = None 295 | frame_world = None 296 | 297 | def lerp(v1, v2, t): 298 | return v1 * (1.0 - t) + v2 * t 299 | 300 | # Set up proper GPU state for 2D overlay rendering 301 | gpu.state.blend_set('ALPHA') 302 | gpu.state.depth_test_set('NONE') 303 | 304 | # Draw each note (background + text) 305 | for note in camera_notes: 306 | # If we have a valid camera frame, compute a 3D point on the camera image plane 307 | if frame_world is not None: 308 | # Convert pixel coordinates to normalized (0-1 range for interpolation) 309 | # Assume a reference frame width/height (e.g., 1920x1080 at default FOV) 310 | # This makes pixels relative to camera intrinsic dimensions, not screen pixels 311 | norm_x = note.position_x / 1000.0 # Divide by reference to get normalized 312 | norm_y = note.position_y / 1000.0 313 | 314 | # frame_world order: top-left, top-right, bottom-right, bottom-left 315 | # Interpolate in world space (already transformed) 316 | top = lerp(frame_world[0], frame_world[1], norm_x) 317 | bottom = lerp(frame_world[3], frame_world[2], norm_x) 318 | point_world = lerp(bottom, top, norm_y) 319 | 320 | # Project to region (screen) coordinates 321 | coord_2d = location_3d_to_region_2d(region, rv3d, point_world) 322 | if coord_2d is None: 323 | # point not visible; skip drawing 324 | continue 325 | 326 | x_pos, y_pos = coord_2d.x, coord_2d.y 327 | else: 328 | # Fallback: treat as absolute screen coordinates 329 | x_pos = note.position_x 330 | y_pos = note.position_y 331 | 332 | # Set font size once per note 333 | blf.size(font_id, note.font_size) 334 | 335 | # Draw background if enabled 336 | if note.show_background: 337 | text_width, text_height = blf.dimensions(font_id, note.text) 338 | 339 | padding = 8 340 | bg_x = x_pos - padding 341 | bg_y = y_pos - padding 342 | bg_width = text_width + padding * 2 343 | bg_height = text_height + padding * 2 344 | 345 | # Create fresh shader for each background to avoid state pollution 346 | bg_shader = gpu.shader.from_builtin('UNIFORM_COLOR') 347 | batch = batch_for_shader( 348 | bg_shader, 'TRI_FAN', 349 | {"pos": [ 350 | (bg_x, bg_y), 351 | (bg_x + bg_width, bg_y), 352 | (bg_x + bg_width, bg_y + bg_height), 353 | (bg_x, bg_y + bg_height) 354 | ]}, 355 | ) 356 | 357 | # Ensure blend state is correct before drawing 358 | gpu.state.blend_set('ALPHA') 359 | bg_shader.bind() 360 | bg_color = note.background_color 361 | bg_shader.uniform_float("color", (bg_color[0], bg_color[1], bg_color[2], bg_color[3])) 362 | batch.draw(bg_shader) 363 | # Unbind shader after use 364 | gpu.shader.unbind() 365 | 366 | # Draw text immediately after its background 367 | blf.color(font_id, note.font_color[0], note.font_color[1], 368 | note.font_color[2], note.font_color[3]) 369 | blf.position(font_id, x_pos, y_pos, 0) 370 | blf.draw(font_id, note.text) 371 | 372 | # Restore default GPU state 373 | gpu.state.blend_set('NONE') 374 | gpu.state.depth_test_set('LESS_EQUAL') 375 | 376 | def register_camera_notes_overlay(): 377 | """Register the camera notes overlay draw handler.""" 378 | global _camera_notes_draw_handler 379 | 380 | if _camera_notes_draw_handler is None: 381 | _camera_notes_draw_handler = bpy.types.SpaceView3D.draw_handler_add( 382 | draw_camera_notes_overlay, (), 'WINDOW', 'POST_PIXEL' 383 | ) 384 | 385 | def unregister_camera_notes_overlay(): 386 | """Unregister the camera notes overlay draw handler.""" 387 | global _camera_notes_draw_handler 388 | 389 | if _camera_notes_draw_handler is not None: 390 | bpy.types.SpaceView3D.draw_handler_remove(_camera_notes_draw_handler, 'WINDOW') 391 | _camera_notes_draw_handler = None 392 | 393 | class FavoriteCameraItem(PropertyGroup): 394 | camera: PointerProperty(type=bpy.types.Object) 395 | 396 | class CameraNoteItem(PropertyGroup): 397 | """Property group for individual camera/shot notes""" 398 | text: StringProperty( 399 | name="Note Text", 400 | description="Text content of the note", 401 | default="Note" 402 | ) 403 | camera_name: StringProperty( 404 | name="Camera Name", 405 | description="Name of the camera this note belongs to", 406 | default="" 407 | ) 408 | position_x: IntProperty( 409 | name="Position X", 410 | description="Horizontal position in pixels from left edge of camera view", 411 | default=100, 412 | soft_min=-2000, 413 | soft_max=4000 414 | ) 415 | position_y: IntProperty( 416 | name="Position Y", 417 | description="Vertical position in pixels from bottom edge of camera view", 418 | default=100, 419 | soft_min=-2000, 420 | soft_max=4000 421 | ) 422 | font_size: IntProperty( 423 | name="Font Size", 424 | description="Font size for the note", 425 | default=20, 426 | min=8, 427 | max=100 428 | ) 429 | font_color: FloatVectorProperty( 430 | name="Font Color", 431 | description="Color and opacity of the text", 432 | subtype='COLOR', 433 | size=4, 434 | min=0.0, 435 | max=1.0, 436 | default=(1.0, 1.0, 0.0, 1.0) # Yellow by default 437 | ) 438 | background_color: FloatVectorProperty( 439 | name="Background Color", 440 | description="Color and opacity of the background", 441 | subtype='COLOR', 442 | size=4, 443 | min=0.0, 444 | max=1.0, 445 | default=(0.0, 0.0, 0.0, 0.7) 446 | ) 447 | show_background: BoolProperty( 448 | name="Show Background", 449 | description="Show background behind the note", 450 | default=True 451 | ) 452 | enabled: BoolProperty( 453 | name="Enabled", 454 | description="Show this note in viewport", 455 | default=True 456 | ) 457 | 458 | class OBJECT_OT_BaseOperator(Operator): 459 | bl_options = {'REGISTER', 'UNDO'} 460 | bl_label = "Base Operator" 461 | 462 | def apply_interpolation(self, context, interpolation_type, selected_only=False): 463 | changed_count = 0 464 | for obj in context.selected_objects: 465 | if obj.animation_data and obj.animation_data.action: 466 | for fcurve in obj.animation_data.action.fcurves: 467 | for keyframe in fcurve.keyframe_points: 468 | if not selected_only or keyframe.select_control_point: 469 | if keyframe.interpolation != interpolation_type: 470 | keyframe.interpolation = interpolation_type 471 | changed_count += 1 472 | 473 | self.report({'INFO'}, f"Applied {interpolation_type} interpolation to {changed_count} keyframes") 474 | context.area.tag_redraw() 475 | return {'FINISHED'} 476 | 477 | class OBJECT_OT_toggle_default_interpolation(OBJECT_OT_BaseOperator): 478 | bl_idname = "object.toggle_default_interpolation" 479 | bl_label = "Toggle Default" 480 | 481 | def execute(self, context): 482 | preferences = context.preferences.edit 483 | current_type = preferences.keyframe_new_interpolation_type 484 | new_type = {'CONSTANT': 'LINEAR', 'LINEAR': 'BEZIER'}.get(current_type, 'CONSTANT') 485 | preferences.keyframe_new_interpolation_type = new_type 486 | self.report({'INFO'}, f"Default interpolation set to {new_type.capitalize()}") 487 | return {'FINISHED'} 488 | 489 | class OBJECT_OT_toggle_interpolation_selected(OBJECT_OT_BaseOperator): 490 | bl_idname = "object.toggle_interpolation_selected" 491 | bl_label = "Selected Keyframe" 492 | 493 | @classmethod 494 | def poll(cls, context): 495 | return (context.active_object and 496 | context.active_object.animation_data and 497 | context.active_object.animation_data.action and 498 | any(any(kp.select_control_point for kp in fc.keyframe_points) 499 | for fc in context.active_object.animation_data.action.fcurves)) 500 | 501 | def execute(self, context): 502 | for obj in context.selected_objects: 503 | if obj.animation_data and obj.animation_data.action: 504 | for fcurve in obj.animation_data.action.fcurves: 505 | for keyframe in fcurve.keyframe_points: 506 | if keyframe.select_control_point: 507 | keyframe.interpolation = 'CONSTANT' if keyframe.interpolation != 'CONSTANT' else 'BEZIER' 508 | return {'FINISHED'} 509 | 510 | class OBJECT_OT_toggle_interpolation_all(OBJECT_OT_BaseOperator): 511 | bl_idname = "object.toggle_interpolation_all" 512 | bl_label = "Selected Objects" 513 | 514 | @classmethod 515 | def poll(cls, context): 516 | return any(obj.animation_data and obj.animation_data.action for obj in context.selected_objects) 517 | 518 | def execute(self, context): 519 | for obj in context.selected_objects: 520 | if obj.animation_data and obj.animation_data.action: 521 | for fcurve in obj.animation_data.action.fcurves: 522 | for keyframe in fcurve.keyframe_points: 523 | keyframe.interpolation = 'CONSTANT' if keyframe.interpolation != 'CONSTANT' else 'BEZIER' 524 | return {'FINISHED'} 525 | 526 | class OBJECT_OT_apply_all_constant(OBJECT_OT_BaseOperator): 527 | bl_idname = "object.apply_all_constant" 528 | bl_label = "Constant" 529 | 530 | @classmethod 531 | def poll(cls, context): 532 | return any(obj.animation_data and obj.animation_data.action for obj in context.selected_objects) 533 | 534 | def execute(self, context): 535 | return self.apply_interpolation(context, 'CONSTANT') 536 | 537 | class OBJECT_OT_apply_all_bezier(OBJECT_OT_BaseOperator): 538 | bl_idname = "object.apply_all_bezier" 539 | bl_label = "Bezier" 540 | 541 | @classmethod 542 | def poll(cls, context): 543 | return any(obj.animation_data and obj.animation_data.action for obj in context.selected_objects) 544 | 545 | def execute(self, context): 546 | return self.apply_interpolation(context, 'BEZIER') 547 | 548 | class OBJECT_OT_apply_all_linear(OBJECT_OT_BaseOperator): 549 | bl_idname = "object.apply_all_linear" 550 | bl_label = "Linear" 551 | 552 | @classmethod 553 | def poll(cls, context): 554 | return any(obj.animation_data and obj.animation_data.action for obj in context.selected_objects) 555 | 556 | def execute(self, context): 557 | return self.apply_interpolation(context, 'LINEAR') 558 | 559 | class OBJECT_OT_apply_selected_constant(OBJECT_OT_BaseOperator): 560 | bl_idname = "object.apply_selected_constant" 561 | bl_label = "Constant" 562 | 563 | @classmethod 564 | def poll(cls, context): 565 | return (context.active_object and 566 | context.active_object.animation_data and 567 | context.active_object.animation_data.action and 568 | any(any(kp.select_control_point for kp in fc.keyframe_points) 569 | for fc in context.active_object.animation_data.action.fcurves)) 570 | 571 | def execute(self, context): 572 | return self.apply_interpolation(context, 'CONSTANT', selected_only=True) 573 | 574 | class OBJECT_OT_apply_selected_bezier(OBJECT_OT_BaseOperator): 575 | bl_idname = "object.apply_selected_bezier" 576 | bl_label = "Bezier" 577 | 578 | @classmethod 579 | def poll(cls, context): 580 | return (context.active_object and 581 | context.active_object.animation_data and 582 | context.active_object.animation_data.action and 583 | any(any(kp.select_control_point for kp in fc.keyframe_points) 584 | for fc in context.active_object.animation_data.action.fcurves)) 585 | 586 | def execute(self, context): 587 | return self.apply_interpolation(context, 'BEZIER', selected_only=True) 588 | 589 | class OBJECT_OT_apply_selected_linear(OBJECT_OT_BaseOperator): 590 | bl_idname = "object.apply_selected_linear" 591 | bl_label = "Linear" 592 | 593 | @classmethod 594 | def poll(cls, context): 595 | return (context.active_object and 596 | context.active_object.animation_data and 597 | context.active_object.animation_data.action and 598 | any(any(kp.select_control_point for kp in fc.keyframe_points) 599 | for fc in context.active_object.animation_data.action.fcurves)) 600 | 601 | def execute(self, context): 602 | return self.apply_interpolation(context, 'LINEAR', selected_only=True) 603 | 604 | class OBJECT_OT_toggle_auto_keying(OBJECT_OT_BaseOperator): 605 | bl_idname = "object.toggle_auto_keying" 606 | bl_label = "Auto Keying" 607 | 608 | def execute(self, context): 609 | context.scene.tool_settings.use_keyframe_insert_auto = not context.scene.tool_settings.use_keyframe_insert_auto 610 | return {'FINISHED'} 611 | 612 | class OBJECT_OT_add_keyframes_operator(OBJECT_OT_BaseOperator): 613 | bl_idname = "object.add_keyframes_operator" 614 | bl_label = "Add Per Steps" 615 | 616 | steps: IntProperty(name="Steps", default=2, min=1, description="Number of steps between keyframes") 617 | 618 | @classmethod 619 | def poll(cls, context): 620 | return (context.active_object and 621 | context.active_object.animation_data and 622 | context.active_object.animation_data.action and 623 | any(any(kp.select_control_point for kp in fc.keyframe_points) 624 | for fc in context.active_object.animation_data.action.fcurves)) 625 | 626 | def invoke(self, context, event): 627 | return context.window_manager.invoke_props_dialog(self) 628 | 629 | def execute(self, context): 630 | obj = context.object 631 | action = obj.animation_data.action 632 | 633 | if not action: 634 | self.report({'WARNING'}, "No action found") 635 | return {'CANCELLED'} 636 | 637 | for fc in action.fcurves: 638 | keyframes = [kp for kp in fc.keyframe_points if kp.select_control_point] 639 | for i in range(len(keyframes) - 1): 640 | start_kp, end_kp = keyframes[i], keyframes[i + 1] 641 | start_frame, end_frame = int(start_kp.co.x), int(end_kp.co.x) 642 | start_value, end_value = start_kp.co.y, end_kp.co.y 643 | 644 | for frame in range(start_frame + self.steps, end_frame, self.steps): 645 | t = (frame - start_frame) / (end_frame - start_frame) 646 | interpolated_value = (1 - t) * start_value + t * end_value 647 | fc.keyframe_points.insert(frame, interpolated_value) 648 | 649 | self.report({'INFO'}, f"Keyframes added successfully with step size {self.steps}") 650 | return {'FINISHED'} 651 | 652 | class OBJECT_OT_delete_keyframes_per_steps(OBJECT_OT_BaseOperator): 653 | bl_idname = "object.delete_keyframes_per_steps" 654 | bl_label = "Delete Per Steps" 655 | 656 | steps: IntProperty(name="Steps", default=1, min=1, description="Number of steps between keyframes") 657 | 658 | @classmethod 659 | def poll(cls, context): 660 | return (context.active_object and 661 | context.active_object.animation_data and 662 | context.active_object.animation_data.action and 663 | any(any(kp.select_control_point for kp in fc.keyframe_points) 664 | for fc in context.active_object.animation_data.action.fcurves)) 665 | 666 | def invoke(self, context, event): 667 | return context.window_manager.invoke_props_dialog(self) 668 | 669 | def execute(self, context): 670 | obj = context.object 671 | action = obj.animation_data.action 672 | if not action: 673 | self.report({'WARNING'}, "No animation data found") 674 | return {'CANCELLED'} 675 | 676 | for fc in action.fcurves: 677 | keyframes = [kp for kp in fc.keyframe_points if kp.select_control_point] 678 | for i in range(len(keyframes) - 1, -1, -self.steps): 679 | fc.keyframe_points.remove(keyframes[i]) 680 | 681 | self.report({'INFO'}, f"Keyframes deleted successfully with step size {self.steps}") 682 | return {'FINISHED'} 683 | 684 | class OBJECT_OT_bake_keyframes_per_steps(OBJECT_OT_BaseOperator): 685 | bl_idname = "object.bake_keyframes_per_steps" 686 | bl_label = "Bake Per Steps" 687 | 688 | steps: IntProperty(name="Steps", default=1, min=1, description="Number of steps between keyframes") 689 | bake_type: EnumProperty( 690 | name="Bake Type", 691 | items=( 692 | ('POSE', "Pose", "Bake as Pose"), 693 | ('OBJECT', "Object", "Bake as Object") 694 | ), 695 | default='POSE' 696 | ) 697 | 698 | @classmethod 699 | def poll(cls, context): 700 | return (context.active_object and 701 | context.active_object.animation_data and 702 | context.active_object.animation_data.action and 703 | any(any(kp.select_control_point for kp in fc.keyframe_points) 704 | for fc in context.active_object.animation_data.action.fcurves)) 705 | 706 | def invoke(self, context, event): 707 | return context.window_manager.invoke_props_dialog(self) 708 | 709 | def draw(self, context): 710 | layout = self.layout 711 | layout.prop(self, "steps") 712 | layout.prop(self, "bake_type") 713 | layout.label(text="Unselected keyframes will be deleted.", icon='INFO') 714 | 715 | def execute(self, context): 716 | obj = context.object 717 | action = obj.animation_data.action 718 | if not action: 719 | self.report({'WARNING'}, "No animation data found") 720 | return {'CANCELLED'} 721 | 722 | original_start_frame = context.scene.frame_start 723 | original_end_frame = context.scene.frame_end 724 | 725 | selected_keyframes = [kp.co.x for fc in action.fcurves for kp in fc.keyframe_points if kp.select_control_point] 726 | if not selected_keyframes: 727 | self.report({'WARNING'}, "No keyframes selected") 728 | return {'CANCELLED'} 729 | 730 | start_frame = int(min(selected_keyframes)) 731 | end_frame = int(max(selected_keyframes)) 732 | 733 | context.scene.frame_start = start_frame 734 | context.scene.frame_end = end_frame 735 | 736 | bpy.ops.nla.bake(frame_start=start_frame, frame_end=end_frame, step=self.steps, bake_types={self.bake_type}) 737 | 738 | context.scene.frame_start = original_start_frame 739 | context.scene.frame_end = original_end_frame 740 | 741 | self.report({'INFO'}, f"Keyframes baked successfully with step size {self.steps}") 742 | return {'FINISHED'} 743 | 744 | class SCENE_OT_set_frame(bpy.types.Operator): 745 | bl_idname = "scene.set_frame" 746 | bl_label = "Set Frame" 747 | bl_description = "Set the current frame as start or end frame" 748 | 749 | frame_type: bpy.props.EnumProperty( 750 | items=[ 751 | ('START', "Start", "Set as start frame"), 752 | ('END', "End", "Set as end frame"), 753 | ], 754 | name="Frame Type", 755 | description="Whether to set the start or end frame", 756 | ) 757 | 758 | def execute(self, context): 759 | if self.frame_type == 'START': 760 | context.scene.frame_start = context.scene.frame_current 761 | self.report({'INFO'}, f"Start frame set to {context.scene.frame_start}") 762 | else: 763 | context.scene.frame_end = context.scene.frame_current 764 | self.report({'INFO'}, f"End frame set to {context.scene.frame_end}") 765 | return {'FINISHED'} 766 | 767 | def is_in_exclude_hidden(obj): 768 | exclude_hidden_collection = bpy.data.collections.get("Exclude Hidden") 769 | return obj.hide_render or (exclude_hidden_collection and exclude_hidden_collection in obj.users_collection) 770 | 771 | class RenderToolsSettings(PropertyGroup): 772 | affect_children: BoolProperty( 773 | name="Affect Children", 774 | default=False, 775 | description="Apply the action to child objects of hidden objects" 776 | ) 777 | 778 | exception_collection: StringProperty( 779 | name="Exception Collection", 780 | default="Render_Exceptions", 781 | description="Objects in this collection will not be affected by the disable render operation" 782 | ) 783 | 784 | class OBJECT_OT_ApplyRenderToolsSettings(Operator): 785 | bl_idname = "object.apply_render_tools_settings" 786 | bl_label = "Apply Render Tools Settings" 787 | bl_description = "Apply the current Render Tools settings to hidden objects" 788 | 789 | def execute(self, context): 790 | settings = context.scene.render_tools_settings 791 | hidden_objects = [obj for obj in bpy.data.objects if obj.hide_get()] 792 | affected_count = 0 793 | 794 | for obj in hidden_objects: 795 | self.process_object(obj, settings) 796 | affected_count += 1 797 | 798 | if settings.affect_children: 799 | for child in obj.children_recursive: 800 | self.process_object(child, settings) 801 | affected_count += 1 802 | 803 | self.report({'INFO'}, f"Applied settings to {affected_count} objects") 804 | return {'FINISHED'} 805 | 806 | def process_object(self, obj, settings): 807 | if settings.action == 'DISABLE': 808 | obj.hide_render = True 809 | elif settings.action == 'COLLECTION': 810 | collection = bpy.data.collections.get(settings.collection_name) 811 | if not collection: 812 | collection = bpy.data.collections.new(settings.collection_name) 813 | bpy.context.scene.collection.children.link(collection) 814 | if obj.name not in collection.objects: 815 | collection.objects.link(obj) 816 | 817 | class SelectHiddenDisableRenderOperator(Operator): 818 | bl_idname = "object.select_hidden_disable_render" 819 | bl_label = "Disable Renders for Hidden Objects" 820 | 821 | def execute(self, context): 822 | exception_collection = bpy.data.collections.get("Exclude Hidden") 823 | selected_objects = [obj for obj in context.view_layer.objects if obj.hide_get() and 824 | (not exception_collection or obj.name not in exception_collection.objects)] 825 | 826 | for obj in selected_objects: 827 | obj.hide_render = True 828 | obj.select_set(True) 829 | 830 | # Deselect all objects first 831 | bpy.ops.object.select_all(action='DESELECT') 832 | 833 | # Then select the hidden objects 834 | for obj in selected_objects: 835 | obj.select_set(True) 836 | 837 | self.report({'INFO'}, f"Successfully disabled renders for {len(selected_objects)} hidden objects") 838 | return {'FINISHED'} 839 | 840 | class OBJECT_OT_DisableRenderForHidden(Operator): 841 | bl_idname = "object.disable_render_for_hidden" 842 | bl_label = "Disable Render for Hidden Objects" 843 | bl_description = "Disable render for all hidden objects, recursively checking collections" 844 | 845 | def process_collection(self, collection, exception_collection, settings, stats): 846 | """Recursively process a collection and its objects""" 847 | # Process all objects in this collection 848 | for obj in collection.objects: 849 | if obj.hide_get() and (not exception_collection or obj.name not in exception_collection.objects): 850 | obj.hide_render = True 851 | stats['affected_count'] += 1 852 | 853 | # Process children if enabled 854 | if settings.affect_children: 855 | for child in obj.children_recursive: 856 | if not exception_collection or child.name not in exception_collection.objects: 857 | child.hide_render = True 858 | stats['affected_count'] += 1 859 | 860 | # Recursively process child collections 861 | for child_collection in collection.children: 862 | self.process_collection(child_collection, exception_collection, settings, stats) 863 | 864 | def execute(self, context): 865 | settings = context.scene.render_tools_settings 866 | exception_collection = bpy.data.collections.get(settings.exception_collection) 867 | 868 | stats = {'affected_count': 0} 869 | 870 | # Start with the master collection and process recursively 871 | self.process_collection(context.scene.collection, exception_collection, settings, stats) 872 | 873 | self.report({'INFO'}, f"Disabled render for {stats['affected_count']} hidden objects") 874 | return {'FINISHED'} 875 | 876 | class OBJECT_OT_CreateExceptionCollection(Operator): 877 | bl_idname = "object.create_exception_collection" 878 | bl_label = "Create Exception Collection" 879 | bl_description = "Create a new collection for render exceptions" 880 | 881 | def execute(self, context): 882 | settings = context.scene.render_tools_settings 883 | 884 | if not settings.exception_collection: 885 | self.report({'ERROR'}, "Exception collection name cannot be empty") 886 | return {'CANCELLED'} 887 | 888 | if not bpy.data.collections.get(settings.exception_collection): 889 | new_collection = bpy.data.collections.new(settings.exception_collection) 890 | context.scene.collection.children.link(new_collection) 891 | self.report({'INFO'}, f"Created exception collection: {settings.exception_collection}") 892 | else: 893 | self.report({'INFO'}, f"Exception collection already exists: {settings.exception_collection}") 894 | return {'FINISHED'} 895 | 896 | class OBJECT_OT_AddSelectedToExceptions(Operator): 897 | bl_idname = "object.add_selected_to_exceptions" 898 | bl_label = "Add Selected to Exceptions" 899 | bl_description = "Add selected objects and their children to the exception collection" 900 | 901 | def process_object_hierarchy(self, obj, exception_collection, stats): 902 | """Process an object and optionally its child hierarchy""" 903 | if obj.name not in exception_collection.objects: 904 | try: 905 | exception_collection.objects.link(obj) 906 | stats['added_count'] += 1 907 | except RuntimeError: 908 | # Object might already be in the collection 909 | pass 910 | 911 | # Process child objects 912 | for child in obj.children_recursive: 913 | if child.name not in exception_collection.objects: 914 | try: 915 | exception_collection.objects.link(child) 916 | stats['added_count'] += 1 917 | except RuntimeError: 918 | pass 919 | 920 | def execute(self, context): 921 | settings = context.scene.render_tools_settings 922 | exception_collection = bpy.data.collections.get(settings.exception_collection) 923 | 924 | if not exception_collection: 925 | exception_collection = bpy.data.collections.new(settings.exception_collection) 926 | context.scene.collection.children.link(exception_collection) 927 | 928 | stats = {'added_count': 0} 929 | 930 | # Process selected objects and their hierarchies 931 | for obj in context.selected_objects: 932 | # If the selected object is a collection 933 | if obj.instance_type == 'COLLECTION' and obj.instance_collection: 934 | for collection_obj in obj.instance_collection.all_objects: 935 | self.process_object_hierarchy(collection_obj, exception_collection, stats) 936 | else: 937 | self.process_object_hierarchy(obj, exception_collection, stats) 938 | 939 | self.report({'INFO'}, f"Added {stats['added_count']} objects to the exception collection") 940 | return {'FINISHED'} 941 | 942 | class CreateExcludeHiddenCollectionOperator(OBJECT_OT_BaseOperator): 943 | bl_idname = "object.create_exclude_hidden_collection" 944 | bl_label = "Create 'Exclude Hidden' Collection" 945 | 946 | def execute(self, context): 947 | exclude_hidden_collection = bpy.data.collections.get("Exclude Hidden") 948 | if not exclude_hidden_collection: 949 | exclude_hidden_collection = bpy.data.collections.new("Exclude Hidden") 950 | context.scene.collection.children.link(exclude_hidden_collection) 951 | 952 | for obj in context.selected_objects: 953 | if obj.type != 'COLLECTION': 954 | exclude_hidden_collection.objects.link(obj) 955 | 956 | self.report({'INFO'}, f"Successfully added selected objects to excluded collection") 957 | return {'FINISHED'} 958 | 959 | #def get_collection_names(self, context): 960 | # return [(coll.name, coll.name, "") for coll in bpy.data.collections] 961 | 962 | def update_collection_index(self, context): 963 | context.area.tag_redraw() 964 | 965 | class CustomNameProperties(PropertyGroup): 966 | collection_name: StringProperty(name="New Collection", default="Cameras") 967 | camera_name: StringProperty(name="Camera", default="Camera") 968 | shot_name: StringProperty(name="Shot", default="Shot") 969 | 970 | favorite_cameras: CollectionProperty( 971 | type=FavoriteCameraItem, 972 | name="Favorite Cameras", 973 | description="List of favorite cameras" 974 | ) 975 | 976 | camera_collection: PointerProperty( 977 | name="Camera Collection", 978 | type=bpy.types.Collection, 979 | description="Select a collection for cameras" 980 | ) 981 | 982 | shot_list_collection: PointerProperty( 983 | name="Shot List Collection", 984 | type=bpy.types.Collection, 985 | description="Select a collection for the shot list" 986 | ) 987 | 988 | camera_list_collection: PointerProperty( 989 | name="Camera List Collection", 990 | type=bpy.types.Collection, 991 | description="Select a collection for the camera list" 992 | ) 993 | 994 | def get_active_collection(self, context): 995 | return self.camera_collection 996 | 997 | def get_shot_list_collection(self, context): 998 | return self.shot_list_collection 999 | 1000 | def get_camera_list_collection(self, context): 1001 | return self.camera_list_collection 1002 | 1003 | def set_active_collections(self, collection): 1004 | self.camera_collection = collection 1005 | self.shot_list_collection = collection 1006 | self.camera_list_collection = collection 1007 | 1008 | default_passepartout: FloatProperty( 1009 | name="Passepartout", 1010 | description="Default passepartout alpha for new cameras", 1011 | default=0.5, 1012 | min=0.0, 1013 | max=1.0 1014 | ) 1015 | default_type: EnumProperty( 1016 | name="Camera Type", 1017 | items=[ 1018 | ('PERSP', "Perspective", "Perspective camera"), 1019 | ('ORTHO', "Orthographic", "Orthographic camera"), 1020 | ('PANO', "Panoramic", "Panoramic camera") 1021 | ], 1022 | default='PERSP' 1023 | ) 1024 | default_clip_start: FloatProperty( 1025 | name="Clip Start", 1026 | description="Default clip start for new cameras", 1027 | default=0.1, 1028 | min=0.01, 1029 | max=1000.0 1030 | ) 1031 | default_clip_end: FloatProperty( 1032 | name="Clip End", 1033 | description="Default clip end for new cameras", 1034 | default=1000.0, 1035 | min=1.0, 1036 | max=10000.0 1037 | ) 1038 | 1039 | default_lens: FloatProperty( 1040 | name="Focal Length", 1041 | description="Default focal length for new perspective cameras", 1042 | default=50.0, 1043 | min=1.0, 1044 | max=5000.0 1045 | ) 1046 | default_ortho_scale: FloatProperty( 1047 | name="Ortho Scale", 1048 | description="Default orthographic scale for new orthographic cameras", 1049 | default=6.0, 1050 | min=0.01, 1051 | max=1000.0 1052 | ) 1053 | 1054 | 1055 | class OBJECT_OT_ApplyPassepartoutToAllCameras(Operator): 1056 | bl_idname = "object.apply_passepartout_to_all_cameras" 1057 | bl_label = "Apply to All Cameras" 1058 | bl_description = "Apply the default passepartout value to all cameras in the scene" 1059 | bl_options = {'REGISTER', 'UNDO'} 1060 | 1061 | def execute(self, context): 1062 | props = context.scene.custom_name_props 1063 | passepartout_value = props.default_passepartout 1064 | 1065 | # Count cameras that will be affected 1066 | camera_count = 0 1067 | for obj in bpy.data.objects: 1068 | if obj.type == 'CAMERA': 1069 | obj.data.passepartout_alpha = passepartout_value 1070 | camera_count += 1 1071 | 1072 | if camera_count > 0: 1073 | self.report({'INFO'}, f"Applied passepartout value {passepartout_value:.2f} to {camera_count} camera(s)") 1074 | else: 1075 | self.report({'WARNING'}, "No cameras found in the scene") 1076 | 1077 | return {'FINISHED'} 1078 | 1079 | 1080 | class OBJECT_OT_CreateCameraCollection(Operator): 1081 | bl_idname = "object.create_camera_collection" 1082 | bl_label = "Create Camera Collection" 1083 | bl_description = "Create a new collection for cameras" 1084 | bl_options = {'REGISTER', 'UNDO'} 1085 | 1086 | def execute(self, context): 1087 | props = context.scene.custom_name_props 1088 | new_collection = bpy.data.collections.new(props.collection_name) 1089 | context.scene.collection.children.link(new_collection) 1090 | 1091 | # Set the new collection as the active one 1092 | props.camera_collection = new_collection 1093 | props.shot_list_collection = new_collection 1094 | props.camera_list_collection = new_collection 1095 | 1096 | # Force UI update 1097 | for area in context.screen.areas: 1098 | area.tag_redraw() 1099 | 1100 | self.report({'INFO'}, f"Created new camera collection: {new_collection.name}") 1101 | return {'FINISHED'} 1102 | 1103 | class AddCameraButton(OBJECT_OT_BaseOperator): 1104 | bl_idname = "object.add_camera" 1105 | bl_label = "Add Camera" 1106 | bl_description = "Add a camera and increment its name" 1107 | 1108 | def execute(self, context): 1109 | scene = context.scene 1110 | props = scene.custom_name_props 1111 | active_collection = get_active_collection(context, self) 1112 | if not active_collection: 1113 | active_collection = bpy.data.collections.new("Cameras") 1114 | context.scene.collection.children.link(active_collection) 1115 | self.report({'INFO'}, "Created new 'Cameras' collection") 1116 | 1117 | camera_base_name = props.camera_name or "Camera" 1118 | camera_count = sum(1 for obj in active_collection.objects if obj.type == 'CAMERA') 1119 | 1120 | new_camera_data = bpy.data.cameras.new(name=f"{camera_base_name} {camera_count + 1}") 1121 | new_camera = bpy.data.objects.new(name=f"{camera_base_name} {camera_count + 1}", object_data=new_camera_data) 1122 | 1123 | new_camera_data.type = props.default_type 1124 | new_camera_data.passepartout_alpha = props.default_passepartout 1125 | new_camera_data.clip_start = props.default_clip_start 1126 | new_camera_data.clip_end = props.default_clip_end 1127 | if new_camera_data.type == 'ORTHO': 1128 | new_camera_data.ortho_scale = props.default_ortho_scale 1129 | else: 1130 | new_camera_data.lens = props.default_lens 1131 | 1132 | # Get the view matrix and apply it to the camera 1133 | view_matrix = get_view_matrix_from_context(context) 1134 | new_camera.matrix_world = view_matrix.inverted() 1135 | 1136 | active_collection.objects.link(new_camera) 1137 | scene.camera = new_camera 1138 | new_camera.select_set(True) 1139 | context.view_layer.objects.active = new_camera 1140 | props.set_active_collections(active_collection) 1141 | 1142 | return {'FINISHED'} 1143 | 1144 | class AddCameraWithMarkerButton(OBJECT_OT_BaseOperator): 1145 | bl_idname = "object.add_camera_with_marker" 1146 | bl_label = "Add Camera Shots" 1147 | bl_description = "Add camera, increment its name, and add a bind marker in the timeline" 1148 | 1149 | def execute(self, context): 1150 | scene = context.scene 1151 | props = scene.custom_name_props 1152 | active_collection = get_active_collection(context, self) 1153 | if not active_collection: 1154 | active_collection = bpy.data.collections.new("Cameras") 1155 | context.scene.collection.children.link(active_collection) 1156 | self.report({'INFO'}, "Created new 'Cameras' collection") 1157 | 1158 | shot_base_name = props.shot_name or "Shot" 1159 | # Count only cameras that have associated markers (shots), not all cameras 1160 | camera_objs = [obj for obj in active_collection.objects if obj.type == 'CAMERA'] 1161 | shot_count = sum(1 for obj in camera_objs 1162 | if any(marker.camera == obj for marker in scene.timeline_markers)) 1163 | 1164 | camera_name = f"{shot_base_name} {shot_count + 1}" 1165 | marker_name = camera_name 1166 | new_camera = bpy.data.cameras.new(name=camera_name) 1167 | camera_object = bpy.data.objects.new(name=camera_name, object_data=new_camera) 1168 | 1169 | new_camera.type = props.default_type 1170 | new_camera.passepartout_alpha = props.default_passepartout 1171 | new_camera.clip_start = props.default_clip_start 1172 | new_camera.clip_end = props.default_clip_end 1173 | if new_camera.type == 'ORTHO': 1174 | new_camera.ortho_scale = props.default_ortho_scale 1175 | else: 1176 | new_camera.lens = props.default_lens 1177 | 1178 | # Get the view matrix and apply it to the camera 1179 | view_matrix = get_view_matrix_from_context(context) 1180 | camera_object.matrix_world = view_matrix.inverted() 1181 | 1182 | active_collection.objects.link(camera_object) 1183 | marker = scene.timeline_markers.new(name=marker_name, frame=scene.frame_current) 1184 | marker.camera = camera_object 1185 | 1186 | scene.camera = camera_object 1187 | camera_object.select_set(True) 1188 | context.view_layer.objects.active = camera_object 1189 | props.set_active_collections(active_collection) 1190 | 1191 | return {'FINISHED'} 1192 | 1193 | class AddCameraCopyPropertiesButton(OBJECT_OT_BaseOperator): 1194 | bl_idname = "object.add_camera_copy_properties" 1195 | bl_label = "Add Camera Copying Properties" 1196 | bl_description = "Create a new camera copying properties from the active camera, but not keyframes" 1197 | 1198 | def execute(self, context): 1199 | scene = context.scene 1200 | props = scene.custom_name_props 1201 | active_collection = get_active_collection(context, self) 1202 | if not active_collection: 1203 | active_collection = bpy.data.collections.new("Cameras") 1204 | context.scene.collection.children.link(active_collection) 1205 | self.report({'INFO'}, "Created new 'Cameras' collection") 1206 | 1207 | # Validate that there's an active camera 1208 | if not context.scene.camera or context.scene.camera.type != 'CAMERA': 1209 | self.report({'ERROR'}, "No active camera in scene") 1210 | return {'CANCELLED'} 1211 | 1212 | camera_base_name = props.camera_name or "Camera" 1213 | camera_count = sum(1 for obj in active_collection.objects if obj.type == 'CAMERA') 1214 | 1215 | new_camera_data = context.scene.camera.data.copy() 1216 | new_camera_data.animation_data_clear() 1217 | 1218 | new_camera = bpy.data.objects.new(name=f"{camera_base_name} {camera_count + 1}", object_data=new_camera_data) 1219 | 1220 | # Get the view matrix and apply it to the camera 1221 | view_matrix = get_view_matrix_from_context(context) 1222 | new_camera.matrix_world = view_matrix.inverted() 1223 | 1224 | active_collection.objects.link(new_camera) 1225 | scene.camera = new_camera 1226 | new_camera.select_set(True) 1227 | context.view_layer.objects.active = new_camera 1228 | props.set_active_collections(active_collection) 1229 | 1230 | return {'FINISHED'} 1231 | 1232 | class AddCameraShotCopyPropertiesButton(OBJECT_OT_BaseOperator): 1233 | bl_idname = "object.add_camera_shot_copy_properties" 1234 | bl_label = "Add Camera Shot Copying Properties" 1235 | bl_description = "Create a new camera copying properties from the active camera, but not keyframes" 1236 | 1237 | def execute(self, context): 1238 | scene = context.scene 1239 | props = scene.custom_name_props 1240 | active_collection = get_active_collection(context, self) 1241 | if not active_collection: 1242 | active_collection = bpy.data.collections.new("Cameras") 1243 | context.scene.collection.children.link(active_collection) 1244 | self.report({'INFO'}, "Created new 'Cameras' collection") 1245 | 1246 | # Validate that there's an active camera 1247 | if not context.scene.camera or context.scene.camera.type != 'CAMERA': 1248 | self.report({'ERROR'}, "No active camera in scene") 1249 | return {'CANCELLED'} 1250 | 1251 | shot_base_name = props.shot_name or "Shot" 1252 | # Count only cameras that have associated markers (shots), not all cameras 1253 | camera_objs = [obj for obj in active_collection.objects if obj.type == 'CAMERA'] 1254 | shot_count = sum(1 for obj in camera_objs 1255 | if any(marker.camera == obj for marker in scene.timeline_markers)) 1256 | 1257 | camera_name = f"{shot_base_name} {shot_count + 1}" 1258 | new_camera_data = context.scene.camera.data.copy() 1259 | new_camera_data.animation_data_clear() 1260 | 1261 | new_camera = bpy.data.objects.new(name=camera_name, object_data=new_camera_data) 1262 | 1263 | # Get the view matrix and apply it to the camera 1264 | view_matrix = get_view_matrix_from_context(context) 1265 | new_camera.matrix_world = view_matrix.inverted() 1266 | 1267 | active_collection.objects.link(new_camera) 1268 | scene.camera = new_camera 1269 | new_camera.select_set(True) 1270 | context.view_layer.objects.active = new_camera 1271 | props.set_active_collections(active_collection) 1272 | 1273 | marker = scene.timeline_markers.new(name=camera_name, frame=scene.frame_current) 1274 | marker.camera = new_camera 1275 | 1276 | return {'FINISHED'} 1277 | 1278 | class SCENE_OT_SetPreviewRange(OBJECT_OT_BaseOperator): 1279 | bl_idname = "scene.set_preview_range" 1280 | bl_label = "Set Preview Range" 1281 | 1282 | start_frame: IntProperty() 1283 | end_frame: IntProperty() 1284 | toggle: BoolProperty(name="Toggle Preview Range", default=True) 1285 | 1286 | def execute(self, context): 1287 | scene = context.scene 1288 | scene.use_preview_range = self.toggle 1289 | if self.toggle: 1290 | scene.frame_preview_start = self.start_frame 1291 | scene.frame_preview_end = self.end_frame 1292 | return {'FINISHED'} 1293 | 1294 | def show_message_box(message="", title="Message Box", icon='INFO'): 1295 | def draw(self, context): 1296 | self.layout.label(text=message) 1297 | bpy.context.window_manager.popup_menu(draw, title=title, icon=icon) 1298 | 1299 | class ShowPopupMessageOperator(OBJECT_OT_BaseOperator): 1300 | bl_idname = "wm.show_popup_message" 1301 | bl_label = "Show Info Message" 1302 | 1303 | def execute(self, context): 1304 | show_message_box("Empty the text field to use existing collection.", "Add Collection", 'INFO') 1305 | return {'FINISHED'} 1306 | 1307 | class OBJECT_OT_ViewportRenderConfirm(OBJECT_OT_BaseOperator): 1308 | bl_idname = "object.viewport_render_confirm" 1309 | bl_label = "Viewport Render Confirm" 1310 | 1311 | def invoke(self, context, event): 1312 | return context.window_manager.invoke_confirm(self, event) 1313 | 1314 | def execute(self, context): 1315 | scene = context.scene 1316 | settings = scene.viewport_render_settings 1317 | 1318 | try: 1319 | output_dir = ensure_output_directory(settings.output_directory) 1320 | except Exception as e: 1321 | report_error(self, f"Failed to create output directory: {e}") 1322 | return {'CANCELLED'} 1323 | 1324 | filename = generate_output_filename(output_dir, settings.filename_suffix) 1325 | 1326 | # Set the render output path 1327 | scene.render.filepath = filename 1328 | 1329 | # Perform the viewport render 1330 | bpy.ops.render.opengl(animation=True, write_still=True, view_context=True) 1331 | 1332 | # Optionally preview the render 1333 | if settings.preview_render: 1334 | bpy.ops.render.play_rendered_anim() 1335 | 1336 | self.report({'INFO'}, f"Viewport render saved to: {scene.render.filepath}") 1337 | return {'FINISHED'} 1338 | 1339 | class OBJECT_OT_ViewportRenderSettings(OBJECT_OT_BaseOperator): 1340 | bl_idname = "object.viewport_render_settings" 1341 | bl_label = "Viewport Render Settings" 1342 | 1343 | def invoke(self, context, event): 1344 | return context.window_manager.invoke_props_dialog(self) 1345 | 1346 | def draw(self, context): 1347 | layout = self.layout 1348 | settings = context.scene.viewport_render_settings 1349 | 1350 | layout.prop(settings, "output_directory") 1351 | layout.operator("object.open_viewport_render_directory", text="Open Output Directory") 1352 | layout.prop(settings, "preview_render") 1353 | layout.prop(settings, "save_file") 1354 | layout.prop(settings, "filename_suffix") 1355 | layout.prop(settings, "include_timecode") 1356 | 1357 | if settings.include_timecode: 1358 | box = layout.box() 1359 | box.label(text="Stamp Settings") 1360 | box.prop(settings, "stamp_background") 1361 | box.prop(settings, "stamp_foreground") 1362 | box.prop(settings, "stamp_font_size") 1363 | for prop in [prop for prop in dir(settings) if prop.startswith("use_stamp_")]: 1364 | box.prop(settings, prop) 1365 | 1366 | def execute(self, context): 1367 | self.report({'INFO'}, "Viewport Render settings applied.") 1368 | return {'FINISHED'} 1369 | 1370 | class OBJECT_OT_SnapshotRender(OBJECT_OT_BaseOperator): 1371 | bl_idname = "object.snapshot_render" 1372 | bl_label = "Snapshot Render" 1373 | 1374 | def invoke(self, context, event): 1375 | return context.window_manager.invoke_confirm(self, event) 1376 | 1377 | def execute(self, context): 1378 | scene = context.scene 1379 | settings = scene.snapshot_settings 1380 | 1381 | # Default to the system's temporary directory if no directory is set 1382 | output_dir = bpy.path.abspath(settings.output_directory) if settings.output_directory else tempfile.gettempdir() 1383 | if not os.path.exists(output_dir): 1384 | try: 1385 | os.makedirs(output_dir) 1386 | except Exception as e: 1387 | self.report({'ERROR'}, f"Failed to create directory: {e}") 1388 | return {'CANCELLED'} 1389 | 1390 | # Backup original render settings 1391 | original_file_format = scene.render.image_settings.file_format 1392 | original_filepath = scene.render.filepath 1393 | 1394 | # Generate the output filename 1395 | blend_name = bpy.path.basename(bpy.data.filepath) 1396 | blend_name = os.path.splitext(blend_name)[0] if blend_name else "untitled" 1397 | timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") 1398 | suffix = settings.filename_suffix 1399 | filename = f"{blend_name}_{suffix}_{timestamp}.png" 1400 | file_path = os.path.join(output_dir, filename) 1401 | 1402 | # Update render settings 1403 | scene.render.image_settings.file_format = 'PNG' 1404 | scene.render.filepath = file_path 1405 | 1406 | # Perform the snapshot render 1407 | bpy.ops.render.opengl(write_still=True, view_context=True) 1408 | 1409 | # Restore original settings 1410 | scene.render.image_settings.file_format = original_file_format 1411 | scene.render.filepath = original_filepath 1412 | 1413 | # Optionally preview the rendered snapshot 1414 | if settings.preview_render: 1415 | bpy.ops.render.view_show('INVOKE_DEFAULT') 1416 | 1417 | self.report({'INFO'}, f"Snapshot saved to: {file_path}") 1418 | return {'FINISHED'} 1419 | 1420 | class OBJECT_OT_SnapshotRenderSettings(OBJECT_OT_BaseOperator): 1421 | bl_idname = "object.snapshot_render_settings" 1422 | bl_label = "Snapshot Render Settings" 1423 | 1424 | def invoke(self, context, event): 1425 | return context.window_manager.invoke_props_dialog(self) 1426 | 1427 | def draw(self, context): 1428 | layout = self.layout 1429 | settings = context.scene.snapshot_settings 1430 | 1431 | layout.prop(settings, "output_directory") 1432 | layout.operator("object.open_snapshot_directory", text="Open Output Directory") 1433 | layout.prop(settings, "preview_render") 1434 | layout.prop(settings, "save_file") 1435 | layout.prop(settings, "filename_suffix") 1436 | 1437 | def execute(self, context): 1438 | self.report({'INFO'}, "Settings applied.") 1439 | return {'FINISHED'} 1440 | 1441 | class OBJECT_OT_OpenSnapshotDirectory(OBJECT_OT_BaseOperator): 1442 | bl_idname = "object.open_snapshot_directory" 1443 | bl_label = "Open Snapshot Directory" 1444 | 1445 | def execute(self, context): 1446 | output_dir = context.scene.snapshot_settings.output_directory 1447 | success, message = safe_open_directory(output_dir) 1448 | 1449 | if success: 1450 | self.report({'INFO'}, message) 1451 | return {'FINISHED'} 1452 | else: 1453 | self.report({'ERROR'}, message) 1454 | return {'CANCELLED'} 1455 | 1456 | class OBJECT_OT_OpenOutputDirectory(OBJECT_OT_BaseOperator): 1457 | bl_idname = "object.open_output_directory" 1458 | bl_label = "Open Output Directory" 1459 | 1460 | def execute(self, context): 1461 | settings = context.scene.viewport_render_settings 1462 | output_dir = bpy.path.abspath(settings.output_directory) 1463 | success, message = safe_open_directory(output_dir) 1464 | 1465 | if success: 1466 | self.report({'INFO'}, message) 1467 | return {'FINISHED'} 1468 | else: 1469 | self.report({'ERROR'}, message) 1470 | return {'CANCELLED'} 1471 | 1472 | class SCENE_OT_JumpToMarker(OBJECT_OT_BaseOperator): 1473 | bl_idname = "scene.jump_to_marker" 1474 | bl_label = "Set as Active Camera" 1475 | 1476 | marker_name: StringProperty() 1477 | 1478 | def execute(self, context): 1479 | marker = context.scene.timeline_markers.get(self.marker_name) 1480 | if marker: 1481 | context.scene.frame_current = marker.frame 1482 | return {'FINISHED'} 1483 | return {'CANCELLED'} 1484 | 1485 | class SCENE_OT_RemoveAllMarkers(OBJECT_OT_BaseOperator): 1486 | bl_idname = "scene.remove_all_markers" 1487 | bl_label = "Remove All Markers" 1488 | 1489 | def invoke(self, context, event): 1490 | return context.window_manager.invoke_confirm(self, event) 1491 | 1492 | def execute(self, context): 1493 | # Create a list copy to avoid modifying collection during iteration 1494 | markers_to_remove = list(context.scene.timeline_markers) 1495 | for marker in markers_to_remove: 1496 | context.scene.timeline_markers.remove(marker) 1497 | self.report({'INFO'}, "All markers have been removed") 1498 | return {'FINISHED'} 1499 | 1500 | class SCENE_OT_RemoveMarkerAndCamera(OBJECT_OT_BaseOperator): 1501 | bl_idname = "scene.remove_marker_and_camera" 1502 | bl_label = "Remove Marker and Camera" 1503 | 1504 | marker_name: StringProperty() 1505 | 1506 | def execute(self, context): 1507 | marker = context.scene.timeline_markers.get(self.marker_name) 1508 | if marker: 1509 | if marker.camera and bpy.data.objects.get(marker.camera.name): 1510 | bpy.data.objects.remove(bpy.data.objects[marker.camera.name]) 1511 | context.scene.timeline_markers.remove(marker) 1512 | self.report({'INFO'}, f"Marker '{self.marker_name}' removed") 1513 | return {'FINISHED'} 1514 | self.report({'WARNING'}, f"Marker '{self.marker_name}' not found") 1515 | return {'CANCELLED'} 1516 | 1517 | class SCENE_OT_CleanUpMarkers(OBJECT_OT_BaseOperator): 1518 | bl_idname = "scene.clean_up_markers" 1519 | bl_label = "Clean Up Markers" 1520 | 1521 | def invoke(self, context, event): 1522 | return context.window_manager.invoke_confirm(self, event) 1523 | 1524 | def execute(self, context): 1525 | scene = context.scene 1526 | selected_collection = bpy.data.collections[scene.collection_index] if scene.collection_index < len(bpy.data.collections) else None 1527 | 1528 | if selected_collection: 1529 | invalid_markers = [marker for marker in scene.timeline_markers 1530 | if not marker.camera or 1531 | marker.camera.name not in selected_collection.objects or 1532 | not bpy.data.objects.get(marker.camera.name)] 1533 | 1534 | for marker in invalid_markers: 1535 | scene.timeline_markers.remove(marker) 1536 | 1537 | self.report({'INFO'}, f"Removed {len(invalid_markers)} invalid markers") 1538 | else: 1539 | self.report({'WARNING'}, "No collection selected") 1540 | 1541 | return {'FINISHED'} 1542 | 1543 | class SCENE_OT_RemoveAllShotCameras(OBJECT_OT_BaseOperator): 1544 | bl_idname = "scene.remove_all_shot_cameras" 1545 | bl_label = "Remove All Shots?" 1546 | 1547 | def invoke(self, context, event): 1548 | return context.window_manager.invoke_confirm(self, event) 1549 | 1550 | def execute(self, context): 1551 | scene = context.scene 1552 | selected_collection = bpy.data.collections[scene.collection_index] if scene.collection_index < len(bpy.data.collections) else None 1553 | 1554 | if selected_collection: 1555 | markers_to_remove = [marker for marker in scene.timeline_markers if marker.camera and marker.camera.name in selected_collection.objects] 1556 | 1557 | for marker in markers_to_remove: 1558 | camera = marker.camera 1559 | scene.timeline_markers.remove(marker) 1560 | if camera: 1561 | bpy.data.objects.remove(camera) 1562 | 1563 | return {'FINISHED'} 1564 | 1565 | class SCENE_OT_RemoveAllCameras(OBJECT_OT_BaseOperator): 1566 | bl_idname = "scene.remove_all_cameras" 1567 | bl_label = "Remove All Cameras" 1568 | 1569 | def invoke(self, context, event): 1570 | return context.window_manager.invoke_confirm(self, event) 1571 | 1572 | def execute(self, context): 1573 | scene = context.scene 1574 | selected_collection = bpy.data.collections[scene.collection_index] if scene.collection_index < len(bpy.data.collections) else None 1575 | 1576 | if selected_collection: 1577 | cameras_to_remove = [obj for obj in selected_collection.objects if obj.type == 'CAMERA'] 1578 | 1579 | for camera in cameras_to_remove: 1580 | bpy.data.objects.remove(camera) 1581 | 1582 | return {'FINISHED'} 1583 | 1584 | class SCENE_OT_SelectCamera(OBJECT_OT_BaseOperator): 1585 | bl_idname = "scene.select_camera" 1586 | bl_label = "Select Camera" 1587 | 1588 | camera_name: StringProperty() 1589 | 1590 | def execute(self, context): 1591 | bpy.ops.object.select_all(action='DESELECT') 1592 | camera = bpy.data.objects.get(self.camera_name) 1593 | if camera: 1594 | context.view_layer.objects.active = camera 1595 | camera.select_set(True) 1596 | return {'FINISHED'} 1597 | return {'CANCELLED'} 1598 | 1599 | class OBJECT_OT_toggle_local_camera(OBJECT_OT_BaseOperator): 1600 | bl_idname = "object.toggle_local_camera" 1601 | bl_label = "Toggle Local Camera" 1602 | 1603 | def execute(self, context): 1604 | context.space_data.use_local_camera = not context.space_data.use_local_camera 1605 | return {'FINISHED'} 1606 | 1607 | class OBJECT_OT_toggle_camera_info_overlay(OBJECT_OT_BaseOperator): 1608 | bl_idname = "object.toggle_camera_info_overlay" 1609 | bl_label = "Toggle Camera Info Overlay" 1610 | bl_description = "Toggle camera information overlay in viewport" 1611 | 1612 | def execute(self, context): 1613 | try: 1614 | preferences = context.preferences.addons[__package__].preferences 1615 | preferences.show_camera_info_overlay = not preferences.show_camera_info_overlay 1616 | 1617 | status = "enabled" if preferences.show_camera_info_overlay else "disabled" 1618 | self.report({'INFO'}, f"Camera info overlay {status}") 1619 | return {'FINISHED'} 1620 | except (KeyError, AttributeError): 1621 | self.report({'WARNING'}, "Could not access addon preferences") 1622 | return {'CANCELLED'} 1623 | 1624 | class OBJECT_OT_toggle_camera_notes_overlay(OBJECT_OT_BaseOperator): 1625 | bl_idname = "object.toggle_camera_notes_overlay" 1626 | bl_label = "Toggle Camera Notes" 1627 | bl_description = "Toggle camera notes overlay in viewport" 1628 | 1629 | def execute(self, context): 1630 | try: 1631 | preferences = context.preferences.addons[__package__].preferences 1632 | preferences.show_camera_notes = not preferences.show_camera_notes 1633 | 1634 | status = "shown" if preferences.show_camera_notes else "hidden" 1635 | self.report({'INFO'}, f"Camera notes {status}") 1636 | return {'FINISHED'} 1637 | except (KeyError, AttributeError): 1638 | self.report({'WARNING'}, "Could not access addon preferences") 1639 | return {'CANCELLED'} 1640 | 1641 | # Global variables for interactive note placement 1642 | _note_placement_handler = None 1643 | _note_placement_data = {} 1644 | 1645 | def draw_note_placement_preview(): 1646 | """Draw preview of note being placed""" 1647 | global _note_placement_data 1648 | 1649 | if not _note_placement_data: 1650 | return 1651 | 1652 | context = bpy.context 1653 | 1654 | # Only draw in camera view 1655 | if not context.space_data or context.space_data.type != 'VIEW_3D': 1656 | return 1657 | 1658 | region_3d = context.space_data.region_3d 1659 | if not region_3d or region_3d.view_perspective != 'CAMERA': 1660 | return 1661 | 1662 | # Get mouse position and settings 1663 | mouse_x = _note_placement_data.get('mouse_x', 0) 1664 | mouse_y = _note_placement_data.get('mouse_y', 0) 1665 | text = _note_placement_data.get('text', 'Note') 1666 | font_size = _note_placement_data.get('font_size', 20) 1667 | font_color = _note_placement_data.get('font_color', (1.0, 1.0, 0.0, 1.0)) 1668 | bg_color = _note_placement_data.get('bg_color', (0.0, 0.0, 0.0, 0.5)) 1669 | 1670 | # Draw preview 1671 | font_id = 0 1672 | blf.size(font_id, font_size) 1673 | 1674 | # Calculate text dimensions for background 1675 | text_width, text_height = blf.dimensions(font_id, text) 1676 | 1677 | # Draw background 1678 | padding = 8 1679 | bg_x = mouse_x - padding 1680 | bg_y = mouse_y - padding 1681 | bg_width = text_width + padding * 2 1682 | bg_height = text_height + padding * 2 1683 | 1684 | # Set up proper GPU state for 2D overlay rendering 1685 | gpu.state.blend_set('ALPHA') 1686 | gpu.state.depth_test_set('NONE') 1687 | try: 1688 | shader = gpu.shader.from_builtin('UNIFORM_COLOR') 1689 | 1690 | # Draw background 1691 | batch_bg = batch_for_shader( 1692 | shader, 'TRI_FAN', 1693 | {"pos": [ 1694 | (bg_x, bg_y), 1695 | (bg_x + bg_width, bg_y), 1696 | (bg_x + bg_width, bg_y + bg_height), 1697 | (bg_x, bg_y + bg_height) 1698 | ]}, 1699 | ) 1700 | shader.bind() 1701 | shader.uniform_float("color", bg_color) 1702 | batch_bg.draw(shader) 1703 | 1704 | # Draw text 1705 | blf.color(font_id, font_color[0], font_color[1], font_color[2], font_color[3]) 1706 | blf.position(font_id, mouse_x, mouse_y, 0) 1707 | blf.draw(font_id, text) 1708 | 1709 | # Draw crosshair at cursor (reuse shader) 1710 | cross_size = 10 1711 | batch_cross = batch_for_shader( 1712 | shader, 'LINES', 1713 | {"pos": [ 1714 | (mouse_x - cross_size, mouse_y), 1715 | (mouse_x + cross_size, mouse_y), 1716 | (mouse_x, mouse_y - cross_size), 1717 | (mouse_x, mouse_y + cross_size) 1718 | ]}, 1719 | ) 1720 | shader.bind() 1721 | shader.uniform_float("color", (1.0, 1.0, 1.0, 0.8)) 1722 | batch_cross.draw(shader) 1723 | finally: 1724 | # Restore default GPU state 1725 | gpu.state.blend_set('NONE') 1726 | gpu.state.depth_test_set('LESS_EQUAL') 1727 | 1728 | class OBJECT_OT_add_note_interactive(Operator): 1729 | bl_idname = "object.add_note_interactive" 1730 | bl_label = "Add Note (Interactive)" 1731 | bl_description = "Place a note at cursor position. Type to edit text, scroll to scale, Shift+scroll for font color, Ctrl+scroll for bg color" 1732 | bl_options = {'REGISTER', 'UNDO'} 1733 | 1734 | camera_name: StringProperty() 1735 | 1736 | # Instance variables initialized in invoke 1737 | mouse_x: IntProperty(default=0) 1738 | mouse_y: IntProperty(default=0) 1739 | text_input: StringProperty(default="") 1740 | font_size: IntProperty(default=20) 1741 | font_color: FloatVectorProperty(size=4, default=(1.0, 1.0, 0.0, 1.0)) 1742 | bg_color: FloatVectorProperty(size=4, default=(0.0, 0.0, 0.0, 0.5)) 1743 | hue_index: IntProperty(default=2) # Start at yellow (0=Red, 1=Green, 2=Yellow, 3=Cyan, 4=Blue, 5=Magenta) 1744 | 1745 | def modal(self, context, event): 1746 | global _note_placement_data, _note_placement_handler 1747 | 1748 | context.area.tag_redraw() 1749 | 1750 | # Update mouse position 1751 | if event.type == 'MOUSEMOVE': 1752 | self.mouse_x = event.mouse_region_x 1753 | self.mouse_y = event.mouse_region_y 1754 | _note_placement_data['mouse_x'] = self.mouse_x 1755 | _note_placement_data['mouse_y'] = self.mouse_y 1756 | 1757 | # Shift + Scroll to change font color 1758 | elif event.type == 'WHEELUPMOUSE' and event.shift: 1759 | self.hue_index = (self.hue_index + 1) % 7 1760 | self.font_color = self.get_color_from_index(self.hue_index) 1761 | _note_placement_data['font_color'] = tuple(self.font_color) 1762 | return {'RUNNING_MODAL'} 1763 | 1764 | elif event.type == 'WHEELDOWNMOUSE' and event.shift: 1765 | self.hue_index = (self.hue_index - 1) % 7 1766 | self.font_color = self.get_color_from_index(self.hue_index) 1767 | _note_placement_data['font_color'] = tuple(self.font_color) 1768 | return {'RUNNING_MODAL'} 1769 | 1770 | # Ctrl + Scroll to change background opacity 1771 | elif event.type == 'WHEELUPMOUSE' and event.ctrl: 1772 | self.bg_color[3] = min(1.0, self.bg_color[3] + 0.1) 1773 | _note_placement_data['bg_color'] = tuple(self.bg_color) 1774 | return {'RUNNING_MODAL'} 1775 | 1776 | elif event.type == 'WHEELDOWNMOUSE' and event.ctrl: 1777 | self.bg_color[3] = max(0.0, self.bg_color[3] - 0.1) 1778 | _note_placement_data['bg_color'] = tuple(self.bg_color) 1779 | return {'RUNNING_MODAL'} 1780 | 1781 | # Scroll to change font size (no modifiers) 1782 | elif event.type == 'WHEELUPMOUSE' and not event.shift and not event.ctrl: 1783 | self.font_size = min(100, self.font_size + 2) 1784 | _note_placement_data['font_size'] = self.font_size 1785 | return {'RUNNING_MODAL'} 1786 | 1787 | elif event.type == 'WHEELDOWNMOUSE' and not event.shift and not event.ctrl: 1788 | self.font_size = max(8, self.font_size - 2) 1789 | _note_placement_data['font_size'] = self.font_size 1790 | return {'RUNNING_MODAL'} 1791 | 1792 | # Text input - handle all printable characters 1793 | elif event.type in {'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 1794 | 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', 1795 | 'ZERO', 'ONE', 'TWO', 'THREE', 'FOUR', 'FIVE', 'SIX', 'SEVEN', 'EIGHT', 'NINE', 1796 | 'SPACE', 'PERIOD', 'COMMA', 'MINUS', 'PLUS', 'SLASH', 'SEMI_COLON', 'QUOTE', 1797 | 'LEFT_BRACKET', 'RIGHT_BRACKET', 'BACK_SLASH', 'EQUAL', 'ACCENT_GRAVE'} and event.value == 'PRESS': 1798 | 1799 | # Convert event type to character (with shift support) 1800 | if event.shift: 1801 | shift_char_map = { 1802 | 'ZERO': ')', 'ONE': '!', 'TWO': '@', 'THREE': '#', 'FOUR': '$', 1803 | 'FIVE': '%', 'SIX': '^', 'SEVEN': '&', 'EIGHT': '*', 'NINE': '(', 1804 | 'PERIOD': '>', 'COMMA': '<', 'MINUS': '_', 'PLUS': '+', 'SLASH': '?', 1805 | 'SEMI_COLON': ':', 'QUOTE': '"', 'LEFT_BRACKET': '{', 'RIGHT_BRACKET': '}', 1806 | 'BACK_SLASH': '|', 'EQUAL': '+', 'ACCENT_GRAVE': '~' 1807 | } 1808 | if event.type in shift_char_map: 1809 | char = shift_char_map[event.type] 1810 | else: 1811 | # Letters to uppercase 1812 | char = event.type.upper() if len(event.type) == 1 else event.type.lower() 1813 | else: 1814 | char_map = { 1815 | 'ZERO': '0', 'ONE': '1', 'TWO': '2', 'THREE': '3', 'FOUR': '4', 1816 | 'FIVE': '5', 'SIX': '6', 'SEVEN': '7', 'EIGHT': '8', 'NINE': '9', 1817 | 'SPACE': ' ', 'PERIOD': '.', 'COMMA': ',', 'MINUS': '-', 'PLUS': '=', 1818 | 'SLASH': '/', 'SEMI_COLON': ';', 'QUOTE': "'", 'LEFT_BRACKET': '[', 1819 | 'RIGHT_BRACKET': ']', 'BACK_SLASH': '\\', 'EQUAL': '=', 'ACCENT_GRAVE': '`' 1820 | } 1821 | 1822 | if event.type in char_map: 1823 | char = char_map[event.type] 1824 | else: 1825 | char = event.type.lower() 1826 | 1827 | self.text_input += char 1828 | _note_placement_data['text'] = self.text_input if self.text_input else "Note" 1829 | return {'RUNNING_MODAL'} 1830 | 1831 | # Backspace to delete characters 1832 | elif event.type == 'BACK_SPACE' and event.value == 'PRESS': 1833 | if self.text_input: 1834 | self.text_input = self.text_input[:-1] 1835 | _note_placement_data['text'] = self.text_input if self.text_input else "Note" 1836 | return {'RUNNING_MODAL'} 1837 | 1838 | # Left click to confirm placement 1839 | elif event.type == 'LEFTMOUSE' and event.value == 'PRESS': 1840 | # Calculate normalized camera-plane coordinates 1841 | scene = context.scene 1842 | region = context.region 1843 | rv3d = context.space_data.region_3d 1844 | cam = bpy.data.objects.get(self.camera_name) 1845 | 1846 | norm_x = 0.5 1847 | norm_y = 0.5 1848 | 1849 | # Try to get normalized position on camera image plane 1850 | try: 1851 | frame_local = cam.data.view_frame() 1852 | fw = [cam.matrix_world @ v for v in frame_local] 1853 | 1854 | # Ray from view through mouse 1855 | mouse_region = Vector((self.mouse_x, self.mouse_y)) 1856 | ray_origin = region_2d_to_origin_3d(region, rv3d, mouse_region) 1857 | ray_dir = region_2d_to_vector_3d(region, rv3d, mouse_region) 1858 | 1859 | # Define plane using camera frame 1860 | bl_world = fw[3] 1861 | br_world = fw[2] 1862 | tl_world = fw[0] 1863 | plane_normal = (br_world - bl_world).cross(tl_world - bl_world).normalized() 1864 | 1865 | # Intersect ray with plane 1866 | denom = ray_dir.dot(plane_normal) 1867 | if abs(denom) > 1e-6: 1868 | t = (bl_world - ray_origin).dot(plane_normal) / denom 1869 | point_world = ray_origin + ray_dir * t 1870 | point_local = cam.matrix_world.inverted() @ point_world 1871 | 1872 | # Solve for normalized coordinates 1873 | bl_local = frame_local[3] 1874 | r = frame_local[2] - frame_local[3] 1875 | u = frame_local[0] - frame_local[3] 1876 | 1877 | mat = mathutils.Matrix(((r.x, u.x), (r.y, u.y))) 1878 | vec = mathutils.Vector((point_local.x - bl_local.x, point_local.y - bl_local.y)) 1879 | if mat.determinant() != 0: 1880 | sol = mat.inverted() @ vec 1881 | norm_x = sol[0] 1882 | norm_y = sol[1] 1883 | except Exception: 1884 | pass 1885 | 1886 | # Convert normalized to pixel values (multiply by reference) 1887 | pixel_x = int(round(norm_x * 1000.0)) 1888 | pixel_y = int(round(norm_y * 1000.0)) 1889 | 1890 | note = scene.camera_notes.add() 1891 | note.camera_name = self.camera_name 1892 | note.text = self.text_input if self.text_input else "Note" 1893 | note.position_x = pixel_x 1894 | note.position_y = pixel_y 1895 | note.font_size = self.font_size 1896 | note.font_color = self.font_color 1897 | note.background_color = self.bg_color 1898 | note.show_background = True # Enable background by default 1899 | scene.active_note_index = len(scene.camera_notes) - 1 1900 | 1901 | # Force viewport redraw 1902 | for area in context.screen.areas: 1903 | if area.type == 'VIEW_3D': 1904 | area.tag_redraw() 1905 | 1906 | # Cleanup 1907 | self.cleanup(context) 1908 | self.report({'INFO'}, f"Note added at ({self.mouse_x}, {self.mouse_y})") 1909 | return {'FINISHED'} 1910 | 1911 | # Right click or ESC to cancel 1912 | elif event.type in {'RIGHTMOUSE', 'ESC'} and event.value == 'PRESS': 1913 | self.cleanup(context) 1914 | self.report({'INFO'}, "Note placement cancelled") 1915 | return {'CANCELLED'} 1916 | 1917 | return {'RUNNING_MODAL'} 1918 | 1919 | def get_color_from_index(self, index): 1920 | """Get a color from a predefined palette""" 1921 | colors = [ 1922 | (1.0, 0.0, 0.0, 1.0), # Red 1923 | (0.0, 1.0, 0.0, 1.0), # Green 1924 | (1.0, 1.0, 0.0, 1.0), # Yellow 1925 | (0.0, 1.0, 1.0, 1.0), # Cyan 1926 | (0.0, 0.0, 1.0, 1.0), # Blue 1927 | (1.0, 0.0, 1.0, 1.0), # Magenta 1928 | (1.0, 1.0, 1.0, 1.0), # White 1929 | ] 1930 | return colors[index % len(colors)] 1931 | 1932 | def invoke(self, context, event): 1933 | global _note_placement_data, _note_placement_handler 1934 | 1935 | # Check if in camera view 1936 | if not context.space_data or context.space_data.type != 'VIEW_3D': 1937 | self.report({'WARNING'}, "Must be in 3D View") 1938 | return {'CANCELLED'} 1939 | 1940 | region_3d = context.space_data.region_3d 1941 | if not region_3d or region_3d.view_perspective != 'CAMERA': 1942 | self.report({'WARNING'}, "Must be in camera view to place notes") 1943 | return {'CANCELLED'} 1944 | 1945 | # Check if camera exists 1946 | scene = context.scene 1947 | camera = bpy.data.objects.get(self.camera_name) 1948 | if not camera or camera.type != 'CAMERA': 1949 | self.report({'WARNING'}, f"Camera '{self.camera_name}' not found") 1950 | return {'CANCELLED'} 1951 | 1952 | # Reset instance variables for fresh start 1953 | self.text_input = "" 1954 | self.font_size = 20 1955 | self.hue_index = 2 # Yellow 1956 | self.font_color = self.get_color_from_index(self.hue_index) 1957 | self.bg_color = [0.0, 0.0, 0.0, 0.5] 1958 | self.mouse_x = event.mouse_region_x 1959 | self.mouse_y = event.mouse_region_y 1960 | 1961 | # Initialize placement data 1962 | _note_placement_data = { 1963 | 'mouse_x': self.mouse_x, 1964 | 'mouse_y': self.mouse_y, 1965 | 'text': 'Note', 1966 | 'font_size': 20, 1967 | 'font_color': tuple(self.font_color), 1968 | 'bg_color': tuple(self.bg_color) 1969 | } 1970 | 1971 | # Register draw handler 1972 | if _note_placement_handler is None: 1973 | _note_placement_handler = bpy.types.SpaceView3D.draw_handler_add( 1974 | draw_note_placement_preview, (), 'WINDOW', 'POST_PIXEL' 1975 | ) 1976 | 1977 | # Add modal handler 1978 | context.window_manager.modal_handler_add(self) 1979 | 1980 | self.report({'INFO'}, "Click to place note. Type to edit text. Scroll to scale. ESC to cancel.") 1981 | return {'RUNNING_MODAL'} 1982 | 1983 | def cleanup(self, context): 1984 | global _note_placement_data, _note_placement_handler 1985 | 1986 | # Remove draw handler 1987 | if _note_placement_handler is not None: 1988 | bpy.types.SpaceView3D.draw_handler_remove(_note_placement_handler, 'WINDOW') 1989 | _note_placement_handler = None 1990 | 1991 | _note_placement_data = {} 1992 | context.area.tag_redraw() 1993 | 1994 | class OBJECT_OT_add_camera_note(OBJECT_OT_BaseOperator): 1995 | bl_idname = "object.add_camera_note" 1996 | bl_label = "Add Note" 1997 | bl_description = "Add a new note to the selected camera" 1998 | 1999 | camera_name: StringProperty() 2000 | 2001 | def execute(self, context): 2002 | scene = context.scene 2003 | note = scene.camera_notes.add() 2004 | note.camera_name = self.camera_name 2005 | note.text = f"Note {len(scene.camera_notes)}" 2006 | scene.active_note_index = len(scene.camera_notes) - 1 2007 | 2008 | # Force viewport redraw 2009 | for area in context.screen.areas: 2010 | if area.type == 'VIEW_3D': 2011 | area.tag_redraw() 2012 | 2013 | self.report({'INFO'}, f"Added note to camera '{self.camera_name}'") 2014 | return {'FINISHED'} 2015 | 2016 | class OBJECT_OT_remove_camera_note(OBJECT_OT_BaseOperator): 2017 | bl_idname = "object.remove_camera_note" 2018 | bl_label = "Remove Note" 2019 | bl_description = "Remove the selected note" 2020 | 2021 | note_index: IntProperty() 2022 | 2023 | def execute(self, context): 2024 | scene = context.scene 2025 | if 0 <= self.note_index < len(scene.camera_notes): 2026 | scene.camera_notes.remove(self.note_index) 2027 | if scene.active_note_index >= len(scene.camera_notes): 2028 | scene.active_note_index = len(scene.camera_notes) - 1 2029 | 2030 | # Force viewport redraw 2031 | for area in context.screen.areas: 2032 | if area.type == 'VIEW_3D': 2033 | area.tag_redraw() 2034 | 2035 | self.report({'INFO'}, "Note removed") 2036 | return {'FINISHED'} 2037 | return {'CANCELLED'} 2038 | 2039 | class OBJECT_OT_clear_camera_notes(OBJECT_OT_BaseOperator): 2040 | bl_idname = "object.clear_camera_notes" 2041 | bl_label = "Clear All Notes" 2042 | bl_description = "Remove all notes for the selected camera" 2043 | 2044 | camera_name: StringProperty() 2045 | 2046 | def execute(self, context): 2047 | scene = context.scene 2048 | indices_to_remove = [] 2049 | 2050 | for i, note in enumerate(scene.camera_notes): 2051 | if note.camera_name == self.camera_name: 2052 | indices_to_remove.append(i) 2053 | 2054 | # Remove in reverse order to maintain indices 2055 | for i in reversed(indices_to_remove): 2056 | scene.camera_notes.remove(i) 2057 | 2058 | scene.active_note_index = 0 2059 | self.report({'INFO'}, f"Cleared {len(indices_to_remove)} notes") 2060 | return {'FINISHED'} 2061 | 2062 | class VIEW3D_MT_PIE_QuickCamera(Menu): 2063 | bl_label = "Quick Camera" 2064 | 2065 | def draw(self, context): 2066 | layout = self.layout 2067 | pie = layout.menu_pie() 2068 | 2069 | # Get active camera and check if in camera view 2070 | scene = context.scene 2071 | active_camera = scene.camera 2072 | 2073 | # Check if in camera view 2074 | in_camera_view = False 2075 | if context.space_data and context.space_data.type == 'VIEW_3D': 2076 | region_3d = context.space_data.region_3d 2077 | if region_3d and region_3d.view_perspective == 'CAMERA': 2078 | in_camera_view = True 2079 | 2080 | # Main pie items 2081 | pie.operator("object.add_camera", text="Add Camera", icon="CAMERA_DATA") 2082 | pie.operator("object.add_camera_with_marker", text="Add Shot", icon="VIEW_CAMERA") 2083 | pie.operator("object.add_camera_copy_properties", text="Copy Camera", icon="CAMERA_DATA") 2084 | pie.operator("object.add_camera_shot_copy_properties", text="Copy Shot", icon="VIEW_CAMERA") 2085 | 2086 | # Add Note option (only if there's an active camera AND in camera view) 2087 | if active_camera and active_camera.type == 'CAMERA' and in_camera_view: 2088 | op = pie.operator("object.add_note_interactive", text="Add Note", icon="FILE_TEXT") 2089 | op.camera_name = active_camera.name 2090 | else: 2091 | pie.separator() 2092 | 2093 | # Toggle Notes overlay (only show when in camera view) 2094 | if in_camera_view: 2095 | try: 2096 | preferences = context.preferences.addons[__package__].preferences 2097 | icon = 'HIDE_OFF' if preferences.show_camera_notes else 'HIDE_ON' 2098 | text = "Hide Notes" if preferences.show_camera_notes else "Show Notes" 2099 | pie.operator("object.toggle_camera_notes_overlay", text=text, icon=icon) 2100 | except (KeyError, AttributeError): 2101 | pie.separator() 2102 | else: 2103 | pie.separator() 2104 | 2105 | class SCENE_OT_SetActiveCamera(OBJECT_OT_BaseOperator): 2106 | bl_idname = "scene.set_active_camera" 2107 | bl_label = "Set Active Camera" 2108 | 2109 | camera_name: StringProperty() 2110 | 2111 | def execute(self, context): 2112 | camera = bpy.data.objects.get(self.camera_name) 2113 | if camera: 2114 | context.scene.camera = camera 2115 | self.report({'INFO'}, f"Camera '{self.camera_name}' set as active.") 2116 | return {'FINISHED'} 2117 | else: 2118 | self.report({'ERROR'}, f"Camera '{self.camera_name}' not found.") 2119 | return {'CANCELLED'} 2120 | 2121 | class OBJECT_OT_delete_camera(OBJECT_OT_BaseOperator): 2122 | bl_idname = "object.delete_camera" 2123 | bl_label = "Delete Camera" 2124 | 2125 | camera_name: StringProperty() 2126 | 2127 | def execute(self, context): 2128 | camera = bpy.data.objects.get(self.camera_name) 2129 | if camera: 2130 | bpy.data.objects.remove(camera) 2131 | self.report({'INFO'}, f"Camera '{self.camera_name}' removed.") 2132 | return {'FINISHED'} 2133 | else: 2134 | self.report({'ERROR'}, f"Camera '{self.camera_name}' not found.") 2135 | return {'CANCELLED'} 2136 | 2137 | def update_collection_index(self, context): 2138 | context.area.tag_redraw() 2139 | 2140 | class SCENE_OT_set_and_view_camera(Operator): 2141 | bl_idname = "scene.set_and_view_camera" 2142 | bl_label = "Set and View Camera" 2143 | bl_description = "Set the camera as active and view through it" 2144 | 2145 | camera_name: StringProperty() 2146 | 2147 | def execute(self, context): 2148 | camera = bpy.data.objects.get(self.camera_name) 2149 | if camera and camera.type == 'CAMERA': 2150 | context.scene.camera = camera 2151 | for area in context.screen.areas: 2152 | if area.type == 'VIEW_3D': 2153 | area.spaces[0].region_3d.view_perspective = 'CAMERA' 2154 | break 2155 | return {'FINISHED'} 2156 | return {'CANCELLED'} 2157 | 2158 | class OBJECT_OT_toggle_favorite_camera(Operator): 2159 | bl_idname = "object.toggle_favorite_camera" 2160 | bl_label = "Toggle Favorite Camera" 2161 | bl_description = "Toggle this camera as a favorite" 2162 | 2163 | camera_name: StringProperty() 2164 | 2165 | def execute(self, context): 2166 | props = context.scene.custom_name_props 2167 | camera = bpy.data.objects.get(self.camera_name) 2168 | if camera and camera.type == 'CAMERA': 2169 | favorite_cameras = [fc.camera for fc in props.favorite_cameras if fc.camera] 2170 | 2171 | if camera in favorite_cameras: 2172 | for i, fc in enumerate(props.favorite_cameras): 2173 | if fc.camera == camera: 2174 | props.favorite_cameras.remove(i) 2175 | break 2176 | elif len(props.favorite_cameras) < 8: 2177 | new_favorite = props.favorite_cameras.add() 2178 | new_favorite.camera = camera 2179 | else: 2180 | self.report({'WARNING'}, "You can only have up to 8 favorite cameras") 2181 | 2182 | return {'FINISHED'} 2183 | 2184 | class VIEW3D_MT_PIE_favorite_camera(Menu): 2185 | bl_label = "Favorite Cameras" 2186 | 2187 | def draw(self, context): 2188 | layout = self.layout 2189 | pie = layout.menu_pie() 2190 | 2191 | props = context.scene.custom_name_props 2192 | favorite_cameras = [fc for fc in props.favorite_cameras if fc.camera] 2193 | 2194 | for i, fc in enumerate(favorite_cameras): 2195 | if i < 8: # Limit to 8 cameras in the pie menu 2196 | op = pie.operator("scene.set_and_view_camera", text=fc.camera.name) 2197 | op.camera_name = fc.camera.name 2198 | 2199 | # Fill remaining slots with empty operators to complete the pie menu 2200 | for _ in range(8 - len(favorite_cameras)): 2201 | pie.separator() 2202 | 2203 | class WM_OT_capture_keymap(bpy.types.Operator): 2204 | bl_idname = "wm.capture_keymap" 2205 | bl_label = "Press a Key" 2206 | 2207 | pie_menu: bpy.props.StringProperty() 2208 | 2209 | def execute(self, context): 2210 | addon_prefs = context.preferences.addons[__package__].preferences 2211 | addon_prefs.capture_key = True 2212 | context.window_manager.modal_handler_add(self) 2213 | return {'RUNNING_MODAL'} 2214 | 2215 | def modal(self, context, event): 2216 | addon_prefs = context.preferences.addons[__package__].preferences 2217 | if event.type == 'TIMER': 2218 | return {'PASS_THROUGH'} 2219 | 2220 | if event.value == 'PRESS': 2221 | if self.pie_menu == "quick_camera": 2222 | addon_prefs.quick_camera_key = event.type 2223 | addon_prefs.quick_camera_ctrl = event.ctrl 2224 | addon_prefs.quick_camera_alt = event.alt 2225 | addon_prefs.quick_camera_shift = event.shift 2226 | elif self.pie_menu == "camera_controls": 2227 | addon_prefs.camera_controls_key = event.type 2228 | addon_prefs.camera_controls_ctrl = event.ctrl 2229 | addon_prefs.camera_controls_alt = event.alt 2230 | addon_prefs.camera_controls_shift = event.shift 2231 | elif self.pie_menu == "favorite_camera": 2232 | addon_prefs.favorite_camera_key = event.type 2233 | addon_prefs.favorite_camera_ctrl = event.ctrl 2234 | addon_prefs.favorite_camera_alt = event.alt 2235 | addon_prefs.favorite_camera_shift = event.shift 2236 | addon_prefs.capture_key = False 2237 | 2238 | update_keymap(self, context) 2239 | return {'FINISHED'} 2240 | 2241 | if event.type == 'ESC': 2242 | addon_prefs.capture_key = False 2243 | return {'CANCELLED'} 2244 | 2245 | return {'RUNNING_MODAL'} 2246 | 2247 | class WM_OT_remove_keymap(bpy.types.Operator): 2248 | bl_idname = "wm.remove_keymap" 2249 | bl_label = "Remove Keymap" 2250 | 2251 | pie_menu: bpy.props.StringProperty() 2252 | 2253 | def execute(self, context): 2254 | addon_prefs = context.preferences.addons[__package__].preferences 2255 | if self.pie_menu == "quick_camera": 2256 | addon_prefs.quick_camera_key = '' 2257 | addon_prefs.quick_camera_ctrl = False 2258 | addon_prefs.quick_camera_alt = False 2259 | addon_prefs.quick_camera_shift = False 2260 | elif self.pie_menu == "camera_controls": 2261 | addon_prefs.camera_controls_key = '' 2262 | addon_prefs.camera_controls_ctrl = False 2263 | addon_prefs.camera_controls_alt = False 2264 | addon_prefs.camera_controls_shift = False 2265 | elif self.pie_menu == "favorite_camera": 2266 | addon_prefs.favorite_camera_key = '' 2267 | addon_prefs.favorite_camera_ctrl = False 2268 | addon_prefs.favorite_camera_alt = False 2269 | addon_prefs.favorite_camera_shift = False 2270 | 2271 | update_keymap(self, context) 2272 | return {'FINISHED'} 2273 | 2274 | def update_keymap(self, context): 2275 | pass 2276 | 2277 | class OBJECT_OT_adjust_focal_length(Operator): 2278 | bl_idname = "object.adjust_focal_length" 2279 | bl_label = "Adjust Focal Length/Ortho Scale" 2280 | 2281 | initial_value: FloatProperty() 2282 | 2283 | def modal(self, context, event): 2284 | try: 2285 | context.area.tag_redraw() 2286 | 2287 | if event.type == 'MOUSEMOVE': 2288 | sensitivity = 0.1 if event.shift else 1.0 2289 | delta = (event.mouse_x - event.mouse_prev_x) * sensitivity 2290 | 2291 | if self.camera.data.type == 'ORTHO': 2292 | self.camera.data.ortho_scale = max(0.1, self.camera.data.ortho_scale + delta * 0.05) 2293 | self.display_text = f"Orthographic Scale: {self.camera.data.ortho_scale:.2f}" 2294 | else: 2295 | self.camera.data.lens = max(1, self.camera.data.lens + delta) 2296 | self.display_text = f"Focal Length: {self.camera.data.lens:.1f}mm" 2297 | 2298 | elif event.type == 'LEFTMOUSE' and event.value == 'RELEASE': 2299 | if hasattr(self, '_handle'): 2300 | bpy.types.SpaceView3D.draw_handler_remove(self._handle, 'WINDOW') 2301 | return {'FINISHED'} 2302 | 2303 | elif event.type in {'RIGHTMOUSE', 'ESC'}: 2304 | if self.camera.data.type == 'ORTHO': 2305 | self.camera.data.ortho_scale = self.initial_value 2306 | else: 2307 | self.camera.data.lens = self.initial_value 2308 | if hasattr(self, '_handle'): 2309 | bpy.types.SpaceView3D.draw_handler_remove(self._handle, 'WINDOW') 2310 | return {'CANCELLED'} 2311 | 2312 | return {'RUNNING_MODAL'} 2313 | except Exception as e: 2314 | # Ensure handler is removed on error 2315 | if hasattr(self, '_handle'): 2316 | bpy.types.SpaceView3D.draw_handler_remove(self._handle, 'WINDOW') 2317 | self.report({'ERROR'}, f"Error in modal operator: {str(e)}") 2318 | return {'CANCELLED'} 2319 | 2320 | def invoke(self, context, event): 2321 | self.camera = context.scene.camera or context.view_layer.objects.active 2322 | if self.camera and self.camera.type == 'CAMERA': 2323 | if self.camera.data.type == 'ORTHO': 2324 | self.initial_value = self.camera.data.ortho_scale 2325 | self.display_text = f"Orthographic Scale: {self.initial_value:.2f}" 2326 | else: 2327 | self.initial_value = self.camera.data.lens 2328 | self.display_text = f"Focal Length: {self.initial_value:.1f}mm" 2329 | args = (self, context) 2330 | self._handle = bpy.types.SpaceView3D.draw_handler_add(self.draw_callback_px, args, 'WINDOW', 'POST_PIXEL') 2331 | context.window_manager.modal_handler_add(self) 2332 | return {'RUNNING_MODAL'} 2333 | else: 2334 | self.report({'WARNING'}, "No active camera") 2335 | return {'CANCELLED'} 2336 | 2337 | def draw_callback_px(self, op, context): 2338 | font_id = 0 2339 | blf.color(font_id, 1, 1, 1, 1) 2340 | 2341 | # Get the dimensions of the region 2342 | region = context.region 2343 | width = region.width 2344 | height = region.height 2345 | 2346 | # Calculate text dimensions 2347 | blf.size(font_id, 20) 2348 | text_width, text_height = blf.dimensions(font_id, self.display_text) 2349 | 2350 | # Position text at the bottom center 2351 | x = (width - text_width) / 2 2352 | y = 70 # Adjust this value to move the text up or down 2353 | 2354 | blf.position(font_id, x, y, 0) 2355 | blf.draw(font_id, self.display_text) 2356 | 2357 | class OBJECT_OT_adjust_fstop(Operator): 2358 | bl_idname = "object.adjust_fstop" 2359 | bl_label = "Adjust F-Stop" 2360 | 2361 | def modal(self, context, event): 2362 | try: 2363 | context.area.tag_redraw() 2364 | 2365 | if event.type == 'MOUSEMOVE': 2366 | sensitivity = 0.01 if event.shift else 0.1 2367 | delta = (event.mouse_x - event.mouse_prev_x) * sensitivity 2368 | self.camera.data.dof.aperture_fstop = max(0.1, self.camera.data.dof.aperture_fstop + delta) 2369 | self.display_text = f"F-Stop: f/{self.camera.data.dof.aperture_fstop:.1f}" 2370 | elif event.type == 'LEFTMOUSE' and event.value == 'RELEASE': 2371 | if hasattr(self, '_handle'): 2372 | bpy.types.SpaceView3D.draw_handler_remove(self._handle, 'WINDOW') 2373 | return {'FINISHED'} 2374 | elif event.type in {'RIGHTMOUSE', 'ESC'}: 2375 | self.camera.data.dof.aperture_fstop = self.initial_fstop 2376 | if hasattr(self, '_handle'): 2377 | bpy.types.SpaceView3D.draw_handler_remove(self._handle, 'WINDOW') 2378 | return {'CANCELLED'} 2379 | 2380 | return {'RUNNING_MODAL'} 2381 | except Exception as e: 2382 | # Ensure handler is removed on error 2383 | if hasattr(self, '_handle'): 2384 | bpy.types.SpaceView3D.draw_handler_remove(self._handle, 'WINDOW') 2385 | self.report({'ERROR'}, f"Error in modal operator: {str(e)}") 2386 | return {'CANCELLED'} 2387 | 2388 | def invoke(self, context, event): 2389 | self.camera = context.scene.camera or context.view_layer.objects.active 2390 | if self.camera and self.camera.type == 'CAMERA': 2391 | self.initial_fstop = self.camera.data.dof.aperture_fstop 2392 | self.camera.data.dof.use_dof = True # Enable DoF 2393 | self.display_text = f"F-Stop: f/{self.initial_fstop:.1f}" 2394 | args = (self, context) 2395 | self._handle = bpy.types.SpaceView3D.draw_handler_add(self.draw_callback_px, args, 'WINDOW', 'POST_PIXEL') 2396 | context.window_manager.modal_handler_add(self) 2397 | return {'RUNNING_MODAL'} 2398 | else: 2399 | self.report({'WARNING'}, "No active camera") 2400 | return {'CANCELLED'} 2401 | 2402 | def draw_callback_px(self, op, context): 2403 | font_id = 0 2404 | blf.color(font_id, 1, 1, 1, 1) 2405 | 2406 | # Get the dimensions of the region 2407 | region = context.region 2408 | width = region.width 2409 | height = region.height 2410 | 2411 | # Calculate text dimensions 2412 | blf.size(font_id, 20) 2413 | text_width, text_height = blf.dimensions(font_id, self.display_text) 2414 | 2415 | # Position text at the bottom center 2416 | x = (width - text_width) / 2 2417 | y = 70 # Adjust this value to move the text up or down 2418 | 2419 | blf.position(font_id, x, y, 0) 2420 | blf.draw(font_id, self.display_text) 2421 | 2422 | class OBJECT_OT_dof_picker(Operator): 2423 | bl_idname = "object.dof_picker" 2424 | bl_label = "DoF Picker" 2425 | 2426 | def modal(self, context, event): 2427 | try: 2428 | context.area.tag_redraw() 2429 | 2430 | if event.type == 'MOUSEMOVE': 2431 | self.mouse_pos = Vector((event.mouse_region_x, event.mouse_region_y)) 2432 | self.update_focus(context, event) 2433 | elif event.type == 'LEFTMOUSE' and event.value == 'RELEASE': 2434 | context.window.cursor_modal_restore() 2435 | if hasattr(self, '_handle'): 2436 | bpy.types.SpaceView3D.draw_handler_remove(self._handle, 'WINDOW') 2437 | return {'FINISHED'} 2438 | elif event.type in {'RIGHTMOUSE', 'ESC'}: 2439 | context.window.cursor_modal_restore() 2440 | self.camera.data.dof.focus_distance = self.initial_focus 2441 | if hasattr(self, '_handle'): 2442 | bpy.types.SpaceView3D.draw_handler_remove(self._handle, 'WINDOW') 2443 | return {'CANCELLED'} 2444 | 2445 | return {'RUNNING_MODAL'} 2446 | except Exception as e: 2447 | # Ensure handler is removed on error 2448 | context.window.cursor_modal_restore() 2449 | if hasattr(self, '_handle'): 2450 | bpy.types.SpaceView3D.draw_handler_remove(self._handle, 'WINDOW') 2451 | self.report({'ERROR'}, f"Error in modal operator: {str(e)}") 2452 | return {'CANCELLED'} 2453 | 2454 | def invoke(self, context, event): 2455 | if context.space_data.type != 'VIEW_3D': 2456 | self.report({'WARNING'}, "Active space must be a 3D view") 2457 | return {'CANCELLED'} 2458 | 2459 | self.camera = context.scene.camera or context.view_layer.objects.active 2460 | if self.camera and self.camera.type == 'CAMERA': 2461 | self.initial_focus = self.camera.data.dof.focus_distance 2462 | self.camera.data.dof.use_dof = True # Enable DoF 2463 | context.window.cursor_modal_set('EYEDROPPER') 2464 | self.mouse_pos = Vector((event.mouse_region_x, event.mouse_region_y)) 2465 | self.display_text = f"DoF Distance: {self.initial_focus:.2f}m" 2466 | args = (self, context) 2467 | self._handle = bpy.types.SpaceView3D.draw_handler_add(self.draw_callback_px, args, 'WINDOW', 'POST_PIXEL') 2468 | context.window_manager.modal_handler_add(self) 2469 | return {'RUNNING_MODAL'} 2470 | else: 2471 | self.report({'WARNING'}, "No active camera") 2472 | return {'CANCELLED'} 2473 | 2474 | def update_focus(self, context, event): 2475 | coord = event.mouse_region_x, event.mouse_region_y 2476 | region = context.region 2477 | rv3d = context.region_data 2478 | view_vector = region_2d_to_vector_3d(region, rv3d, coord) 2479 | ray_origin = region_2d_to_origin_3d(region, rv3d, coord) 2480 | ray_target = ray_origin + view_vector 2481 | 2482 | hit, location, _, _, _, _ = context.scene.ray_cast(context.view_layer.depsgraph, ray_origin, view_vector) 2483 | 2484 | if hit: 2485 | focus_distance = (self.camera.matrix_world.inverted() @ location).length 2486 | self.camera.data.dof.focus_distance = focus_distance 2487 | self.display_text = f"DoF Distance: {focus_distance:.2f}m" 2488 | 2489 | def draw_callback_px(self, op, context): 2490 | font_id = 0 2491 | blf.color(font_id, 1, 1, 1, 1) 2492 | blf.position(font_id, self.mouse_pos.x + 15, self.mouse_pos.y - 15, 0) 2493 | blf.size(font_id, 16) 2494 | blf.draw(font_id, self.display_text) 2495 | 2496 | class OBJECT_OT_dof_focus_object_picker(Operator): 2497 | bl_idname = "object.dof_focus_object_picker" 2498 | bl_label = "Pick DoF Focus Object" 2499 | 2500 | def modal(self, context, event): 2501 | try: 2502 | context.area.tag_redraw() 2503 | 2504 | if event.type == 'MOUSEMOVE': 2505 | self.mouse_pos = Vector((event.mouse_region_x, event.mouse_region_y)) 2506 | self.update_focus_object(context, event) 2507 | elif event.type == 'LEFTMOUSE' and event.value == 'RELEASE': 2508 | context.window.cursor_modal_restore() 2509 | context.area.header_text_set(None) 2510 | if hasattr(self, '_handle'): 2511 | bpy.types.SpaceView3D.draw_handler_remove(self._handle, 'WINDOW') 2512 | return {'FINISHED'} 2513 | elif event.type in {'RIGHTMOUSE', 'ESC'}: 2514 | context.window.cursor_modal_restore() 2515 | context.area.header_text_set(None) 2516 | self.camera.data.dof.focus_object = self.initial_focus_object 2517 | if hasattr(self, '_handle'): 2518 | bpy.types.SpaceView3D.draw_handler_remove(self._handle, 'WINDOW') 2519 | return {'CANCELLED'} 2520 | 2521 | return {'RUNNING_MODAL'} 2522 | except Exception as e: 2523 | # Ensure handler is removed on error 2524 | context.window.cursor_modal_restore() 2525 | context.area.header_text_set(None) 2526 | if hasattr(self, '_handle'): 2527 | bpy.types.SpaceView3D.draw_handler_remove(self._handle, 'WINDOW') 2528 | self.report({'ERROR'}, f"Error in modal operator: {str(e)}") 2529 | return {'CANCELLED'} 2530 | 2531 | def invoke(self, context, event): 2532 | if context.space_data.type != 'VIEW_3D': 2533 | self.report({'WARNING'}, "Active space must be a 3D view") 2534 | return {'CANCELLED'} 2535 | 2536 | self.camera = context.scene.camera or context.view_layer.objects.active 2537 | if self.camera and self.camera.type == 'CAMERA': 2538 | self.initial_focus_object = self.camera.data.dof.focus_object 2539 | self.camera.data.dof.use_dof = True # Enable DoF 2540 | context.window.cursor_modal_set('EYEDROPPER') 2541 | self.mouse_pos = Vector((event.mouse_region_x, event.mouse_region_y)) 2542 | self.current_object = None 2543 | args = (self, context) 2544 | self._handle = bpy.types.SpaceView3D.draw_handler_add(self.draw_callback_px, args, 'WINDOW', 'POST_PIXEL') 2545 | context.window_manager.modal_handler_add(self) 2546 | return {'RUNNING_MODAL'} 2547 | else: 2548 | self.report({'WARNING'}, "No active camera") 2549 | return {'CANCELLED'} 2550 | 2551 | def update_focus_object(self, context, event): 2552 | coord = event.mouse_region_x, event.mouse_region_y 2553 | region = context.region 2554 | rv3d = context.region_data 2555 | view_vector = region_2d_to_vector_3d(region, rv3d, coord) 2556 | ray_origin = region_2d_to_origin_3d(region, rv3d, coord) 2557 | 2558 | result, location, normal, index, object, matrix = context.scene.ray_cast(context.view_layer.depsgraph, ray_origin, view_vector) 2559 | 2560 | if result: 2561 | self.camera.data.dof.focus_object = object 2562 | self.current_object = object 2563 | context.area.header_text_set(f"Focus Object: {object.name}") 2564 | else: 2565 | self.current_object = None 2566 | context.area.header_text_set("No object under cursor") 2567 | 2568 | def draw_callback_px(self, op, context): 2569 | if self.current_object: 2570 | font_id = 0 2571 | blf.color(font_id, 1, 1, 1, 1) 2572 | blf.position(font_id, self.mouse_pos.x + 15, self.mouse_pos.y - 15, 0) 2573 | blf.size(font_id, 16) 2574 | blf.draw(font_id, self.current_object.name) 2575 | 2576 | class OBJECT_OT_set_dof_object(Operator): 2577 | bl_idname = "object.set_dof_object" 2578 | bl_label = "Set DoF Object" 2579 | 2580 | def execute(self, context): 2581 | camera = context.scene.camera or context.view_layer.objects.active 2582 | if camera and camera.type == 'CAMERA': 2583 | camera.data.dof.use_dof = True # Enable DoF 2584 | return bpy.ops.object.dof_focus_object_picker('INVOKE_DEFAULT') 2585 | 2586 | class OBJECT_OT_remove_dof_object(Operator): 2587 | bl_idname = "object.remove_dof_object" 2588 | bl_label = "Remove DoF Object" 2589 | bl_description = "Remove the current focus object from the camera" 2590 | 2591 | def execute(self, context): 2592 | camera = context.scene.camera or context.view_layer.objects.active 2593 | if camera and camera.type == 'CAMERA': 2594 | camera.data.dof.focus_object = None 2595 | self.report({'INFO'}, "Removed DoF focus object") 2596 | else: 2597 | self.report({'WARNING'}, "No active camera") 2598 | return {'FINISHED'} 2599 | 2600 | class OBJECT_OT_create_empty_focus(Operator): 2601 | bl_idname = "object.create_empty_focus" 2602 | bl_label = "Create Empty Focus" 2603 | bl_description = "Create an empty object and set it as the camera's focus object" 2604 | 2605 | def modal(self, context, event): 2606 | try: 2607 | context.area.tag_redraw() 2608 | 2609 | if event.type == 'LEFTMOUSE' and event.value == 'PRESS': 2610 | self.create_empty_and_set_focus(context, event) 2611 | if hasattr(self, '_handle'): 2612 | bpy.types.SpaceView3D.draw_handler_remove(self._handle, 'WINDOW') 2613 | return {'FINISHED'} 2614 | elif event.type in {'RIGHTMOUSE', 'ESC'}: 2615 | if hasattr(self, '_handle'): 2616 | bpy.types.SpaceView3D.draw_handler_remove(self._handle, 'WINDOW') 2617 | return {'CANCELLED'} 2618 | 2619 | return {'RUNNING_MODAL'} 2620 | except Exception as e: 2621 | # Ensure handler is removed on error 2622 | if hasattr(self, '_handle'): 2623 | bpy.types.SpaceView3D.draw_handler_remove(self._handle, 'WINDOW') 2624 | self.report({'ERROR'}, f"Error in modal operator: {str(e)}") 2625 | return {'CANCELLED'} 2626 | 2627 | def invoke(self, context, event): 2628 | if context.space_data.type != 'VIEW_3D': 2629 | self.report({'WARNING'}, "Active space must be a 3D view") 2630 | return {'CANCELLED'} 2631 | 2632 | self.camera = context.scene.camera or context.view_layer.objects.active 2633 | if not self.camera or self.camera.type != 'CAMERA': 2634 | self.report({'WARNING'}, "No active camera") 2635 | return {'CANCELLED'} 2636 | 2637 | args = (self, context) 2638 | self._handle = bpy.types.SpaceView3D.draw_handler_add(self.draw_callback_px, args, 'WINDOW', 'POST_PIXEL') 2639 | 2640 | context.window.cursor_modal_set('CROSSHAIR') 2641 | context.window_manager.modal_handler_add(self) 2642 | return {'RUNNING_MODAL'} 2643 | 2644 | def create_empty_and_set_focus(self, context, event): 2645 | # Get the ray from the viewport and mouse 2646 | region = context.region 2647 | rv3d = context.region_data 2648 | coord = event.mouse_region_x, event.mouse_region_y 2649 | view_vector = region_2d_to_vector_3d(region, rv3d, coord) 2650 | ray_origin = region_2d_to_origin_3d(region, rv3d, coord) 2651 | 2652 | result, location, normal, index, object, matrix = context.scene.ray_cast(context.view_layer.depsgraph, ray_origin, view_vector) 2653 | 2654 | if result: 2655 | # Create empty 2656 | empty = bpy.data.objects.new("CameraFocus", None) 2657 | empty.empty_display_type = 'PLAIN_AXES' 2658 | empty.location = location 2659 | context.scene.collection.objects.link(empty) 2660 | 2661 | # Set empty as camera's focus object 2662 | self.camera.data.dof.focus_object = empty 2663 | self.camera.data.dof.use_dof = True 2664 | 2665 | self.report({'INFO'}, "Created empty focus object and set it as camera's focus") 2666 | else: 2667 | self.report({'WARNING'}, "No object found under mouse cursor") 2668 | 2669 | context.window.cursor_modal_restore() 2670 | 2671 | def draw_callback_px(self, op, context): 2672 | font_id = 0 2673 | blf.color(font_id, 1, 1, 1, 1) 2674 | 2675 | # Get the dimensions of the region 2676 | region = context.region 2677 | width = region.width 2678 | height = region.height 2679 | 2680 | # Calculate text dimensions 2681 | text = "Click to Place Empty Focus" 2682 | blf.size(font_id, 20) 2683 | text_width, text_height = blf.dimensions(font_id, text) 2684 | 2685 | # Position text at the bottom center 2686 | x = (width - text_width) / 2 2687 | y = 70 # Adjust this value to move the text up or down 2688 | 2689 | blf.position(font_id, x, y, 0) 2690 | blf.draw(font_id, text) 2691 | 2692 | class OBJECT_OT_toggle_lock_camera_to_view(Operator): 2693 | bl_idname = "object.toggle_lock_camera_to_view" 2694 | bl_label = "Toggle Lock Camera to View" 2695 | bl_description = "Toggle the 'Lock Camera to View' option" 2696 | 2697 | def execute(self, context): 2698 | context.space_data.lock_camera = not context.space_data.lock_camera 2699 | status = "enabled" if context.space_data.lock_camera else "disabled" 2700 | self.report({'INFO'}, f"Lock Camera to View {status}") 2701 | return {'FINISHED'} 2702 | 2703 | class OBJECT_OT_select_active_camera(Operator): 2704 | bl_idname = "object.select_active_camera" 2705 | bl_label = "Select Active Camera" 2706 | bl_description = "Select the active camera in the scene" 2707 | 2708 | def execute(self, context): 2709 | active_camera = context.scene.camera 2710 | if active_camera: 2711 | bpy.ops.object.select_all(action='DESELECT') 2712 | active_camera.select_set(True) 2713 | context.view_layer.objects.active = active_camera 2714 | self.report({'INFO'}, f"Selected active camera: {active_camera.name}") 2715 | else: 2716 | self.report({'WARNING'}, "No active camera in the scene") 2717 | return {'FINISHED'} 2718 | 2719 | class OBJECT_OT_toggle_favorite_active_camera(Operator): 2720 | bl_idname = "object.toggle_favorite_active_camera" 2721 | bl_label = "Toggle Favorite Active Camera" 2722 | bl_description = "Add or remove the active camera from favorites" 2723 | 2724 | def execute(self, context): 2725 | active_camera = context.scene.camera 2726 | if active_camera and active_camera.type == 'CAMERA': 2727 | props = context.scene.custom_name_props 2728 | favorite_cameras = [fc.camera for fc in props.favorite_cameras if fc.camera] 2729 | 2730 | if active_camera in favorite_cameras: 2731 | for i, fc in enumerate(props.favorite_cameras): 2732 | if fc.camera == active_camera: 2733 | props.favorite_cameras.remove(i) 2734 | self.report({'INFO'}, f"Removed {active_camera.name} from favorites") 2735 | break 2736 | else: 2737 | if len(props.favorite_cameras) < 8: 2738 | new_favorite = props.favorite_cameras.add() 2739 | new_favorite.camera = active_camera 2740 | self.report({'INFO'}, f"Added {active_camera.name} to favorites") 2741 | else: 2742 | self.report({'WARNING'}, "Favorite list is full. Remove a camera to add a new one.") 2743 | else: 2744 | self.report({'WARNING'}, "No active camera to favorite/unfavorite") 2745 | return {'FINISHED'} 2746 | 2747 | class VIEW3D_MT_PIE_camera_controls(Menu): 2748 | bl_label = "Camera Controls" 2749 | 2750 | def draw(self, context): 2751 | layout = self.layout 2752 | pie = layout.menu_pie() 2753 | 2754 | camera = context.scene.camera or context.view_layer.objects.active 2755 | 2756 | pie.operator("object.dof_picker", text="DoF Distance", icon='DRIVER_DISTANCE') 2757 | 2758 | if camera and camera.type == 'CAMERA' and camera.data.dof.focus_object: 2759 | pie.operator("object.remove_dof_object", text="Remove DoF Object", icon='X') 2760 | else: 2761 | pie.operator("object.set_dof_object", text="Set DoF Object", icon='OBJECT_DATA') 2762 | 2763 | pie.operator("object.adjust_focal_length", text="Focal Length", icon='CAMERA_DATA') 2764 | pie.operator("object.adjust_fstop", text="F-Stop", icon='CAMERA_DATA') 2765 | pie.operator("object.create_empty_focus", text="Create Empty Focus", icon='EMPTY_AXIS') 2766 | pie.operator("object.toggle_lock_camera_to_view", text="Lock Camera to View", icon='LOCKVIEW_ON') 2767 | pie.operator("object.select_active_camera", text="Select Active Camera", icon='OUTLINER_OB_CAMERA') 2768 | pie.operator("object.favorite_active_camera", text="Favorite Active Camera", icon='SOLO_ON') 2769 | 2770 | props = context.scene.custom_name_props 2771 | favorite_cameras = [fc.camera for fc in props.favorite_cameras if fc.camera] 2772 | is_favorite = camera in favorite_cameras if camera and camera.type == 'CAMERA' else False 2773 | 2774 | if is_favorite: 2775 | pie.operator("object.toggle_favorite_active_camera", text="Remove from Favorite", icon='X') 2776 | else: 2777 | pie.operator("object.toggle_favorite_active_camera", text="Favorite Active Camera", icon='SOLO_ON') 2778 | 2779 | def collect_all_attributes(obj): 2780 | """Recursively collects all attributes of an object.""" 2781 | if obj is None: 2782 | return None 2783 | 2784 | attributes = {} 2785 | for attr in dir(obj): 2786 | if not attr.startswith('_') and not callable(getattr(obj, attr)): 2787 | try: 2788 | value = getattr(obj, attr) 2789 | if isinstance(value, (bool, int, float, str)): 2790 | attributes[attr] = value 2791 | elif hasattr(value, '__iter__') and not isinstance(value, str): 2792 | if len(value) > 0 and all(isinstance(x, (bool, int, float, str)) for x in value): 2793 | attributes[attr] = list(value) 2794 | except (AttributeError, TypeError, RuntimeError): 2795 | # Skip attributes that can't be accessed or serialized 2796 | continue 2797 | return attributes 2798 | 2799 | def apply_settings(obj, settings): 2800 | """Recursively applies settings to an object.""" 2801 | if not settings or not obj: 2802 | return 2803 | 2804 | for key, value in settings.items(): 2805 | try: 2806 | if isinstance(value, dict): 2807 | apply_settings(getattr(obj, key), value) 2808 | elif hasattr(obj, key): 2809 | setattr(obj, key, value) 2810 | except Exception as e: 2811 | print(f"Failed to set {key}: {e}") 2812 | 2813 | 2814 | 2815 | class OBJECT_OT_ExportAllSettings(Operator): 2816 | bl_idname = "render.export_all_settings" 2817 | bl_label = "Export All Settings" 2818 | bl_description = "Export all render settings, including nested attributes, as a JSON file" 2819 | bl_options = {'REGISTER', 'UNDO'} 2820 | 2821 | filepath: bpy.props.StringProperty(subtype="FILE_PATH") 2822 | 2823 | def execute(self, context): 2824 | try: 2825 | # Get current active preset name if it exists 2826 | preset_name = "New Preset" 2827 | if hasattr(context.scene, "render_presets"): 2828 | index = context.scene.render_presets.active_preset_index 2829 | if index >= 0 and index < len(context.scene.render_presets.presets): 2830 | preset_name = context.scene.render_presets.presets[index].name 2831 | 2832 | # Collect settings 2833 | settings = { 2834 | "preset_name": preset_name, # Include preset name in export 2835 | "render_settings": collect_all_attributes(bpy.context.scene.render), 2836 | "cycles_settings": collect_all_attributes(bpy.context.scene.cycles) if hasattr(bpy.context.scene, "cycles") else None, 2837 | "eevee_settings": collect_all_attributes(bpy.context.scene.eevee) if hasattr(bpy.context.scene, "eevee") else None, 2838 | "world_settings": collect_all_attributes(bpy.context.scene.world) if bpy.context.scene.world else None, 2839 | "view_settings": collect_all_attributes(bpy.context.scene.view_settings), 2840 | } 2841 | 2842 | with open(self.filepath, "w") as file: 2843 | json.dump(settings, file, indent=4) 2844 | self.report({"INFO"}, f"Settings exported to {self.filepath}") 2845 | except Exception as e: 2846 | self.report({"ERROR"}, f"Failed to export settings: {e}") 2847 | return {"FINISHED"} 2848 | 2849 | def invoke(self, context, event): 2850 | if not self.filepath: 2851 | # Use active preset name for filename if available 2852 | if hasattr(context.scene, "render_presets"): 2853 | index = context.scene.render_presets.active_preset_index 2854 | if index >= 0 and index < len(context.scene.render_presets.presets): 2855 | preset_name = context.scene.render_presets.presets[index].name 2856 | # Sanitize the preset name to prevent path traversal 2857 | safe_name = sanitize_filename(preset_name) 2858 | self.filepath = os.path.join( 2859 | os.path.dirname(bpy.data.filepath) if bpy.data.filepath else os.path.expanduser("~"), 2860 | f"{safe_name}.json" 2861 | ) 2862 | 2863 | context.window_manager.fileselect_add(self) 2864 | return {"RUNNING_MODAL"} 2865 | 2866 | class OBJECT_OT_ImportAllSettings(Operator): 2867 | bl_idname = "render.import_all_settings" 2868 | bl_label = "Import All Settings" 2869 | bl_description = "Import render settings from a JSON file as a new preset" 2870 | bl_options = {'REGISTER', 'UNDO'} 2871 | 2872 | filepath: bpy.props.StringProperty(subtype="FILE_PATH") 2873 | 2874 | def execute(self, context): 2875 | try: 2876 | # Load settings from JSON 2877 | with open(self.filepath, "r") as file: 2878 | settings = json.load(file) 2879 | 2880 | # Extract preset name if it exists in the file, otherwise use filename 2881 | if isinstance(settings, dict): 2882 | preset_name = settings.pop("preset_name", None) 2883 | if not preset_name: 2884 | preset_name = os.path.splitext(os.path.basename(self.filepath))[0] 2885 | 2886 | # Create new preset if render_presets exists 2887 | if hasattr(context.scene, "render_presets"): 2888 | presets = context.scene.render_presets.presets 2889 | new_preset = presets.add() 2890 | new_preset.name = preset_name 2891 | # Store settings as a JSON string in the preset 2892 | new_preset.settings = json.dumps(settings) 2893 | # Set as active preset 2894 | context.scene.render_presets.active_preset_index = len(presets) - 1 2895 | 2896 | self.report({"INFO"}, f"Settings imported as preset: {preset_name}") 2897 | except Exception as e: 2898 | self.report({"ERROR"}, f"Failed to import settings: {e}") 2899 | return {"FINISHED"} 2900 | 2901 | def invoke(self, context, event): 2902 | context.window_manager.fileselect_add(self) 2903 | return {"RUNNING_MODAL"} 2904 | 2905 | class RenderPreset(PropertyGroup): 2906 | name: StringProperty(default="New Preset") 2907 | settings: StringProperty() # Store settings as JSON string 2908 | render_engine: StringProperty(default="BLENDER_EEVEE") # Store render engine type 2909 | 2910 | # Property group to store all presets and active index 2911 | class RenderPresetsCollection(PropertyGroup): 2912 | presets: CollectionProperty(type=RenderPreset) 2913 | active_preset_index: IntProperty() 2914 | 2915 | # UI List for render presets 2916 | class RENDER_UL_presets_list(UIList): 2917 | def draw_item(self, context, layout, data, item, icon, active_data, active_propname): 2918 | if self.layout_type in {'DEFAULT', 'COMPACT'}: 2919 | row = layout.row(align=True) 2920 | 2921 | # Get render engine type from preset settings 2922 | settings = json.loads(item.settings) if item.settings else {} 2923 | render_settings = settings.get("render_settings", {}) 2924 | engine = render_settings.get("engine", item.render_engine) 2925 | 2926 | # Display appropriate icon based on render engine 2927 | engine_icon = 'EVENT_E' 2928 | if engine == 'CYCLES': 2929 | engine_icon = 'EVENT_C' 2930 | elif engine == 'BLENDER_EEVEE': 2931 | engine_icon = 'EVENT_UNKNOWN' 2932 | 2933 | row.label(text="", icon=engine_icon) 2934 | row.prop(item, "name", text="", emboss=False) 2935 | elif self.layout_type in {'GRID'}: 2936 | layout.alignment = 'CENTER' 2937 | layout.label(text="", icon='PRESET') 2938 | 2939 | # Operator to add new preset 2940 | class RENDER_OT_add_preset(Operator): 2941 | bl_idname = "render.add_preset" 2942 | bl_label = "Add Preset" 2943 | bl_description = "Add a new render preset" 2944 | bl_options = {'REGISTER', 'UNDO'} 2945 | 2946 | def execute(self, context): 2947 | presets = context.scene.render_presets.presets 2948 | new_preset = presets.add() 2949 | new_preset.name = f"Preset {len(presets)}" 2950 | context.scene.render_presets.active_preset_index = len(presets) - 1 2951 | return {'FINISHED'} 2952 | 2953 | # Operator to remove preset 2954 | class RENDER_OT_remove_preset(Operator): 2955 | bl_idname = "render.remove_preset" 2956 | bl_label = "Remove Preset" 2957 | bl_description = "Remove selected render preset" 2958 | bl_options = {'REGISTER', 'UNDO'} 2959 | 2960 | def execute(self, context): 2961 | presets = context.scene.render_presets.presets 2962 | index = context.scene.render_presets.active_preset_index 2963 | 2964 | if index >= 0 and index < len(presets): 2965 | presets.remove(index) 2966 | context.scene.render_presets.active_preset_index = min(index, len(presets) - 1) 2967 | 2968 | return {'FINISHED'} 2969 | 2970 | # Operator to save current render settings to preset 2971 | class RENDER_OT_save_to_preset(Operator): 2972 | bl_idname = "render.save_to_preset" 2973 | bl_label = "Save Current Settings" 2974 | bl_description = "Save current render settings to selected preset" 2975 | bl_options = {'REGISTER', 'UNDO'} 2976 | 2977 | def execute(self, context): 2978 | index = context.scene.render_presets.active_preset_index 2979 | if index < 0: 2980 | self.report({'ERROR'}, "No preset selected") 2981 | return {'CANCELLED'} 2982 | 2983 | try: 2984 | # Collect all settings 2985 | settings = { 2986 | "render_settings": collect_all_attributes(context.scene.render), 2987 | "cycles_settings": collect_all_attributes(context.scene.cycles) if hasattr(context.scene, "cycles") else None, 2988 | "eevee_settings": collect_all_attributes(context.scene.eevee) if hasattr(context.scene, "eevee") else None, 2989 | "world_settings": collect_all_attributes(context.scene.world) if context.scene.world else None, 2990 | "view_settings": collect_all_attributes(context.scene.view_settings), 2991 | } 2992 | 2993 | # Save settings to preset 2994 | preset = context.scene.render_presets.presets[index] 2995 | preset.settings = json.dumps(settings) 2996 | 2997 | # Store current render engine 2998 | preset.render_engine = context.scene.render.engine 2999 | 3000 | self.report({'INFO'}, f"Settings saved to preset {preset.name}") 3001 | 3002 | except Exception as e: 3003 | self.report({'ERROR'}, f"Failed to save settings: {str(e)}") 3004 | return {'CANCELLED'} 3005 | 3006 | return {'FINISHED'} 3007 | 3008 | # Operator to apply preset settings 3009 | # Update the RENDER_OT_apply_preset operator 3010 | class RENDER_OT_apply_preset(Operator): 3011 | bl_idname = "render.apply_preset" 3012 | bl_label = "Apply Preset" 3013 | bl_description = "Apply selected preset settings" 3014 | bl_options = {'REGISTER', 'UNDO'} 3015 | 3016 | # Add properties for selective application 3017 | apply_render: BoolProperty( 3018 | name="Render Settings", 3019 | description="Apply render settings (resolution, samples, etc.)", 3020 | default=True 3021 | ) 3022 | 3023 | apply_cycles: BoolProperty( 3024 | name="Cycles Settings", 3025 | description="Apply Cycles render engine settings", 3026 | default=True 3027 | ) 3028 | 3029 | apply_eevee: BoolProperty( 3030 | name="EEVEE Settings", 3031 | description="Apply EEVEE render engine settings", 3032 | default=True 3033 | ) 3034 | 3035 | apply_world: BoolProperty( 3036 | name="World Settings", 3037 | description="Apply world settings (background, environment, etc.)", 3038 | default=True 3039 | ) 3040 | 3041 | apply_view: BoolProperty( 3042 | name="View Settings", 3043 | description="Apply view settings (color management, etc.)", 3044 | default=True 3045 | ) 3046 | 3047 | def invoke(self, context, event): 3048 | return context.window_manager.invoke_props_dialog(self) 3049 | 3050 | def draw(self, context): 3051 | layout = self.layout 3052 | layout.label(text="Select Settings to Apply:") 3053 | 3054 | col = layout.column(align=True) 3055 | col.prop(self, "apply_render") 3056 | col.prop(self, "apply_cycles") 3057 | col.prop(self, "apply_eevee") 3058 | col.prop(self, "apply_world") 3059 | col.prop(self, "apply_view") 3060 | 3061 | def execute(self, context): 3062 | index = context.scene.render_presets.active_preset_index 3063 | if index < 0: 3064 | self.report({'ERROR'}, "No preset selected") 3065 | return {'CANCELLED'} 3066 | 3067 | preset = context.scene.render_presets.presets[index] 3068 | if not preset.settings: 3069 | self.report({'ERROR'}, "No settings saved for this preset") 3070 | return {'CANCELLED'} 3071 | 3072 | try: 3073 | # Load settings from preset 3074 | settings = json.loads(preset.settings) 3075 | 3076 | # Apply settings based on user selection 3077 | if self.apply_render and "render_settings" in settings: 3078 | apply_settings(context.scene.render, settings["render_settings"]) 3079 | 3080 | if self.apply_cycles and "cycles_settings" in settings and hasattr(context.scene, "cycles"): 3081 | apply_settings(context.scene.cycles, settings["cycles_settings"]) 3082 | 3083 | if self.apply_eevee and "eevee_settings" in settings and hasattr(context.scene, "eevee"): 3084 | apply_settings(context.scene.eevee, settings["eevee_settings"]) 3085 | 3086 | if self.apply_world and "world_settings" in settings and context.scene.world: 3087 | apply_settings(context.scene.world, settings["world_settings"]) 3088 | 3089 | if self.apply_view and "view_settings" in settings: 3090 | apply_settings(context.scene.view_settings, settings["view_settings"]) 3091 | 3092 | self.report({'INFO'}, f"Applied selected settings from preset {preset.name}") 3093 | 3094 | except Exception as e: 3095 | self.report({'ERROR'}, f"Failed to apply settings: {str(e)}") 3096 | return {'CANCELLED'} 3097 | 3098 | return {'FINISHED'} 3099 | 3100 | class RENDER_OT_backup_all_presets(Operator): 3101 | bl_idname = "render.backup_all_presets" 3102 | bl_label = "Backup All Presets" 3103 | bl_description = "Save all render presets to a single file" 3104 | bl_options = {'REGISTER', 'UNDO'} 3105 | 3106 | filepath: StringProperty(subtype="FILE_PATH") 3107 | 3108 | def execute(self, context): 3109 | try: 3110 | presets = context.scene.render_presets.presets 3111 | backup_data = { 3112 | "timestamp": datetime.now().isoformat(), 3113 | "presets": [] 3114 | } 3115 | 3116 | # Collect all presets 3117 | for preset in presets: 3118 | preset_data = { 3119 | "name": preset.name, 3120 | "settings": json.loads(preset.settings) if preset.settings else {} 3121 | } 3122 | backup_data["presets"].append(preset_data) 3123 | 3124 | # Save to file 3125 | with open(self.filepath, 'w') as f: 3126 | json.dump(backup_data, f, indent=4) 3127 | 3128 | self.report({'INFO'}, f"Successfully backed up {len(presets)} presets") 3129 | 3130 | except Exception as e: 3131 | self.report({'ERROR'}, f"Failed to backup presets: {str(e)}") 3132 | return {'CANCELLED'} 3133 | 3134 | return {'FINISHED'} 3135 | 3136 | def invoke(self, context, event): 3137 | if not self.filepath: 3138 | blend_name = os.path.splitext(os.path.basename(bpy.data.filepath))[0] if bpy.data.filepath else "untitled" 3139 | # Sanitize blend name to prevent path traversal 3140 | safe_blend_name = sanitize_filename(blend_name) 3141 | timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") 3142 | self.filepath = os.path.join( 3143 | os.path.dirname(bpy.data.filepath) if bpy.data.filepath else os.path.expanduser("~"), 3144 | f"{safe_blend_name}_render_presets_{timestamp}.json" 3145 | ) 3146 | 3147 | context.window_manager.fileselect_add(self) 3148 | return {'RUNNING_MODAL'} 3149 | 3150 | class RENDER_OT_restore_all_presets(Operator): 3151 | bl_idname = "render.restore_all_presets" 3152 | bl_label = "Restore All Presets" 3153 | bl_description = "Restore render presets from a backup file" 3154 | bl_options = {'REGISTER', 'UNDO'} 3155 | 3156 | filepath: StringProperty(subtype="FILE_PATH") 3157 | replace_existing: BoolProperty( 3158 | name="Replace Existing", 3159 | description="Replace existing presets with the same names", 3160 | default=True 3161 | ) 3162 | 3163 | def execute(self, context): 3164 | try: 3165 | with open(self.filepath, 'r') as f: 3166 | backup_data = json.load(f) 3167 | 3168 | # Validate backup data structure 3169 | if not isinstance(backup_data, dict) or "presets" not in backup_data: 3170 | self.report({'ERROR'}, "Invalid backup file format") 3171 | return {'CANCELLED'} 3172 | 3173 | presets = context.scene.render_presets.presets 3174 | restored_count = 0 3175 | skipped_count = 0 3176 | 3177 | # Process each preset in the backup 3178 | for preset_data in backup_data["presets"]: 3179 | name = preset_data["name"] 3180 | settings = preset_data["settings"] 3181 | 3182 | # Check if preset with this name already exists 3183 | existing_preset = None 3184 | for p in presets: 3185 | if p.name == name: 3186 | existing_preset = p 3187 | break 3188 | 3189 | if existing_preset: 3190 | if self.replace_existing: 3191 | # Update existing preset 3192 | existing_preset.settings = json.dumps(settings) 3193 | restored_count += 1 3194 | else: 3195 | skipped_count += 1 3196 | continue 3197 | else: 3198 | # Create new preset 3199 | new_preset = presets.add() 3200 | new_preset.name = name 3201 | new_preset.settings = json.dumps(settings) 3202 | restored_count += 1 3203 | 3204 | msg = f"Restored {restored_count} presets" 3205 | if skipped_count > 0: 3206 | msg += f" (skipped {skipped_count} existing)" 3207 | self.report({'INFO'}, msg) 3208 | 3209 | except Exception as e: 3210 | self.report({'ERROR'}, f"Failed to restore presets: {str(e)}") 3211 | return {'CANCELLED'} 3212 | 3213 | return {'FINISHED'} 3214 | 3215 | def invoke(self, context, event): 3216 | context.window_manager.fileselect_add(self) 3217 | return {'RUNNING_MODAL'} 3218 | 3219 | def draw(self, context): 3220 | layout = self.layout 3221 | layout.prop(self, "replace_existing") 3222 | 3223 | class RENDER_OT_duplicate_preset(Operator): 3224 | bl_idname = "render.duplicate_preset" 3225 | bl_label = "Duplicate Preset" 3226 | bl_description = "Create a copy of the selected preset" 3227 | bl_options = {'REGISTER', 'UNDO'} 3228 | 3229 | def execute(self, context): 3230 | presets = context.scene.render_presets.presets 3231 | active_index = context.scene.render_presets.active_preset_index 3232 | 3233 | if active_index < 0 or active_index >= len(presets): 3234 | self.report({'WARNING'}, "No preset selected") 3235 | return {'CANCELLED'} 3236 | 3237 | # Get the source preset 3238 | source_preset = presets[active_index] 3239 | 3240 | # Create new preset 3241 | new_preset = presets.add() 3242 | 3243 | # Copy settings 3244 | new_preset.name = source_preset.name + " Copy" 3245 | new_preset.settings = source_preset.settings # Copy the settings JSON string 3246 | 3247 | # Set the new preset as active 3248 | context.scene.render_presets.active_preset_index = len(presets) - 1 3249 | 3250 | self.report({'INFO'}, f"Duplicated preset: {source_preset.name}") 3251 | return {'FINISHED'} 3252 | 3253 | # Update the draw_render_presets function 3254 | def draw_render_presets(self, context, layout): 3255 | box = layout.box() 3256 | box.label(text="Render Presets:", icon="PRESET") 3257 | 3258 | row = box.row() 3259 | # Main list on the left 3260 | row.template_list("RENDER_UL_presets_list", "", context.scene.render_presets, 3261 | "presets", context.scene.render_presets, "active_preset_index") 3262 | 3263 | col = row.column(align=True) 3264 | col.operator("render.add_preset", icon='ADD', text="") 3265 | col.operator("render.remove_preset", icon='REMOVE', text="") 3266 | col.separator() 3267 | col.operator("render.duplicate_preset", icon='DUPLICATE', text="") 3268 | col.operator("render.save_to_preset", icon='FILE_TICK', text="") 3269 | col.operator("render.apply_preset", icon='CHECKMARK', text="") 3270 | 3271 | row = box.row(align=True) 3272 | row.operator("render.backup_all_presets", icon='EXPORT', text="Batch Export") 3273 | row.operator("render.restore_all_presets", icon='IMPORT', text="Batch Import") 3274 | 3275 | row = box.row(align=True) 3276 | row.operator("render.export_all_settings", icon='FILE_BACKUP', text="Export Settings") 3277 | row.operator("render.import_all_settings", icon='FILE_REFRESH', text="Import Settings") 3278 | 3279 | classes = ( 3280 | FavoriteCameraItem, 3281 | CameraNoteItem, 3282 | OBJECT_OT_toggle_default_interpolation, 3283 | OBJECT_OT_toggle_interpolation_selected, 3284 | OBJECT_OT_toggle_interpolation_all, 3285 | OBJECT_OT_apply_all_constant, 3286 | OBJECT_OT_apply_all_bezier, 3287 | OBJECT_OT_apply_all_linear, 3288 | OBJECT_OT_apply_selected_constant, 3289 | OBJECT_OT_apply_selected_bezier, 3290 | OBJECT_OT_apply_selected_linear, 3291 | OBJECT_OT_toggle_auto_keying, 3292 | OBJECT_OT_add_keyframes_operator, 3293 | OBJECT_OT_delete_keyframes_per_steps, 3294 | OBJECT_OT_bake_keyframes_per_steps, 3295 | SCENE_OT_set_frame, 3296 | RenderToolsSettings, 3297 | OBJECT_OT_ApplyRenderToolsSettings, 3298 | SelectHiddenDisableRenderOperator, 3299 | OBJECT_OT_DisableRenderForHidden, 3300 | OBJECT_OT_CreateExceptionCollection, 3301 | OBJECT_OT_AddSelectedToExceptions, 3302 | CreateExcludeHiddenCollectionOperator, 3303 | CustomNameProperties, 3304 | OBJECT_OT_ApplyPassepartoutToAllCameras, 3305 | OBJECT_OT_CreateCameraCollection, 3306 | AddCameraButton, 3307 | AddCameraWithMarkerButton, 3308 | AddCameraCopyPropertiesButton, 3309 | AddCameraShotCopyPropertiesButton, 3310 | SCENE_OT_SetPreviewRange, 3311 | ShowPopupMessageOperator, 3312 | OBJECT_OT_ViewportRenderConfirm, 3313 | OBJECT_OT_ViewportRenderSettings, 3314 | OBJECT_OT_SnapshotRender, 3315 | OBJECT_OT_SnapshotRenderSettings, 3316 | OBJECT_OT_OpenSnapshotDirectory, 3317 | OBJECT_OT_OpenOutputDirectory, 3318 | SCENE_OT_JumpToMarker, 3319 | SCENE_OT_RemoveAllMarkers, 3320 | SCENE_OT_RemoveMarkerAndCamera, 3321 | SCENE_OT_CleanUpMarkers, 3322 | SCENE_OT_RemoveAllShotCameras, 3323 | SCENE_OT_RemoveAllCameras, 3324 | SCENE_OT_SelectCamera, 3325 | OBJECT_OT_toggle_local_camera, 3326 | VIEW3D_MT_PIE_QuickCamera, 3327 | SCENE_OT_SetActiveCamera, 3328 | OBJECT_OT_delete_camera, 3329 | WM_OT_capture_keymap, 3330 | WM_OT_remove_keymap, 3331 | OBJECT_OT_dof_picker, 3332 | OBJECT_OT_remove_dof_object, 3333 | OBJECT_OT_dof_focus_object_picker, 3334 | OBJECT_OT_adjust_focal_length, 3335 | OBJECT_OT_adjust_fstop, 3336 | OBJECT_OT_set_dof_object, 3337 | OBJECT_OT_toggle_lock_camera_to_view, 3338 | OBJECT_OT_select_active_camera, 3339 | OBJECT_OT_toggle_camera_info_overlay, 3340 | OBJECT_OT_toggle_camera_notes_overlay, 3341 | OBJECT_OT_add_note_interactive, 3342 | OBJECT_OT_add_camera_note, 3343 | OBJECT_OT_remove_camera_note, 3344 | OBJECT_OT_clear_camera_notes, 3345 | VIEW3D_MT_PIE_camera_controls, 3346 | SCENE_OT_set_and_view_camera, 3347 | OBJECT_OT_create_empty_focus, 3348 | OBJECT_OT_toggle_favorite_camera, 3349 | VIEW3D_MT_PIE_favorite_camera, 3350 | OBJECT_OT_toggle_favorite_active_camera, 3351 | OBJECT_OT_ExportAllSettings, 3352 | OBJECT_OT_ImportAllSettings, 3353 | RenderPreset, 3354 | RenderPresetsCollection, 3355 | RENDER_UL_presets_list, 3356 | RENDER_OT_add_preset, 3357 | RENDER_OT_remove_preset, 3358 | RENDER_OT_save_to_preset, 3359 | RENDER_OT_apply_preset, 3360 | RENDER_OT_backup_all_presets, 3361 | RENDER_OT_restore_all_presets, 3362 | RENDER_OT_duplicate_preset, 3363 | 3364 | 3365 | ) 3366 | 3367 | 3368 | 3369 | if __name__ == "__main__": 3370 | import bpy 3371 | pass 3372 | 3373 | --------------------------------------------------------------------------------