Created
September 21, 2025 10:03
-
-
Save minimalefforttech/051bd24e455bedd3312352432385df03 to your computer and use it in GitHub Desktop.
Doom Player Panel in Blender Icon
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| """ | |
| Running Doom inside a Blender icon | |
| Copyright (c) 2025 Alex Telford | |
| License: GPLv3 or later | |
| 3rd Party Libraries: | |
| - vizdoom: MIT License | |
| - numpy: Modified BSD License (Bundled with Blender) | |
| - pillow: HPND License | |
| Product listing: | |
| https://alextelford.gumroad.com/l/blender_doom_icon | |
| This is a free product, no restrictions on use, but likewise the code is provided as-is, with no warranty. | |
| Disclaimer: | |
| This will crash at some point, either by consuming too much memory or vizdoom breaking when you die. | |
| I just wanted to see if I could do it, it's not really a functional or useful product. | |
| Instructions: | |
| - Download freedoom from https://freedoom.github.io/download.html | |
| - Extract the zip and note the path to freedoom1.wad | |
| - Run this script in Blender, ideally a standalone version so it can install dependencies. | |
| - Wait for 3rd party dependencies to install, this can take a bit. | |
| - In the Scene Properties panel go to the "Doom Player" tab. | |
| - Click "Enable Doom Player" to enable the player. | |
| - Set the path to your freedoom1.wad file. | |
| - Click "Start Game" to start the game. | |
| - Click the arrows to move around. | |
| Config | |
| - The FPS controls the game update and speed, too high and it will flicker | |
| - The Preflight controls how many frames to buffer ahead of time, more means less stutter the game will be that many frames behind. | |
| Enjoy the insanity. | |
| I've released this for free, if you want to support my shenanigans please consider buying one of my books: | |
| Blender Tool Development Fundamentals (Pre-order) | |
| https://jettelly.com/store/blender-tool-development-fundamentals?referral=atelford | |
| Practical Python for Production under Pressure (Complete) | |
| https://leanpub.com/practical_python | |
| Or buying the listing for this script: | |
| https://alextelford.gumroad.com/l/blender_doom_icon | |
| But no pressure, it's free to use and modify as you wish. | |
| """ | |
| from __future__ import annotations | |
| import os | |
| import sys | |
| import subprocess | |
| import ensurepip | |
| import tempfile | |
| import shutil | |
| import importlib | |
| import traceback | |
| from dataclasses import dataclass | |
| from typing import Dict, List, Optional | |
| import bpy | |
| from bpy.utils import previews # Seems to be a bug in 4.5.3 portable where this is not in bpy.utils by default | |
| from bpy.props import StringProperty, IntProperty, BoolProperty | |
| from bpy.types import Panel, Operator, PropertyGroup | |
| # MARK: Constants | |
| FRAMES_PER_COLLECTION = 100 | |
| ICON_SCALE = 12 | |
| DEFAULT_WAD_PATH = "" | |
| # DEFAULT_WAD_PATH = r"C:\\projects\\freedoom-0.13.0\\freedoom1.wad" | |
| DEFAULT_MAP = "map01" | |
| ESCAPE_TO_STOP = False # Set to True to allow ESC to stop the game | |
| def _ensure_dependencies(dependencies): | |
| """ | |
| Ensure dependencies are installed for Blender's Python. | |
| Args: | |
| dependencies (list): List of dependencies, where each item is either: | |
| - "package" (same for pip and import) | |
| - ("pip_name", "import_name") | |
| """ | |
| # Normalize dependencies into (pip_name, import_name) | |
| normalized = [(dep, dep) if isinstance(dep, str) else dep for dep in dependencies] | |
| missing = [] | |
| for pip_name, import_name in normalized: | |
| try: | |
| importlib.import_module(import_name) | |
| except ImportError: | |
| missing.append((pip_name, import_name)) | |
| if not missing: | |
| return | |
| # Target Blender's site-packages explicitly, this prevents it being installed | |
| # Into the system globals which can mess with things | |
| site_packages = os.path.join(os.path.dirname(sys.executable), "..", "Lib", "site-packages") | |
| site_packages = os.path.normpath(site_packages) | |
| # Check site_packages exists and is writable | |
| if not os.path.exists(site_packages) or not os.access(site_packages, os.W_OK): | |
| pkgs = " ".join(p for p, _ in missing) | |
| raise ImportError( | |
| f"Need to install dependencies: {pkgs}\nBut cannot write to site-packages, try using the portable version" | |
| ) | |
| try: | |
| ensurepip.bootstrap() | |
| for pip_name, _ in missing: | |
| subprocess.check_call([sys.executable, "-m", "pip", "install", pip_name, "--target", site_packages]) | |
| # Verify imports after install | |
| for _, import_name in missing: | |
| importlib.import_module(import_name) | |
| except Exception as e: | |
| pkgs = " ".join(p for p, _ in missing) | |
| raise ImportError( | |
| f"Could not auto-install dependencies. " | |
| f"Install manually with:\n" | |
| f'"{sys.executable}" -m pip install {pkgs} --target "{site_packages}"' | |
| ) from e | |
| _ensure_dependencies(["vizdoom", ("pillow", "PIL")]) | |
| import numpy as np # noqa: E402 | |
| import vizdoom as vzd # noqa: E402 | |
| from PIL import Image # noqa: E402 | |
| # MARK: Globals | |
| preview_collections: Dict[str, previews.ImagePreviewCollection] = {} | |
| PREFLIGHT_QUEUE: List[str] = [] | |
| _IS_UPDATING_MOVEMENT = False | |
| ACTIVE_DOOM_WRANGLER: Optional["DoomWrangler"] = None | |
| # MARK: Prop callbacks | |
| def update_move_forward(self, context): | |
| global _IS_UPDATING_MOVEMENT | |
| if _IS_UPDATING_MOVEMENT: | |
| return | |
| if self.move_forward: | |
| _IS_UPDATING_MOVEMENT = True | |
| self.move_backward = False | |
| _IS_UPDATING_MOVEMENT = False | |
| def update_move_backward(self, context): | |
| global _IS_UPDATING_MOVEMENT | |
| if _IS_UPDATING_MOVEMENT: | |
| return | |
| if self.move_backward: | |
| _IS_UPDATING_MOVEMENT = True | |
| self.move_forward = False | |
| _IS_UPDATING_MOVEMENT = False | |
| def update_turn_left(self, context): | |
| global _IS_UPDATING_MOVEMENT | |
| if _IS_UPDATING_MOVEMENT: | |
| return | |
| if self.turn_left: | |
| _IS_UPDATING_MOVEMENT = True | |
| self.turn_right = False | |
| _IS_UPDATING_MOVEMENT = False | |
| def update_turn_right(self, context): | |
| global _IS_UPDATING_MOVEMENT | |
| if _IS_UPDATING_MOVEMENT: | |
| return | |
| if self.turn_right: | |
| _IS_UPDATING_MOVEMENT = True | |
| self.turn_left = False | |
| _IS_UPDATING_MOVEMENT = False | |
| # MARK: Properties | |
| class PreflightFrameItem(bpy.types.PropertyGroup): | |
| """Preflight frame reference item""" | |
| frame_id: StringProperty(name="Frame ID") | |
| collection_name: StringProperty(name="Collection Name") | |
| class DoomPlayerProperties(PropertyGroup): | |
| """Root property group for Doom player""" | |
| # Config | |
| fps: IntProperty(name="FPS", default=12, min=1, max=60) | |
| preflight: IntProperty(name="Preflight Frames", default=2, min=0, max=30) | |
| wad_path: StringProperty( | |
| name="WAD File", | |
| description="Path to the Doom WAD file", | |
| default=DEFAULT_WAD_PATH, | |
| maxlen=1024, | |
| subtype="FILE_PATH", | |
| ) | |
| current_map: StringProperty(name="Current Map", default=DEFAULT_MAP) | |
| temp_dir: StringProperty(name="Temp Directory", default="") | |
| is_enabled: BoolProperty(name="Enable Doom Player", description="Enable/disable the Doom player", default=False) | |
| is_running: BoolProperty(name="Game Running", description="Whether the game is currently running", default=False) | |
| use_keyboard: BoolProperty(name="Use Keyboard", default=False) | |
| # Input | |
| move_forward: BoolProperty(name="Move Forward", default=False, update=update_move_forward) | |
| move_backward: BoolProperty(name="Move Backward", default=False, update=update_move_backward) | |
| turn_left: BoolProperty(name="Turn Left", default=False, update=update_turn_left) | |
| turn_right: BoolProperty(name="Turn Right", default=False, update=update_turn_right) | |
| attack: BoolProperty(name="Attack", default=False) | |
| use: BoolProperty(name="Use", default=False) | |
| # State | |
| frame_counter: IntProperty(name="Frame Counter", default=0) | |
| current_frame_id: StringProperty(name="Current Frame ID", default="") | |
| preflight_frames: bpy.props.CollectionProperty(type=bpy.types.PropertyGroup) | |
| current_collection_index: IntProperty(name="Current Collection Index", default=0) | |
| # MARK: Wrangler | |
| @dataclass | |
| class DoomState: | |
| game: Optional[vzd.DoomGame] = None | |
| initialized: bool = False | |
| frame_id_counter: int = 0 | |
| class DoomWrangler: | |
| def __init__(self, props: DoomPlayerProperties): | |
| self.props = props | |
| self.state = DoomState() | |
| def initialize(self): | |
| if self.state.initialized: | |
| return | |
| temp_dir = tempfile.mkdtemp(prefix="doom_frames_") | |
| self.props.temp_dir = temp_dir | |
| to_remove = [n for n in preview_collections if n.startswith("doom_frames_")] | |
| for name in to_remove: | |
| try: | |
| previews.remove(preview_collections[name]) | |
| except Exception: | |
| pass | |
| preview_collections.pop(name, None) | |
| game = vzd.DoomGame() | |
| game.set_doom_game_path(self.props.wad_path) | |
| game.set_doom_map(self.props.current_map) | |
| game.set_window_visible(False) | |
| game.set_mode(vzd.Mode.PLAYER) | |
| game.set_screen_format(vzd.ScreenFormat.RGB24) | |
| game.set_screen_resolution(vzd.ScreenResolution.RES_320X240) | |
| game.set_render_hud(True) | |
| game.set_render_minimal_hud(False) | |
| game.set_render_crosshair(True) | |
| game.set_render_weapon(True) | |
| game.set_render_decals(True) | |
| game.set_render_particles(True) | |
| game.set_available_buttons( | |
| [ | |
| vzd.Button.MOVE_FORWARD, | |
| vzd.Button.MOVE_BACKWARD, | |
| vzd.Button.TURN_LEFT, | |
| vzd.Button.TURN_RIGHT, | |
| vzd.Button.ATTACK, | |
| vzd.Button.USE, | |
| ] | |
| ) | |
| game.init() | |
| self.state.game = game | |
| self.state.initialized = True | |
| def step(self): | |
| if not self.state.initialized or not self.state.game: | |
| self.initialize() | |
| return | |
| g = self.state.game | |
| if g.is_episode_finished(): | |
| g.new_episode() | |
| action = [ | |
| 1 if self.props.move_forward else 0, | |
| 1 if self.props.move_backward else 0, | |
| 1 if self.props.turn_left else 0, | |
| 1 if self.props.turn_right else 0, | |
| 1 if self.props.attack else 0, | |
| 1 if self.props.use else 0, | |
| ] | |
| g.make_action(action) | |
| state = g.get_state() | |
| if state and state.screen_buffer is not None: | |
| self._update_frame(state) | |
| def _update_frame(self, state): | |
| buf = state.screen_buffer | |
| if buf.ndim == 3 and buf.shape[0] in (3, 4): | |
| img_array = np.transpose(buf, (1, 2, 0)) | |
| else: | |
| img_array = buf | |
| if img_array.ndim == 3: | |
| img_pil = Image.fromarray(img_array.astype(np.uint8)) | |
| else: | |
| img_pil = Image.fromarray(img_array.astype(np.uint8), mode="L") | |
| frame_file = os.path.join(self.props.temp_dir, f"frame_{self.props.frame_counter:06d}.png") | |
| img_pil.save(frame_file) | |
| local_index = self.state.frame_id_counter // FRAMES_PER_COLLECTION | |
| collection_name = f"doom_frames_{local_index}" | |
| if collection_name not in preview_collections: | |
| preview_collections[collection_name] = previews.new() | |
| to_purge = [] | |
| for cname in preview_collections: | |
| if cname.startswith("doom_frames_"): | |
| idx = int(cname.split("_")[-1]) | |
| if idx < local_index - 1: | |
| to_purge.append(cname) | |
| for cname in to_purge: | |
| try: | |
| previews.remove(preview_collections[cname]) | |
| except Exception: | |
| pass | |
| preview_collections.pop(cname, None) | |
| pcoll = preview_collections[collection_name] | |
| frame_local_id = self.state.frame_id_counter % FRAMES_PER_COLLECTION | |
| frame_id = f"frame_{frame_local_id:03d}" | |
| pcoll.load(frame_id, frame_file, "IMAGE") | |
| full_frame_id = f"{collection_name}:{frame_id}" | |
| PREFLIGHT_QUEUE.append(full_frame_id) | |
| max_queue = self.props.preflight * 2 | |
| if max_queue > 0 and len(PREFLIGHT_QUEUE) > max_queue: | |
| del PREFLIGHT_QUEUE[: len(PREFLIGHT_QUEUE) - max_queue] | |
| if self.props.preflight > 0 and len(PREFLIGHT_QUEUE) > self.props.preflight: | |
| self.props.current_frame_id = PREFLIGHT_QUEUE[-(self.props.preflight + 1)] | |
| else: | |
| self.props.current_frame_id = PREFLIGHT_QUEUE[0] | |
| self.state.frame_id_counter += 1 | |
| self.props.frame_counter += 1 | |
| def cleanup(self): | |
| self.props.is_running = False | |
| if self.state.game: | |
| try: | |
| self.state.game.close() | |
| except Exception: | |
| pass | |
| self.state = DoomState() | |
| to_remove = [n for n in preview_collections if n.startswith("doom_frames_")] | |
| for name in to_remove: | |
| try: | |
| previews.remove(preview_collections[name]) | |
| except Exception: | |
| pass | |
| preview_collections.pop(name, None) | |
| PREFLIGHT_QUEUE.clear() | |
| if self.props.temp_dir and os.path.exists(self.props.temp_dir): | |
| try: | |
| shutil.rmtree(self.props.temp_dir, ignore_errors=True) | |
| except Exception: | |
| pass | |
| self.props.temp_dir = "" | |
| # MARK: Operators | |
| class DOOM_OT_select_wad(Operator): | |
| bl_idname = "doom.select_wad" | |
| bl_label = "Browse WAD" | |
| bl_description = "Select a Doom WAD file" | |
| # filepath property bound after class | |
| filepath: str | |
| def execute(self, context): | |
| props = context.scene.doom_player_props | |
| props.wad_path = self.filepath | |
| return {"FINISHED"} | |
| def invoke(self, context, event): | |
| context.window_manager.fileselect_add(self) | |
| return {"RUNNING_MODAL"} | |
| class DOOM_OT_toggle_game(Operator): | |
| bl_idname = "doom.toggle_game" | |
| bl_label = "Start/Stop Game" | |
| bl_description = "Start or stop the Doom game" | |
| def execute(self, context): | |
| global ACTIVE_DOOM_WRANGLER | |
| props = context.scene.doom_player_props | |
| if props.is_running: | |
| if ACTIVE_DOOM_WRANGLER: | |
| ACTIVE_DOOM_WRANGLER.cleanup() | |
| ACTIVE_DOOM_WRANGLER = None | |
| self.report({"INFO"}, "Doom game stopped") | |
| return {"FINISHED"} | |
| if not props.is_enabled: | |
| props.is_enabled = True | |
| if not os.path.exists(props.wad_path): | |
| self.report({"ERROR"}, f"WAD file not found: {props.wad_path}") | |
| return {"CANCELLED"} | |
| bpy.ops.doom.modal_game() | |
| return {"FINISHED"} | |
| class DOOM_OT_modal_game(Operator): | |
| bl_idname = "doom.modal_game" | |
| bl_label = "Doom Modal Game" | |
| bl_description = "Modal operator handling Doom execution" | |
| _timer = None | |
| def modal(self, context, event): | |
| global ACTIVE_DOOM_WRANGLER | |
| props = context.scene.doom_player_props | |
| if not props.is_enabled or not props.is_running or ACTIVE_DOOM_WRANGLER is None: | |
| self.cleanup(context) | |
| return {"CANCELLED"} | |
| if event.type == "TIMER": | |
| try: | |
| ACTIVE_DOOM_WRANGLER.step() | |
| for area in context.screen.areas: | |
| if area.type in {"VIEW_3D", "PROPERTIES"}: | |
| area.tag_redraw() | |
| except Exception as e: # noqa: BLE001 | |
| print(f"Game error: {e}") | |
| traceback.print_exc() | |
| self.cleanup(context) | |
| return {"CANCELLED"} | |
| if event.type == "ESC" and ESCAPE_TO_STOP: | |
| self.cleanup(context) | |
| return {"CANCELLED"} | |
| if props.use_keyboard and event.value == "PRESS": | |
| if event.type in {"W", "UP_ARROW"}: | |
| props.move_forward = True | |
| elif event.type in {"S", "DOWN_ARROW"}: | |
| props.move_backward = True | |
| elif event.type in {"A", "LEFT_ARROW"}: | |
| props.turn_left = True | |
| elif event.type in {"D", "RIGHT_ARROW"}: | |
| props.turn_right = True | |
| elif event.type == "SPACE": | |
| props.attack = True | |
| elif event.type == "E": | |
| props.use = True | |
| else: | |
| return {"PASS_THROUGH"} | |
| return {"RUNNING_MODAL"} | |
| elif props.use_keyboard and event.value == "RELEASE": | |
| if event.type in {"W", "UP_ARROW"}: | |
| props.move_forward = False | |
| elif event.type in {"S", "DOWN_ARROW"}: | |
| props.move_backward = False | |
| elif event.type in {"A", "LEFT_ARROW"}: | |
| props.turn_left = False | |
| elif event.type in {"D", "RIGHT_ARROW"}: | |
| props.turn_right = False | |
| elif event.type == "SPACE": | |
| props.attack = False | |
| elif event.type == "E": | |
| props.use = False | |
| else: | |
| return {"PASS_THROUGH"} | |
| return {"RUNNING_MODAL"} | |
| return {"PASS_THROUGH"} | |
| def execute(self, context): | |
| global ACTIVE_DOOM_WRANGLER | |
| props = context.scene.doom_player_props | |
| if props.is_running: | |
| self.report({"WARNING"}, "Already running") | |
| return {"CANCELLED"} | |
| props.is_running = True | |
| ACTIVE_DOOM_WRANGLER = DoomWrangler(props) | |
| wm = context.window_manager | |
| self._timer = wm.event_timer_add(1.0 / max(1, props.fps), window=context.window) | |
| wm.modal_handler_add(self) | |
| self.report({"INFO"}, "Doom game started") | |
| return {"RUNNING_MODAL"} | |
| def cleanup(self, context): | |
| global ACTIVE_DOOM_WRANGLER | |
| props = context.scene.doom_player_props | |
| if self._timer: | |
| try: | |
| context.window_manager.event_timer_remove(self._timer) | |
| except Exception: | |
| pass | |
| self._timer = None | |
| if ACTIVE_DOOM_WRANGLER: | |
| ACTIVE_DOOM_WRANGLER.cleanup() | |
| ACTIVE_DOOM_WRANGLER = None | |
| props.is_running = False | |
| def cancel(self, context): | |
| self.cleanup(context) | |
| # MARK: UI | |
| class DOOM_PT_player_panel(Panel): | |
| bl_label = "Doom Player" | |
| bl_idname = "DOOM_PT_player" | |
| bl_space_type = "PROPERTIES" | |
| bl_region_type = "WINDOW" | |
| bl_context = "scene" | |
| def draw(self, context): | |
| layout = self.layout | |
| props = context.scene.doom_player_props | |
| if not props.is_running: | |
| box = layout.box() | |
| box.label(text="Game Configuration", icon="SETTINGS") | |
| row = box.row() | |
| row.prop(props, "wad_path", text="") | |
| row.operator("doom.select_wad", text="", icon="FILE_FOLDER") | |
| box.prop(props, "current_map", text="Map") | |
| box.prop(props, "fps") | |
| box.prop(props, "preflight") | |
| row = layout.row() | |
| row.scale_y = 1.5 | |
| if props.is_running: | |
| row.operator("doom.toggle_game", text="Stop Game", icon="SNAP_FACE") | |
| elif props.is_enabled: | |
| row.operator("doom.toggle_game", text="Start Game", icon="PLAY") | |
| else: | |
| row.prop(props, "is_enabled", toggle=True, text="Enable Doom Player", icon="PLAY") | |
| if props.is_running and props.current_frame_id: | |
| box = layout.box() | |
| box.label(text="Game View", icon="RENDER_RESULT") | |
| try: | |
| collection_name, frame_id = props.current_frame_id.split(":") | |
| pcoll = preview_collections.get(collection_name) | |
| if pcoll and frame_id in pcoll: | |
| row = box.row() | |
| row.alignment = "CENTER" | |
| row.template_icon(icon_value=pcoll[frame_id].icon_id, scale=ICON_SCALE) | |
| except Exception: | |
| pass | |
| box = layout.box() | |
| box.label(text="Input Controls", icon="KEYINGSET") | |
| grid = box.grid_flow(columns=3, row_major=True, even_columns=True, even_rows=True, align=True) | |
| grid.label(text="") | |
| grid.prop(props, "move_forward", toggle=True, icon="TRIA_UP", text="") | |
| grid.label(text="") | |
| grid.prop(props, "turn_left", toggle=True, icon="TRIA_LEFT", text="") | |
| grid.prop(props, "move_backward", toggle=True, icon="TRIA_DOWN", text="") | |
| grid.prop(props, "turn_right", toggle=True, icon="TRIA_RIGHT", text="") | |
| col = box.column(align=True) | |
| row2 = col.row(align=True) | |
| row2.prop(props, "attack", toggle=True, icon="OUTLINER_OB_LIGHTPROBE") | |
| row2.prop(props, "use", toggle=True, icon="HAND") | |
| col.separator(type="LINE") | |
| col.prop(props, "use_keyboard", text="Hijack Keyboard Input") | |
| if PREFLIGHT_QUEUE: | |
| box = layout.box() | |
| display_frames = ( | |
| PREFLIGHT_QUEUE[-props.preflight :] | |
| if props.preflight > 0 and len(PREFLIGHT_QUEUE) > props.preflight | |
| else PREFLIGHT_QUEUE | |
| ) | |
| box.label(text="Frame Preflight", icon="SEQUENCE") | |
| flow = box.grid_flow( | |
| columns=min(8, len(display_frames)), row_major=True, even_columns=True, even_rows=True, align=True | |
| ) | |
| for frame_ref in display_frames: | |
| if ":" in frame_ref: | |
| cname, fid = frame_ref.split(":") | |
| pcoll = preview_collections.get(cname) | |
| if pcoll and fid in pcoll: | |
| flow.template_icon(icon_value=pcoll[fid].icon_id, scale=0.1) | |
| # MARK: Registration | |
| classes = ( | |
| PreflightFrameItem, | |
| DoomPlayerProperties, | |
| DOOM_OT_select_wad, | |
| DOOM_OT_toggle_game, | |
| DOOM_OT_modal_game, | |
| DOOM_PT_player_panel, | |
| ) | |
| def register(): | |
| for cls in classes: | |
| bpy.utils.register_class(cls) | |
| bpy.types.Scene.doom_player_props = bpy.props.PointerProperty(type=DoomPlayerProperties) | |
| DOOM_OT_select_wad.filepath = StringProperty(subtype="FILE_PATH") | |
| def unregister(): | |
| global ACTIVE_DOOM_WRANGLER | |
| if ACTIVE_DOOM_WRANGLER: | |
| try: | |
| ACTIVE_DOOM_WRANGLER.cleanup() | |
| except Exception: | |
| pass | |
| ACTIVE_DOOM_WRANGLER = None | |
| for pcoll in list(preview_collections.values()): | |
| try: | |
| previews.remove(pcoll) | |
| except Exception: | |
| pass | |
| preview_collections.clear() | |
| if hasattr(bpy.types.Scene, "doom_player_props"): | |
| try: | |
| props = bpy.context.scene.doom_player_props | |
| if props and props.temp_dir and os.path.exists(props.temp_dir): | |
| shutil.rmtree(props.temp_dir, ignore_errors=True) | |
| except Exception: | |
| pass | |
| del bpy.types.Scene.doom_player_props | |
| for cls in reversed(classes): | |
| bpy.utils.unregister_class(cls) | |
| if __name__ == "__main__": | |
| register() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment