""" Leenkx viewport framebuffer via shared memory. """ import bpy import gpu from gpu_extras.batch import batch_for_shader import struct import ctypes import sys import os import array import atexit HAS_GPU_STATE = bpy.app.version >= (3, 0, 0) if not HAS_GPU_STATE: import bgl SHADER_UNIFORM_COLOR = 'UNIFORM_COLOR' if bpy.app.version >= (3, 4, 0) else '2D_UNIFORM_COLOR' SHADER_IMAGE = 'IMAGE' if bpy.app.version >= (3, 4, 0) else '2D_IMAGE' HAS_GPU_TEXTURE = bpy.app.version >= (3, 0, 0) try: import numpy as np HAS_NUMPY = True except ImportError: HAS_NUMPY = False # TODO: Fix correctly def _build_srgb_to_linear_lut(): lut = [] for i in range(256): srgb = i / 255.0 if srgb <= 0.04045: linear = srgb / 12.92 else: linear = ((srgb + 0.055) / 1.055) ** 2.4 lut.append(linear) return lut _srgb_to_linear_lut_list = _build_srgb_to_linear_lut() if HAS_NUMPY: _srgb_to_linear_lut = np.array(_srgb_to_linear_lut_list, dtype=np.float32) if sys.platform == 'win32': import mmap else: import mmap try: import posix_ipc HAS_POSIX_IPC = True except ImportError: HAS_POSIX_IPC = False VIEWPORT_BUILD = 0x4B524F4D VIEWPORT_VERSION = 1 VIEWPORT_SHMEM_NAME_BASE = "KROM_VIEWPORT_FB" def _get_viewport_shmem_name(viewport_id=None): """Get shared memory name for a specific viewport.""" if viewport_id: return f"{VIEWPORT_SHMEM_NAME_BASE}_{viewport_id}" return VIEWPORT_SHMEM_NAME_BASE # Header structure offsets (must match viewport_server.h SharedFramebufferHeader) # build: 0-3 (4 bytes, uint32) # version: 4-7 (4 bytes, uint32) # width: 8-11 (4 bytes, uint32) # height: 12-15 (4 bytes, uint32) # frame_id: 16-23 (8 bytes, uint64) # ready_flag: 24-27 (4 bytes, uint32) # format: 28-31 (4 bytes, uint32) # viewport_width: 32-35 (4 bytes, uint32) # viewport_height: 36-39 (4 bytes, uint32) # resize_request: 40-43 (4 bytes, uint32) # shutdown_flag: 44-47 (4 bytes, uint32) # input_enabled: 48-51 (4 bytes, uint32) # view_matrix: 52-115 (64 bytes, 16 floats) # proj_matrix: 116-179 (64 bytes, 16 floats) # krom_camera_pos: 180-191 (12 bytes, 3 floats) # krom_camera_rot: 192-207 (16 bytes, 4 floats) # krom_camera_dirty: 208-211 (4 bytes, uint32) # input_write_idx: 212-215 (4 bytes, uint32) # input_read_idx: 216-219 (4 bytes, uint32) # input_events: 220-603 (384 bytes, 32 events * 12 bytes each) # Total: 604 bytes OFFSET_BUILD = 0 OFFSET_WIDTH = 8 OFFSET_FRAME_ID = 16 OFFSET_READY_FLAG = 24 OFFSET_VIEWPORT_WIDTH = 32 OFFSET_RESIZE_REQUEST = 40 OFFSET_SHUTDOWN_FLAG = 44 OFFSET_INPUT_ENABLED = 48 OFFSET_VIEW_MATRIX = 52 OFFSET_PROJ_MATRIX = 116 OFFSET_KROM_CAMERA_POS = 180 # 12 bytes vec OFFSET_KROM_CAMERA_ROT = 192 # 16 bytes quat OFFSET_KROM_CAMERA_DIRTY = 208 # 1 uint32 OFFSET_INPUT_WRITE_IDX = 212 OFFSET_INPUT_READ_IDX = 216 OFFSET_INPUT_EVENTS = 220 MAX_INPUT_EVENTS = 32 INPUT_EVENT_SIZE = 12 VIEWPORT_HEADER_SIZE = 604 INPUT_EVENT_NONE = 0 INPUT_EVENT_MOUSE_MOVE = 1 INPUT_EVENT_MOUSE_DOWN = 2 INPUT_EVENT_MOUSE_UP = 3 INPUT_EVENT_KEY_DOWN = 4 INPUT_EVENT_KEY_UP = 5 INPUT_EVENT_MOUSE_WHEEL = 6 MOUSE_BUTTON_LEFT = 0 MOUSE_BUTTON_RIGHT = 1 MOUSE_BUTTON_MIDDLE = 2 _rendered_mode_pending = False _rendered_mode_retries = 0 _msgbus_owner = object() @bpy.app.handlers.persistent def _on_depsgraph_update(scene, depsgraph): """Called on depsgraph update - check if render engine changed to Krom.""" try: if scene.render.engine == 'KROM_VIEWPORT': for window in bpy.context.window_manager.windows: for area in window.screen.areas: if area.type == 'VIEW_3D': for space in area.spaces: if space.type == 'VIEW_3D': if space.shading.type != 'RENDERED': space.shading.type = 'RENDERED' area.tag_redraw() return except: pass def _deferred_set_rendered_mode(): """Module-level timer callback to set rendered mode.""" global _rendered_mode_pending, _rendered_mode_retries try: current_engine = bpy.context.scene.render.engine for window in bpy.context.window_manager.windows: screen = window.screen for area in screen.areas: if area.type == 'VIEW_3D': for space in area.spaces: if space.type == 'VIEW_3D': current_shading = space.shading.type if current_shading == 'RENDERED': _rendered_mode_pending = False _rendered_mode_retries = 0 return None space.shading.type = 'RENDERED' area.tag_redraw() _rendered_mode_pending = False _rendered_mode_retries = 0 return None _rendered_mode_retries += 1 if _rendered_mode_retries < 10: print(f"Viewport VIEW_3D not found, retrying... ({_rendered_mode_retries})") return 0.2 except Exception as e: print(f"Could not set rendered mode: {e}") import traceback traceback.print_exc() _rendered_mode_retries += 1 if _rendered_mode_retries < 10: return 0.2 _rendered_mode_pending = False _rendered_mode_retries = 0 return None class KromViewportEngine(bpy.types.RenderEngine): """Leenkx render engine that displays Krom runtime output in viewport.""" bl_idname = 'KROM_VIEWPORT' bl_label = 'Krom Viewport' bl_use_preview = False bl_use_gpu_context = True bl_use_shading_nodes_custom = False bl_use_eevee_viewport = True def _ensure_initialized(self): if not hasattr(self, '_viewport_init_done'): self._shm = None self._shm_file = None self._texture = None self._last_frame_id = 0 self._width = 0 self._height = 0 self._initialized = False self._shader = None self._batch = None self._viewport_init_done = True self._rendered_mode_set = False self._last_shading_mode = None self._viewport_id = None self._shmem_name = None self._krom_proc = None self._krom_launch_attempted = False self._pending_camera_sync = False self._engine_id = id(self) # print(f"New engine instance created: {self._engine_id}") def _set_rendered_mode(self, context): if self._rendered_mode_set: return try: space = context.space_data if space and hasattr(space, 'shading'): current = space.shading.type if current != 'RENDERED': space.shading.type = 'RENDERED' self._rendered_mode_set = True except Exception as e: print(f"Failed to set rendered mode: {e}") def _handle_mode_switch(self, context): """Detect shading mode changes and restart Krom on Solid->Rendered switch if needed.""" try: space = context.space_data if not space or not hasattr(space, 'shading'): return current_mode = space.shading.type if self._last_shading_mode == 'SOLID' and current_mode == 'RENDERED': self._initialized = False self._rendered_mode_set = False self._krom_launch_attempted = False self._cleanup_shared_memory() self._pending_camera_sync = True if self._last_shading_mode == 'RENDERED' and current_mode != 'RENDERED': self._rendered_mode_set = False # keep krom running in solid mode for switching back ti rendered self._last_shading_mode = current_mode except Exception as e: pass def _sync_camera_once(self, context): """Sync the camera once for Solid->Rendered mode""" if not self._initialized: return try: rv3d = context.region_data if not rv3d: return view_matrix = rv3d.view_matrix proj_matrix = rv3d.window_matrix view_data = struct.pack('<16f', *[view_matrix[i][j] for j in range(4) for i in range(4)]) proj_data = struct.pack('<16f', *[proj_matrix[i][j] for j in range(4) for i in range(4)]) if sys.platform == 'win32': if not self._shm_ptr: return ctypes.memmove(self._shm_ptr + OFFSET_VIEW_MATRIX, view_data, len(view_data)) ctypes.memmove(self._shm_ptr + OFFSET_PROJ_MATRIX, proj_data, len(proj_data)) else: self._shm.seek(OFFSET_VIEW_MATRIX) self._shm.write(view_data) self._shm.seek(OFFSET_PROJ_MATRIX) self._shm.write(proj_data) except Exception as e: print(f"Failed camera sync: {e}") def _launch_krom_process(self, context): """Launch Krom process for this viewport instance.""" if self._krom_launch_attempted: return self._krom_proc is not None self._krom_launch_attempted = True if self._viewport_id is None and context is not None: try: space = context.space_data if space: self._viewport_id = hex(space.as_pointer())[-6:] except: pass if not self._viewport_id: import time self._viewport_id = hex(int(time.time() * 1000) % 0xFFFFFF)[-6:] region = context.region width = region.width // 2 if region else 960 height = region.height if region else 540 viewport_id = self._viewport_id engine_id = self._engine_id shmem_name = _get_viewport_shmem_name(viewport_id) # print(f"ID: {engine_id} launching: {viewport_id} with shared memory: {shmem_name}") def deferred_launch(): try: import lnx.make as make make.play_viewport(viewport_id, width, height) except Exception as e: print(f"Failed to launch viewport: {e}") import traceback traceback.print_exc() return None bpy.app.timers.register(deferred_launch, first_interval=0.1) return True def _stop_krom_process(self): if self._krom_proc: try: import lnx.make as make make.stop_viewport_runtime(self._viewport_id) except: pass self._krom_proc = None def _init_shared_memory(self, context=None): self._ensure_initialized() if self._initialized: return True if self._viewport_id is None and context is not None: try: space = context.space_data if space: self._viewport_id = hex(space.as_pointer())[-6:] except: pass if self._viewport_id is None: import time self._viewport_id = hex(int(time.time() * 1000) % 0xFFFFFF)[-6:] shmem_name = _get_viewport_shmem_name(self._viewport_id) self._shmem_name = shmem_name try: if sys.platform == 'win32': max_size = VIEWPORT_HEADER_SIZE + (4096 * 4096 * 4) try: import ctypes.wintypes kernel32 = ctypes.windll.kernel32 FILE_MAP_ALL_ACCESS = 0x000F001F kernel32.OpenFileMappingW.argtypes = [ctypes.wintypes.DWORD, ctypes.wintypes.BOOL, ctypes.wintypes.LPCWSTR] kernel32.OpenFileMappingW.restype = ctypes.wintypes.HANDLE kernel32.MapViewOfFile.argtypes = [ctypes.wintypes.HANDLE, ctypes.wintypes.DWORD, ctypes.wintypes.DWORD, ctypes.wintypes.DWORD, ctypes.c_size_t] kernel32.MapViewOfFile.restype = ctypes.c_void_p kernel32.UnmapViewOfFile.argtypes = [ctypes.c_void_p] kernel32.UnmapViewOfFile.restype = ctypes.wintypes.BOOL kernel32.CloseHandle.argtypes = [ctypes.wintypes.HANDLE] kernel32.CloseHandle.restype = ctypes.wintypes.BOOL kernel32.VirtualQuery.argtypes = [ctypes.c_void_p, ctypes.c_void_p, ctypes.c_size_t] kernel32.VirtualQuery.restype = ctypes.c_size_t handle = kernel32.OpenFileMappingW( FILE_MAP_ALL_ACCESS, False, shmem_name ) if not handle: return False # print(f"ID: {self._engine_id} CONNECTED: {shmem_name}") # use 0 to map entire region ptr = kernel32.MapViewOfFile( handle, FILE_MAP_ALL_ACCESS, 0, 0, 0 ) if not ptr: error = kernel32.GetLastError() kernel32.CloseHandle(handle) print(f"Failed to map shared memory: {error}") return False self._shm_handle = handle self._shm_ptr = ptr # VirtualQuery to get actual size of mapped region class MEMORY_BASIC_INFORMATION(ctypes.Structure): _fields_ = [ ("BaseAddress", ctypes.c_void_p), ("AllocationBase", ctypes.c_void_p), ("AllocationProtect", ctypes.c_ulong), ("RegionSize", ctypes.c_size_t), ("State", ctypes.c_ulong), ("Protect", ctypes.c_ulong), ("Type", ctypes.c_ulong), ] mbi = MEMORY_BASIC_INFORMATION() result = kernel32.VirtualQuery(ptr, ctypes.byref(mbi), ctypes.sizeof(mbi)) if result == 0 or mbi.RegionSize == 0: actual_size = VIEWPORT_HEADER_SIZE + (1920 * 1080 * 4) else: actual_size = mbi.RegionSize self._shm_size = actual_size self._shm_buffer = (ctypes.c_ubyte * actual_size).from_address(ptr) self._initialized = True # print(f"Connected: {shmem_name}") return True except Exception as e: print(f"Failed to open shared memory: {e}") return False else: posix_shmem_name = f"/{shmem_name}" max_size = VIEWPORT_HEADER_SIZE + (4096 * 4096 * 4) if HAS_POSIX_IPC: try: shm = posix_ipc.SharedMemory(posix_shmem_name) self._shm = mmap.mmap(shm.fd, max_size, mmap.MAP_SHARED, mmap.PROT_READ | mmap.PROT_WRITE) shm.close_fd() self._initialized = True # print(f"Connected: {posix_shmem_name}") return True except Exception as e: print(f"Failed to open shared memory: {e}") return False else: # fallback: try /dev/shm directly shm_path = f"/dev/shm{posix_shmem_name}" try: self._shm_file = open(shm_path, 'r+b') self._shm = mmap.mmap(self._shm_file.fileno(), max_size, mmap.MAP_SHARED, mmap.PROT_READ | mmap.PROT_WRITE) self._initialized = True # print(f"Connected: {shm_path}") return True except Exception as e: print(f"Failed to open /dev/shm: {e}") return False except Exception as e: print(f"Error creating shared memory: {e}") return False def _cleanup_shared_memory(self): """Close shared memory handles.""" if sys.platform == 'win32': if hasattr(self, '_shm_ptr') and self._shm_ptr: try: kernel32 = ctypes.windll.kernel32 kernel32.UnmapViewOfFile(self._shm_ptr) except: pass self._shm_ptr = None if hasattr(self, '_shm_handle') and self._shm_handle: try: kernel32 = ctypes.windll.kernel32 kernel32.CloseHandle(self._shm_handle) except: pass self._shm_handle = None else: # POSIX cleanup if hasattr(self, '_shm') and self._shm: try: self._shm.close() except: pass self._shm = None if hasattr(self, '_shm_file') and self._shm_file: try: self._shm_file.close() except: pass self._shm_file = None self._initialized = False def _read_header(self): """Read and parse the shared memory header.""" self._ensure_initialized() if not self._initialized: return None try: if sys.platform == 'win32': if not self._shm_ptr: return None # use memmove for safety header_buffer = (ctypes.c_ubyte * VIEWPORT_HEADER_SIZE)() ctypes.memmove(header_buffer, self._shm_ptr, VIEWPORT_HEADER_SIZE) header_data = bytes(header_buffer) else: self._shm.seek(0) header_data = self._shm.read(VIEWPORT_HEADER_SIZE) build, version, width, height = struct.unpack(' 4096 or height > 4096: return None try: pixel_size = width * height * 4 if sys.platform == 'win32': if not self._shm_ptr: return None pixel_buffer = (ctypes.c_ubyte * pixel_size)() ctypes.memmove(pixel_buffer, self._shm_ptr + VIEWPORT_HEADER_SIZE, pixel_size) pixels = bytes(pixel_buffer) ready_bytes = struct.pack(' 0 else 10.0 target = camera_pos + forward * view_dist rv3d.view_location = target rv3d.view_rotation = quat except Exception as e: pass def _request_resize(self, width, height): """Request Krom to resize the viewport.""" self._ensure_initialized() if not self._initialized: return if hasattr(self, '_last_requested_width') and hasattr(self, '_last_requested_height'): if self._last_requested_width == width and self._last_requested_height == height: return self._last_requested_width = width self._last_requested_height = height try: data = struct.pack(' 0 and region.height > 0: self._request_resize(region.width, region.height) def view_draw(self, context, depsgraph): """Called to draw the viewport.""" self._ensure_initialized() self._handle_mode_switch(context) was_initialized = self._initialized if not self._init_shared_memory(context): if not self._krom_launch_attempted: # print(f"Shared memory not found, launching Krom for viewport {self._viewport_id}") self._launch_krom_process(context) self._draw_placeholder(context) return if not was_initialized and self._initialized: region = context.region if region and region.width > 0 and region.height > 0: self._last_requested_width = 0 self._last_requested_height = 0 # width from region is 2x the actual viewport width.. TODO: re-investigate initial_width = region.width // 2 self._request_resize(initial_width, region.height) if self._pending_camera_sync: self._pending_camera_sync = False self._sync_camera_once(context) self._set_rendered_mode(context) # TODO: input forwarding when viewport is active global _active_krom_engine, _active_krom_engines, _active_viewport_id, _input_operator_running _active_krom_engine = self # legacy single-engine reference if self._viewport_id: _active_krom_engines[self._viewport_id] = self _active_viewport_id = self._viewport_id self._set_input_enabled(True) if not _input_operator_running: bpy.app.timers.register(_start_input_capture, first_interval=0.1) else: if not hasattr(self, '_input_check_counter'): self._input_check_counter = 0 self._input_check_counter += 1 if self._input_check_counter > 60: self._input_check_counter = 0 bpy.app.timers.register(_start_input_capture, first_interval=0.5) region = context.region if region and region.width > 0 and region.height > 0: self._request_resize(region.width, region.height) self._read_krom_camera(context) pixels = self._read_framebuffer() if pixels: self._update_texture(pixels, self._width, self._height) if not self._texture: self._draw_placeholder(context) return self._create_shader() if HAS_GPU_STATE: gpu.state.blend_set('NONE') else: bgl.glDisable(bgl.GL_BLEND) region = context.region vertices = ( (0, 0), (region.width, 0), (region.width, region.height), (0, region.height), ) tex_coords = ( (0, 1), # Flip Y (1, 1), (1, 0), (0, 0), ) indices = ((0, 1, 2), (0, 2, 3)) self._shader.bind() self._shader.uniform_sampler("image", self._texture) batch = batch_for_shader(self._shader, 'TRIS', {"pos": vertices, "texCoord": tex_coords}, indices=indices) batch.draw(self._shader) if HAS_GPU_STATE: gpu.state.blend_set('NONE') else: bgl.glDisable(bgl.GL_BLEND) self.tag_redraw() def _draw_placeholder(self, context): """Draw a placeholder when Krom is not connected.""" # Simple colored background if HAS_GPU_STATE: gpu.state.blend_set('ALPHA') else: bgl.glEnable(bgl.GL_BLEND) bgl.glBlendFunc(bgl.GL_SRC_ALPHA, bgl.GL_ONE_MINUS_SRC_ALPHA) shader = gpu.shader.from_builtin(SHADER_UNIFORM_COLOR) shader.bind() shader.uniform_float("color", (0.1, 0.1, 0.15, 1.0)) region = context.region vertices = ( (0, 0), (region.width, 0), (region.width, region.height), (0, region.height), ) indices = ((0, 1, 2), (0, 2, 3)) batch = batch_for_shader(shader, 'TRIS', {"pos": vertices}, indices=indices) batch.draw(shader) if HAS_GPU_STATE: gpu.state.blend_set('NONE') else: bgl.glDisable(bgl.GL_BLEND) class KROM_OT_viewport_input(bpy.types.Operator): """Modal operator to capture and forward input to Krom viewport""" bl_idname = "krom.viewport_input" bl_label = "Krom Viewport Input" bl_options = {'INTERNAL'} _engine = None _last_mouse_x = 0 _last_mouse_y = 0 def modal(self, context, event): global _input_operator_running # check if we should stop if event.type == 'ESC': try: if self._engine: self._engine._set_input_enabled(False) except ReferenceError: pass _input_operator_running = False return {'CANCELLED'} if event.type in {'SCREEN_SET', 'SCREEN_CHANGED'}: return {'PASS_THROUGH'} if not context.space_data or not context.area: return {'PASS_THROUGH'} if context.space_data.type == 'VIEW_3D': if context.engine != 'KROM_VIEWPORT': try: if self._engine: self._engine._set_input_enabled(False) except ReferenceError: pass _input_operator_running = False return {'CANCELLED'} current_engine = self._get_engine_for_area(context, event) if current_engine: self._engine = current_engine try: if not self._engine or not self._engine._initialized: return {'PASS_THROUGH'} except ReferenceError: _input_operator_running = False return {'CANCELLED'} region = context.region if not region: return {'PASS_THROUGH'} if region.type != 'WINDOW': return {'PASS_THROUGH'} mouse_in_region = (0 <= event.mouse_region_x <= region.width and 0 <= event.mouse_region_y <= region.height) if not mouse_in_region: return {'PASS_THROUGH'} area = context.area if area: mouse_x_abs = event.mouse_x mouse_y_abs = event.mouse_y for rgn in area.regions: # skip the main window region where we actually want input if rgn.type == 'WINDOW': continue # check if mouse is over toolbar, header, UI panel, etc. if (rgn.x <= mouse_x_abs <= rgn.x + rgn.width and rgn.y <= mouse_y_abs <= rgn.y + rgn.height): # mouse is over a UI region - pass through return {'PASS_THROUGH'} mouse_x = event.mouse_region_x # flip Y for Krom mouse_y = region.height - event.mouse_region_y try: if event.type == 'MOUSEMOVE': self._engine._send_input_event(INPUT_EVENT_MOUSE_MOVE, x=mouse_x, y=mouse_y) self._last_mouse_x = mouse_x self._last_mouse_y = mouse_y return {'RUNNING_MODAL'} if event.type == 'LEFTMOUSE': if event.value == 'PRESS': self._engine._send_input_event(INPUT_EVENT_MOUSE_DOWN, button=MOUSE_BUTTON_LEFT, x=mouse_x, y=mouse_y) elif event.value == 'RELEASE': self._engine._send_input_event(INPUT_EVENT_MOUSE_UP, button=MOUSE_BUTTON_LEFT, x=mouse_x, y=mouse_y) return {'RUNNING_MODAL'} if event.type == 'RIGHTMOUSE': if event.value == 'PRESS': self._engine._send_input_event(INPUT_EVENT_MOUSE_DOWN, button=MOUSE_BUTTON_RIGHT, x=mouse_x, y=mouse_y) elif event.value == 'RELEASE': self._engine._send_input_event(INPUT_EVENT_MOUSE_UP, button=MOUSE_BUTTON_RIGHT, x=mouse_x, y=mouse_y) return {'RUNNING_MODAL'} if event.type == 'MIDDLEMOUSE': if event.value == 'PRESS': self._engine._send_input_event(INPUT_EVENT_MOUSE_DOWN, button=MOUSE_BUTTON_MIDDLE, x=mouse_x, y=mouse_y) elif event.value == 'RELEASE': self._engine._send_input_event(INPUT_EVENT_MOUSE_UP, button=MOUSE_BUTTON_MIDDLE, x=mouse_x, y=mouse_y) return {'RUNNING_MODAL'} if event.type == 'WHEELUPMOUSE': self._engine._send_input_event(INPUT_EVENT_MOUSE_WHEEL, delta=1) return {'RUNNING_MODAL'} if event.type == 'WHEELDOWNMOUSE': self._engine._send_input_event(INPUT_EVENT_MOUSE_WHEEL, delta=-1) return {'RUNNING_MODAL'} if event.value in {'PRESS', 'RELEASE'}: key_code = self._blender_to_krom_key(event.type) if key_code is not None: if event.value == 'PRESS': self._engine._send_input_event(INPUT_EVENT_KEY_DOWN, button=key_code) else: self._engine._send_input_event(INPUT_EVENT_KEY_UP, button=key_code) return {'RUNNING_MODAL'} except ReferenceError: _input_operator_running = False return {'CANCELLED'} return {'PASS_THROUGH'} def _blender_to_krom_key(self, blender_key): """Convert Blender key type to Kinc key code.""" # Kinc key codes from keyboard.h key_map = { 'A': 65, 'B': 66, 'C': 67, 'D': 68, 'E': 69, 'F': 70, 'G': 71, 'H': 72, 'I': 73, 'J': 74, 'K': 75, 'L': 76, 'M': 77, 'N': 78, 'O': 79, 'P': 80, 'Q': 81, 'R': 82, 'S': 83, 'T': 84, 'U': 85, 'V': 86, 'W': 87, 'X': 88, 'Y': 89, 'Z': 90, 'ZERO': 48, 'ONE': 49, 'TWO': 50, 'THREE': 51, 'FOUR': 52, 'FIVE': 53, 'SIX': 54, 'SEVEN': 55, 'EIGHT': 56, 'NINE': 57, 'SPACE': 32, 'BACK_SPACE': 8, 'TAB': 9, 'RET': 13, 'LEFT_SHIFT': 16, 'RIGHT_SHIFT': 16, 'LEFT_CTRL': 17, 'RIGHT_CTRL': 17, 'LEFT_ALT': 18, 'RIGHT_ALT': 18, 'UP_ARROW': 38, 'DOWN_ARROW': 40, 'LEFT_ARROW': 37, 'RIGHT_ARROW': 39, 'ESC': 27, 'DEL': 46, 'HOME': 36, 'END': 35, 'PAGE_UP': 33, 'PAGE_DOWN': 34, 'INSERT': 45, 'F1': 112, 'F2': 113, 'F3': 114, 'F4': 115, 'F5': 116, 'F6': 117, 'F7': 118, 'F8': 119, 'F9': 120, 'F10': 121, 'F11': 122, 'F12': 123, } return key_map.get(blender_key) def _get_engine_for_area(self, context, event=None): """Get the correct engine for the viewport under the mouse cursor.""" # Use mouse coordinates to find which viewport the mouse is over if event: mouse_x = event.mouse_x mouse_y = event.mouse_y for window in bpy.context.window_manager.windows: for area in window.screen.areas: if area.type != 'VIEW_3D': continue if (area.x <= mouse_x < area.x + area.width and area.y <= mouse_y < area.y + area.height): for space in area.spaces: if space.type == 'VIEW_3D': try: viewport_id = hex(space.as_pointer())[-6:] if viewport_id in _active_krom_engines: return _active_krom_engines[viewport_id] except: pass break if context.space_data: try: viewport_id = hex(context.space_data.as_pointer())[-6:] if viewport_id in _active_krom_engines: return _active_krom_engines[viewport_id] except: pass if _active_viewport_id and _active_viewport_id in _active_krom_engines: return _active_krom_engines[_active_viewport_id] return _active_krom_engine def invoke(self, context, event): self._engine = self._get_engine_for_area(context) if not self._engine: return {'CANCELLED'} context.window_manager.modal_handler_add(self) return {'RUNNING_MODAL'} _active_krom_engines = {} _active_viewport_id = None _input_operator_running = False _active_krom_engine = None def _start_input_capture(): """Timer callback to start input capture operator.""" global _input_operator_running if _active_krom_engine and not _input_operator_running: try: for window in bpy.context.window_manager.windows: for area in window.screen.areas: if area.type == 'VIEW_3D': for region in area.regions: if region.type == 'WINDOW': override = {'window': window, 'area': area, 'region': region} with bpy.context.temp_override(**override): bpy.ops.krom.viewport_input('INVOKE_DEFAULT') _input_operator_running = True return None except Exception as e: pass return None def get_panels(): """Get panels to ensure world settings and other properties are available.""" exclude_panels = { 'VIEWLAYER_PT_filter', 'VIEWLAYER_PT_layer_passes', } compatible_engines = {'BLENDER_RENDER', 'BLENDER_EEVEE', 'BLENDER_EEVEE_NEXT'} panels = [] for panel in bpy.types.Panel.__subclasses__(): if hasattr(panel, 'COMPAT_ENGINES'): if panel.COMPAT_ENGINES & compatible_engines: if panel.__name__ not in exclude_panels: panels.append(panel) return panels def _cleanup_krom_on_exit(): """Kill any running Krom viewport processes when Blender exits.""" try: import lnx.make as make if hasattr(make, '_viewport_processes'): for viewport_id, proc in list(make._viewport_processes.items()): if proc and proc.poll() is None: proc.terminate() try: proc.wait(timeout=2) except: proc.kill() make._viewport_processes.clear() if hasattr(make, '_viewport_proc') and make._viewport_proc is not None: if make._viewport_proc.poll() is None: make._viewport_proc.terminate() try: make._viewport_proc.wait(timeout=2) except: make._viewport_proc.kill() except: pass class KROM_OT_stop_viewport(bpy.types.Operator): """Stop the Krom viewport process and switch to solid shading""" bl_idname = "krom.stop_viewport" bl_label = "Stop Viewport" bl_description = "Stop Krom viewport rendering and switch to solid mode" bl_options = {'REGISTER'} viewport_id: bpy.props.StringProperty(default="") def execute(self, context): import lnx.make as make viewport_id = self.viewport_id if not viewport_id and context.space_data: viewport_id = hex(context.space_data.as_pointer())[-6:] if viewport_id: make.stop_viewport(viewport_id) global _active_krom_engines if viewport_id in _active_krom_engines: del _active_krom_engines[viewport_id] if context.space_data and hasattr(context.space_data, 'shading'): context.space_data.shading.type = 'SOLID' return {'FINISHED'} def draw_viewport_stop_button(self, context): """Draw X button in viewport header when Krom is running.""" if context.engine != 'KROM_VIEWPORT': return space = context.space_data if not space or not hasattr(space, 'shading'): return if space.shading.type != 'RENDERED': return viewport_id = hex(space.as_pointer())[-6:] global _active_krom_engines if viewport_id not in _active_krom_engines: layout = self.layout layout.label(text="Building...", icon='TIME') return layout = self.layout op = layout.operator("krom.stop_viewport", text="", icon='X', emboss=False) op.viewport_id = viewport_id def register(): """Register the render engine.""" bpy.utils.register_class(KromViewportEngine) bpy.utils.register_class(KROM_OT_viewport_input) bpy.utils.register_class(KROM_OT_stop_viewport) bpy.types.VIEW3D_HT_header.append(draw_viewport_stop_button) for panel in get_panels(): panel.COMPAT_ENGINES.add('KROM_VIEWPORT') if _on_depsgraph_update not in bpy.app.handlers.depsgraph_update_post: bpy.app.handlers.depsgraph_update_post.append(_on_depsgraph_update) atexit.register(_cleanup_krom_on_exit) def unregister(): """Unregister the render engine.""" global _active_krom_engine, _active_krom_engines, _active_viewport_id, _input_operator_running, _msgbus_owner _active_krom_engine = None _active_krom_engines.clear() _active_viewport_id = None _input_operator_running = False if _on_depsgraph_update in bpy.app.handlers.depsgraph_update_post: bpy.app.handlers.depsgraph_update_post.remove(_on_depsgraph_update) _cleanup_krom_on_exit() try: atexit.unregister(_cleanup_krom_on_exit) except: pass for panel in get_panels(): if 'KROM_VIEWPORT' in panel.COMPAT_ENGINES: panel.COMPAT_ENGINES.remove('KROM_VIEWPORT') bpy.types.VIEW3D_HT_header.remove(draw_viewport_stop_button) bpy.utils.unregister_class(KROM_OT_stop_viewport) bpy.utils.unregister_class(KROM_OT_viewport_input) bpy.utils.unregister_class(KromViewportEngine)