# SPDX-License-Identifier: MIT OR GPL-3.0-or-later import base64 import hashlib import inspect from collections.abc import Sequence from dataclasses import dataclass, field from enum import Enum from pathlib import Path from tempfile import mkdtemp from typing import Final, Optional, Protocol import bpy from bpy.app.handlers import persistent from bpy.types import Context class RunState(Enum): PREEMPT = 1 FINISH = 2 class SceneWatcher(Protocol): """Protocol for detecting scene changes and performing some action when detected.""" def run(self, context: Context) -> RunState: """Detect scene changes and perform some action when detected. If no changes that need to be detected have occurred in the scene, this method must return in less than 100 microseconds. If there is not enough time, save the current state to instance variables and interrupt the process. This method is executed multiple times across Blender frames. Usually such processing can be efficiently implemented using generators or async, but this class does not do so. The reason is that references to Blender's native objects cannot be held across frames, but programming with generators and async is difficult when considering this limitation. :return FINISH if processing is complete. PREEMPT if not complete. """ raise NotImplementedError def create_fast_path_performance_test_objects(self, context: Context) -> None: """Create objects for testing whether run() normally completes. This tests whether the run() method normally completes in less than 100 microseconds. """ raise NotImplementedError def reset_run_progress(self) -> None: """Reset the interrupted state of run().""" raise NotImplementedError @dataclass class SceneWatcherSchedule: """Holds SceneWatcher and its operation status.""" scene_watcher: SceneWatcher requires_run_once_more: bool = False finished: bool = False @dataclass class SceneWatcherScheduler: """Periodically executes registered SceneWatchers in order. This scheduler is careful not to block the UI while executing the SceneWatcher instances. """ INTERVAL: Final[float] = 0.2 scene_watcher_schedule_index: int = 0 scene_watcher_type_to_schedule: dict[type[SceneWatcher], SceneWatcherSchedule] = ( field(default_factory=dict[type[SceneWatcher], SceneWatcherSchedule]) ) scene_watcher_schedules: list[SceneWatcherSchedule] = field( default_factory=list[SceneWatcherSchedule] ) def trigger(self, scene_watcher_type: type[SceneWatcher]) -> None: scene_watcher_schedule = self.scene_watcher_type_to_schedule.get( scene_watcher_type ) if scene_watcher_schedule: # High-frequency fast path. # Allow returning with minimal processing. if scene_watcher_schedule.finished: # If the task is finished, restart it scene_watcher_schedule.finished = False scene_watcher_schedule.scene_watcher.reset_run_progress() else: # If the task is running, # set it to "run once more after completion" scene_watcher_schedule.requires_run_once_more = True return # Low-frequency slow path. # Heavy processing is okay. # To prevent unregistered SceneWatchers from being missed in testing, # throw an error if trying to instantiate an unregistered SceneWatcher. if scene_watcher_type not in self.get_all_scene_watcher_types(): message = f"{scene_watcher_type} is not registered" raise NotImplementedError(message) new_scene_watcher = scene_watcher_type() new_schedule = SceneWatcherSchedule(scene_watcher=new_scene_watcher) self.scene_watcher_type_to_schedule[scene_watcher_type] = new_schedule self.scene_watcher_schedules.append(new_schedule) @staticmethod def get_all_scene_watcher_types() -> Sequence[type[SceneWatcher]]: from ..editor.mtoon1.scene_watcher import MToon1AutoSetup, OutlineUpdater from ..editor.vrm1.scene_watcher import LookAtPreviewUpdater return [OutlineUpdater, LookAtPreviewUpdater, MToon1AutoSetup] def process(self, context: Context) -> bool: """Execute one SceneWatcher. Use index values to reduce GC allocation. """ if not self.scene_watcher_schedules: return False for _ in range(len(self.scene_watcher_schedules)): self.scene_watcher_schedule_index += 1 self.scene_watcher_schedule_index %= len(self.scene_watcher_schedules) # Skip tasks that are already completed scene_watcher_schedule = self.scene_watcher_schedules[ self.scene_watcher_schedule_index ] if scene_watcher_schedule.finished: continue # Execute the task run_state = scene_watcher_schedule.scene_watcher.run(context) if run_state == RunState.FINISH: if scene_watcher_schedule.requires_run_once_more: scene_watcher_schedule.requires_run_once_more = False scene_watcher_schedule.scene_watcher.reset_run_progress() else: scene_watcher_schedule.finished = True # Return if at least one task was executed return True return False def flush(self, context: Context) -> None: while self.process(context): pass scene_watcher_scheduler = SceneWatcherScheduler() def process_scene_watcher_scheduler() -> Optional[float]: context = bpy.context scene_watcher_scheduler.process(context) return SceneWatcherScheduler.INTERVAL def create_fast_path_performance_test_scene( context: Context, scene_watcher: SceneWatcher ) -> None: class_file_path_str = inspect.getfile(type(scene_watcher)) if not class_file_path_str: message = f"No path for class {type(scene_watcher)}" raise ValueError(message) class_file_path = Path(class_file_path_str) if not class_file_path.exists(): message = f"No {class_file_path} found" raise ValueError(message) class_source_hash = ( base64.urlsafe_b64encode( hashlib.sha3_224(class_file_path.read_bytes()).digest() ) .rstrip(b"=") .decode() ) repository_root_path = ( Path(__file__).resolve(strict=True).parent.parent.parent.parent ) if (repository_root_path / ".git").exists() and ( repository_root_path / "pyproject.toml" ).exists(): temp_path = repository_root_path / "tests" / "temp" else: temp_path = Path(mkdtemp(prefix="vrm-format-")) cached_blend_path = temp_path / ( type(scene_watcher).__name__ + "-" + "_".join(map(str, bpy.app.version)) + "-" + class_source_hash + ".blend" ) if not cached_blend_path.exists(): bpy.ops.wm.read_homefile(use_empty=True) scene_watcher.create_fast_path_performance_test_objects(context) bpy.ops.wm.save_as_mainfile(filepath=str(cached_blend_path)) bpy.ops.wm.read_homefile(use_empty=True) bpy.ops.wm.open_mainfile(filepath=str(cached_blend_path)) def trigger_scene_watcher(scene_watcher_type: type[SceneWatcher]) -> None: scene_watcher_scheduler.trigger(scene_watcher_type) def setup() -> None: if bpy.app.timers.is_registered(process_scene_watcher_scheduler): return bpy.app.timers.register( process_scene_watcher_scheduler, first_interval=SceneWatcherScheduler.INTERVAL ) @persistent def save_pre(_unused: object) -> None: context = bpy.context scene_watcher_scheduler.flush(context)