Cleanup: minor changes & simplification to Align-XY
[blender-addons.git] / system_demo_mode / demo_mode.py
blob6060bf0034a1b788c7571bff979fbbd473598d33
1 # SPDX-License-Identifier: GPL-2.0-or-later
3 # <pep8 compliant>
5 """
6 Even though this is in a package this can run as a stand alone scripts.
8 # --- example usage
9 blender --python release/scripts/addons/system_demo_mode/demo_mode.py
11 looks for demo.py textblock or file in the same path as the blend:
12 # --- example
13 config = [
14 dict(anim_cycles=1, anim_render=False, anim_screen_switch=0.0, anim_time_max=10.0, anim_time_min=4.0, mode='AUTO', display_render=4.0, file='/l/19534_simplest_mesh_2.blend'),
15 dict(anim_cycles=1, anim_render=False, anim_screen_switch=0.0, anim_time_max=10.0, anim_time_min=4.0, mode='AUTO', display_render=4.0, file='/l/252_pivotConstraint_01.blend'),
17 # ---
18 /data/src/blender/lib/tests/rendering/
19 """
21 import bpy
22 import time
23 import os
25 DEMO_CFG = "demo.py"
27 # populate from script
28 global_config_files = []
30 global_config = dict(
31 anim_cycles=1,
32 anim_render=False,
33 anim_screen_switch=0.0,
34 anim_time_max=60.0,
35 anim_time_min=4.0,
36 mode='AUTO',
37 display_render=4.0,
40 # switch to the next file in 2 sec.
41 global_config_fallback = dict(
42 anim_cycles=1,
43 anim_render=False,
44 anim_screen_switch=0.0,
45 anim_time_max=60.0,
46 anim_time_min=4.0,
47 mode='AUTO',
48 display_render=4.0,
51 global_state = {
52 "init_time": 0.0,
53 "last_switch": 0.0,
54 "reset_anim": False,
55 "anim_cycles": 0, # count how many times we played the anim
56 "last_frame": -1,
57 "is_render": False,
58 "render_time": "", # time render was finished.
59 "timer": None,
60 "basedir": "", # demo.py is stored here
61 "demo_index": 0,
62 "exit": False,
66 # -----------------------------------------------------------------------------
67 # render handler - maintain "is_render"
69 def handle_render_clear():
70 for ls in (bpy.app.handlers.render_complete, bpy.app.handlers.render_cancel):
71 while handle_render_done_cb in ls:
72 ls.remove(handle_render_done_cb)
75 def handle_render_done_cb(self):
76 global_state["is_render"] = True
79 def handle_render_init():
80 handle_render_clear()
81 bpy.app.handlers.render_complete.append(handle_render_done_cb)
82 bpy.app.handlers.render_cancel.append(handle_render_done_cb)
83 global_state["is_render"] = False
86 def demo_mode_auto_select():
88 play_area = 0
89 render_area = 0
91 totimg = 0
93 for area in bpy.context.window.screen.areas:
94 size = area.width * area.height
95 if area.type in {'VIEW_3D', 'GRAPH_EDITOR', 'DOPESHEET_EDITOR', 'NLA_EDITOR', 'TIMELINE'}:
96 play_area += size
97 elif area.type in {'IMAGE_EDITOR', 'SEQUENCE_EDITOR', 'NODE_EDITOR'}:
98 render_area += size
100 if area.type == 'IMAGE_EDITOR':
101 totimg += 1
103 # since our test files have this as defacto standard
104 scene = bpy.context.scene
105 if totimg >= 2 and (scene.camera or scene.render.use_sequencer):
106 mode = 'RENDER'
107 else:
108 if play_area >= render_area:
109 mode = 'PLAY'
110 else:
111 mode = 'RENDER'
113 if 0:
114 return 'PLAY'
116 return mode
119 def demo_mode_next_file(step=1):
121 # support for temp
122 if global_config_files[global_state["demo_index"]].get("is_tmp"):
123 del global_config_files[global_state["demo_index"]]
124 global_state["demo_index"] -= 1
126 print(global_state["demo_index"])
127 demo_index_next = (global_state["demo_index"] + step) % len(global_config_files)
129 if global_state["exit"] and step > 0:
130 # check if we cycled
131 if demo_index_next < global_state["demo_index"]:
132 import sys
133 sys.exit(0)
135 global_state["demo_index"] = demo_index_next
136 print(global_state["demo_index"], "....")
137 print("func:demo_mode_next_file", global_state["demo_index"])
138 filepath = global_config_files[global_state["demo_index"]]["file"]
139 bpy.ops.wm.open_mainfile(filepath=filepath)
142 def demo_mode_timer_add():
143 global_state["timer"] = bpy.context.window_manager.event_timer_add(0.8, window=bpy.context.window)
146 def demo_mode_timer_remove():
147 if global_state["timer"]:
148 bpy.context.window_manager.event_timer_remove(global_state["timer"])
149 global_state["timer"] = None
152 def demo_mode_load_file():
153 """ Take care, this can only do limited functions since its running
154 before the file is fully loaded.
155 Some operators will crash like playing an animation.
157 print("func:demo_mode_load_file")
158 DemoMode.first_run = True
159 bpy.ops.wm.demo_mode('EXEC_DEFAULT')
162 def demo_mode_temp_file():
163 """ Initialize a temp config for the duration of the play time.
164 Use this so we can initialize the demo intro screen but not show again.
166 assert(global_state["demo_index"] == 0)
168 temp_config = global_config_fallback.copy()
169 temp_config["anim_time_min"] = 0.0
170 temp_config["anim_time_max"] = 60.0
171 temp_config["anim_cycles"] = 0 # ensures we switch when hitting the end
172 temp_config["mode"] = 'PLAY'
173 temp_config["is_tmp"] = True
175 global_config_files.insert(0, temp_config)
178 def demo_mode_init():
179 print("func:demo_mode_init")
180 DemoKeepAlive.ensure()
182 if 1:
183 global_config.clear()
184 global_config.update(global_config_files[global_state["demo_index"]])
186 print(global_config)
188 demo_mode_timer_add()
190 if global_config["mode"] == 'AUTO':
191 global_config["mode"] = demo_mode_auto_select()
193 if global_config["mode"] == 'PLAY':
194 global_state["last_frame"] = -1
195 global_state["anim_cycles"] = 0
196 bpy.ops.screen.animation_play()
198 elif global_config["mode"] == 'RENDER':
199 print(" render")
201 # setup scene.
202 scene = bpy.context.scene
203 scene.render.filepath = "TEMP_RENDER"
204 scene.render.image_settings.file_format = 'AVI_JPEG' if global_config["anim_render"] else 'PNG'
205 scene.render.use_file_extension = False
206 scene.render.use_placeholder = False
207 try:
208 # XXX - without this rendering will crash because of a bug in blender!
209 bpy.ops.wm.redraw_timer(type='DRAW_WIN_SWAP', iterations=1)
210 if global_config["anim_render"]:
211 bpy.ops.render.render('INVOKE_DEFAULT', animation=True)
212 else:
213 bpy.ops.render.render('INVOKE_DEFAULT') # write_still=True, no need to write now.
215 handle_render_init()
217 except RuntimeError: # no camera for eg:
218 import traceback
219 traceback.print_exc()
221 else:
222 raise Exception("Unsupported mode %r" % global_config["mode"])
224 global_state["init_time"] = global_state["last_switch"] = time.time()
225 global_state["render_time"] = -1.0
228 def demo_mode_update():
229 time_current = time.time()
230 time_delta = time_current - global_state["last_switch"]
231 time_total = time_current - global_state["init_time"]
233 # --------------------------------------------------------------------------
234 # ANIMATE MODE
235 if global_config["mode"] == 'PLAY':
236 frame = bpy.context.scene.frame_current
237 # check for exit
238 if time_total > global_config["anim_time_max"]:
239 demo_mode_next_file()
240 return
241 # above cycles and minimum display time
242 if (
243 (time_total > global_config["anim_time_min"]) and
244 (global_state["anim_cycles"] > global_config["anim_cycles"])
246 # looped enough now.
247 demo_mode_next_file()
248 return
250 # run update funcs
251 if global_state["reset_anim"]:
252 global_state["reset_anim"] = False
253 bpy.ops.screen.animation_cancel(restore_frame=False)
254 bpy.ops.screen.animation_play()
256 # warning, switching the screen can switch the scene
257 # and mess with our last-frame/cycles counting.
258 if global_config["anim_screen_switch"]:
259 # print(time_delta, 1)
260 if time_delta > global_config["anim_screen_switch"]:
261 window = bpy.context.window
262 scene = window.scene
263 workspace = window.workspace
264 index = bpy.data.workspaces.keys().index(workspace.name)
265 workspace_new = bpy.data.workspaces[(index + 1) % len(bpy.data.workspaces)]
266 window.workspace = workspace_new
268 global_state["last_switch"] = time_current
270 # If we also switch scenes then reset last frame
271 # otherwise it could mess up cycle calculation.
272 if scene != window.scene:
273 global_state["last_frame"] = -1
275 #if global_config["mode"] == 'PLAY':
276 if 1:
277 global_state["reset_anim"] = True
279 # did we loop?
280 if global_state["last_frame"] > frame:
281 print("Cycle!")
282 global_state["anim_cycles"] += 1
284 global_state["last_frame"] = frame
286 # --------------------------------------------------------------------------
287 # RENDER MODE
288 elif global_config["mode"] == 'RENDER':
289 if global_state["is_render"]:
290 # wait until the time has passed
291 # XXX, todo, if rendering an anim we need some way to check its done.
292 if global_state["render_time"] == -1.0:
293 global_state["render_time"] = time.time()
294 else:
295 if time.time() - global_state["render_time"] > global_config["display_render"]:
296 handle_render_clear()
297 demo_mode_next_file()
298 return
299 else:
300 raise Exception("Unsupported mode %r" % global_config["mode"])
302 # -----------------------------------------------------------------------------
303 # modal operator
306 class DemoKeepAlive:
307 secret_attr = "_keepalive"
309 @staticmethod
310 def ensure():
311 if DemoKeepAlive.secret_attr not in bpy.app.driver_namespace:
312 bpy.app.driver_namespace[DemoKeepAlive.secret_attr] = DemoKeepAlive()
314 @staticmethod
315 def remove():
316 if DemoKeepAlive.secret_attr in bpy.app.driver_namespace:
317 del bpy.app.driver_namespace[DemoKeepAlive.secret_attr]
319 def __del__(self):
320 """ Hack, when the file is loaded the drivers namespace is cleared.
322 if DemoMode.enabled:
323 demo_mode_load_file()
326 class DemoMode(bpy.types.Operator):
327 bl_idname = "wm.demo_mode"
328 bl_label = "Demo"
330 enabled = False
331 first_run = True
333 def cleanup(self, disable=False):
334 demo_mode_timer_remove()
335 DemoMode.first_run = True
337 if disable:
338 DemoMode.enabled = False
339 DemoKeepAlive.remove()
341 def modal(self, context, event):
342 # print("DemoMode.modal", global_state["anim_cycles"])
343 if not DemoMode.enabled:
344 self.cleanup(disable=True)
345 return {'CANCELLED'}
347 if event.type == 'ESC':
348 self.cleanup(disable=True)
349 # disable here and not in cleanup because this is a user level disable.
350 # which should stay disabled until explicitly enabled again.
351 return {'CANCELLED'}
353 # print(event.type)
354 if DemoMode.first_run:
355 DemoMode.first_run = False
357 demo_mode_init()
358 else:
359 demo_mode_update()
361 return {'PASS_THROUGH'}
363 def execute(self, context):
364 print("func:DemoMode.execute:", len(global_config_files), "files")
366 use_temp = False
368 # load config if not loaded
369 if not global_config_files:
370 load_config()
371 use_temp = True
373 if not global_config_files:
374 self.report({'INFO'}, "No configuration found with text or file: %s. Run File -> Demo Mode Setup" % DEMO_CFG)
375 return {'CANCELLED'}
377 if use_temp:
378 demo_mode_temp_file() # play this once through then never again
380 # toggle
381 if DemoMode.enabled and DemoMode.first_run is False:
382 # this actually cancells the previous running instance
383 # should never happen now, DemoModeControl is for this.
384 return {'CANCELLED'}
385 else:
386 DemoMode.enabled = True
388 context.window_manager.modal_handler_add(self)
389 return {'RUNNING_MODAL'}
391 def cancel(self, context):
392 print("func:DemoMode.cancel")
393 # disable here means no running on file-load.
394 self.cleanup()
396 # call from DemoModeControl
397 @classmethod
398 def disable(cls):
399 if cls.enabled and cls.first_run is False:
400 # this actually cancells the previous running instance
401 # should never happen now, DemoModeControl is for this.
402 cls.enabled = False
405 class DemoModeControl(bpy.types.Operator):
406 bl_idname = "wm.demo_mode_control"
407 bl_label = "Control"
409 mode: bpy.props.EnumProperty(
410 items=(('PREV', "Prev", ""),
411 ('PAUSE', "Pause", ""),
412 ('NEXT', "Next", "")),
413 name="Mode"
416 def execute(self, context):
417 mode = self.mode
418 if mode == 'PREV':
419 demo_mode_next_file(-1)
420 elif mode == 'NEXT':
421 demo_mode_next_file(1)
422 else: # pause
423 DemoMode.disable()
424 return {'FINISHED'}
427 def menu_func(self, context):
428 # print("func:menu_func - DemoMode.enabled:", DemoMode.enabled, "bpy.app.driver_namespace:", DemoKeepAlive.secret_attr not in bpy.app.driver_namespace, 'global_state["timer"]:', global_state["timer"])
429 layout = self.layout
430 layout.operator_context = 'EXEC_DEFAULT'
431 row = layout.row(align=True)
432 row.label(text="Demo Mode:")
433 if not DemoMode.enabled:
434 row.operator("wm.demo_mode", icon='PLAY', text="")
435 else:
436 row.operator("wm.demo_mode_control", icon='REW', text="").mode = 'PREV'
437 row.operator("wm.demo_mode_control", icon='PAUSE', text="").mode = 'PAUSE'
438 row.operator("wm.demo_mode_control", icon='FF', text="").mode = 'NEXT'
441 def register():
442 bpy.utils.register_class(DemoMode)
443 bpy.utils.register_class(DemoModeControl)
444 bpy.types.INFO_HT_header.append(menu_func)
447 def unregister():
448 bpy.utils.unregister_class(DemoMode)
449 bpy.utils.unregister_class(DemoModeControl)
450 bpy.types.INFO_HT_header.remove(menu_func)
453 # -----------------------------------------------------------------------------
454 # parse args
456 def load_config(cfg_name=DEMO_CFG):
457 namespace = {}
458 del global_config_files[:]
459 basedir = os.path.dirname(bpy.data.filepath)
461 text = bpy.data.texts.get(cfg_name)
462 if text is None:
463 demo_path = os.path.join(basedir, cfg_name)
464 if os.path.exists(demo_path):
465 print("Using config file: %r" % demo_path)
466 demo_file = open(demo_path, "r")
467 demo_data = demo_file.read()
468 demo_file.close()
469 else:
470 demo_data = ""
471 else:
472 print("Using config textblock: %r" % cfg_name)
473 demo_data = text.as_string()
474 demo_path = os.path.join(bpy.data.filepath, cfg_name) # fake
476 if not demo_data:
477 print("Could not find %r textblock or %r file." % (DEMO_CFG, demo_path))
478 return False
480 namespace["__file__"] = demo_path
482 exec(demo_data, namespace, namespace)
484 demo_config = namespace["config"]
485 demo_search_path = namespace.get("search_path")
486 global_state["exit"] = namespace.get("exit", False)
488 if demo_search_path is None:
489 print("reading: %r, no search_path found, missing files wont be searched." % demo_path)
490 if demo_search_path.startswith("//"):
491 demo_search_path = bpy.path.abspath(demo_search_path)
492 if not os.path.exists(demo_search_path):
493 print("reading: %r, search_path %r does not exist." % (demo_path, demo_search_path))
494 demo_search_path = None
496 blend_lookup = {}
497 # initialize once, case insensitive dict
499 def lookup_file(filepath):
500 filename = os.path.basename(filepath).lower()
502 if not blend_lookup:
503 # ensure only ever run once.
504 blend_lookup[None] = None
506 def blend_dict_items(path):
507 for dirpath, dirnames, filenames in os.walk(path):
508 # skip '.git'
509 dirnames[:] = [d for d in dirnames if not d.startswith(".")]
510 for filename in filenames:
511 if filename.lower().endswith(".blend"):
512 filepath = os.path.join(dirpath, filename)
513 yield (filename.lower(), filepath)
515 blend_lookup.update(dict(blend_dict_items(demo_search_path)))
517 # fallback to original file
518 return blend_lookup.get(filename, filepath)
519 # done with search lookup
521 for filecfg in demo_config:
522 filepath_test = filecfg["file"]
523 if not os.path.exists(filepath_test):
524 filepath_test = os.path.join(basedir, filecfg["file"])
525 if not os.path.exists(filepath_test):
526 filepath_test = lookup_file(filepath_test) # attempt to get from searchpath
527 if not os.path.exists(filepath_test):
528 print("Cant find %r or %r, skipping!")
529 continue
531 filecfg["file"] = os.path.normpath(filepath_test)
533 # sanitize
534 filecfg["file"] = os.path.abspath(filecfg["file"])
535 filecfg["file"] = os.path.normpath(filecfg["file"])
536 print(" Adding: %r" % filecfg["file"])
537 global_config_files.append(filecfg)
539 print("found %d files" % len(global_config_files))
541 global_state["basedir"] = basedir
543 return bool(global_config_files)
546 # support direct execution
547 if __name__ == "__main__":
548 register()
550 demo_mode_load_file() # kick starts the modal operator