import sys import os import struct import time import types from panda3d.core import ( Filename, loadPrcFileData, GeomVertexFormat, GeomVertexWriter, InternalName, Shader, Texture, SamplerState, Vec3, Vec4, Point2, Point3, LMatrix4f, ShaderBuffer, GeomEnums, OmniBoundingVolume, TransparencyAttrib, BoundingSphere, NodePath, GraphicsEngine, WindowProperties, FrameBufferProperties, GraphicsPipe, GraphicsOutput, Camera, DisplayRegion, OrthographicLens, BoundingBox, BitMask32 ) # p3dimgui.backend first tries `from shaders import *`, which can be shadowed by # project folders named `shaders/` and leave VERT_SHADER/FRAG_SHADER undefined. # Seed a valid fallback module before importing p3dimgui. _shaders_mod = sys.modules.get("shaders") if not (_shaders_mod and hasattr(_shaders_mod, "VERT_SHADER") and hasattr(_shaders_mod, "FRAG_SHADER")): _shaders_mod = types.ModuleType("shaders") _shaders_mod.FRAG_SHADER = """ #version 120 varying vec2 texcoord; varying vec4 color; uniform sampler2D p3d_Texture0; void main() { gl_FragColor = color * texture2D(p3d_Texture0, texcoord); } """ _shaders_mod.VERT_SHADER = """ #version 120 attribute vec4 p3d_Vertex; attribute vec4 p3d_Color; varying vec2 texcoord; varying vec4 color; uniform mat4 p3d_ModelViewProjectionMatrix; void main() { texcoord = p3d_Vertex.zw; color = p3d_Color.bgra; gl_Position = p3d_ModelViewProjectionMatrix * vec4(p3d_Vertex.x, 0.0, -p3d_Vertex.y, 1.0); } """ sys.modules["shaders"] = _shaders_mod from p3dimgui import ImGuiBackend from imgui_bundle import imgui from rpcore.effect import Effect from .ssbo_controller import ObjectController from core.selection_outline import SelectionOutlineManager class SSBOEditor: """ SSBO Editor Component ==================== Encapsulates the SSBO rendering, ImGui editor, and interaction logic. Can be integrated into any ShowBase application using RenderPipeline. """ def __init__(self, base_app, render_pipeline, model_path=None, font_path=None): self.base = base_app self.rp = render_pipeline self.controller = None self.model = None self.ssbo = None self.font_path = font_path self._transform_gizmo = None self._ssbo_transform_active = False self._ssbo_selected_local_indices = [] self._ssbo_gizmo_proxy = None # Internal State self.selected_name = None self.selected_ids = [] self.search_text = "" self.last_search_text = None self.filtered_nodes = [] self.debug_mode = False self.keys = {} self.pick_mask = BitMask32.bit(29) self.pick_buffer = None self._empty_pick_scene = NodePath("ssbo_pick_empty") # Avoid heavy per-frame sync for huge group selections. self._pick_sync_bg_limit = 256 self._last_group_sync_mat = None self._last_single_sync_gid = None self._last_single_sync_mat = None # Performance toggle: forcing shadow tasks every frame is expensive. # Keep it off by default so frustum/content reduction has clearer FPS impact. self.realtime_shadow_updates = False self._scheduler_tasks_original = None self._realtime_shadow_tasks_enabled = False self._outline_manager = getattr(self.base, "_selection_outline_manager", None) if self._outline_manager is None: self._outline_manager = SelectionOutlineManager(self.base) setattr(self.base, "_selection_outline_manager", self._outline_manager) # Initialize ImGui Backend if not already present if not hasattr(self.base, 'imgui_backend'): print("[SSBOEditor] Initializing ImGui Backend...") self.base.imgui_backend = ImGuiBackend() self.load_font() # Register Events self.base.accept("imgui-new-frame", self.draw_imgui) self.base.accept("f", self.focus_on_selected) self.base.accept("d", self.toggle_debug) self.base.accept("mouse1", self.on_mouse_click) # Register Input Tasks for key in ['arrow_up', 'arrow_down', 'arrow_left', 'arrow_right', 'z', 'x']: self.base.accept(key, self.keys.__setitem__, [key, True]) self.base.accept(f"{key}-up", self.keys.__setitem__, [key, False]) # Add Tasks self.base.taskMgr.add(self.update_task, "update_task") # Load Model if provided if model_path: self.load_model(model_path) def _capture_scheduler_tasks_snapshot(self): scheduler = getattr(self.rp, "task_scheduler", None) if not scheduler or not hasattr(scheduler, "_tasks"): return if self._scheduler_tasks_original is None: self._scheduler_tasks_original = [list(frame_tasks) for frame_tasks in scheduler._tasks] def _enable_realtime_shadow_tasks(self): """ Force PSSM-related scheduled tasks to run every frame to avoid visible shadow lag/ghosting while editing moving objects. """ scheduler = getattr(self.rp, "task_scheduler", None) if not scheduler or not hasattr(scheduler, "_tasks"): return self._capture_scheduler_tasks_snapshot() required = { "pssm_scene_shadows", "pssm_distant_shadows", "pssm_convert_distant_to_esm", "pssm_blur_distant_vert", "pssm_blur_distant_horiz", } changed = False for frame_tasks in scheduler._tasks: for task_name in required: if task_name not in frame_tasks: frame_tasks.append(task_name) changed = True if changed: print("[SSBOEditor] Realtime shadow tasks enabled (PSSM updates every frame).") self._realtime_shadow_tasks_enabled = True def _disable_realtime_shadow_tasks(self): """Restore scheduler layout captured before realtime shadow override.""" scheduler = getattr(self.rp, "task_scheduler", None) if not scheduler or not hasattr(scheduler, "_tasks"): return if self._scheduler_tasks_original is None: self._realtime_shadow_tasks_enabled = False return scheduler._tasks[:] = [list(frame_tasks) for frame_tasks in self._scheduler_tasks_original] self._realtime_shadow_tasks_enabled = False def set_realtime_shadow_updates(self, enabled): """Public toggle for aggressive per-frame shadow updates.""" self.realtime_shadow_updates = bool(enabled) if self.realtime_shadow_updates: self._enable_realtime_shadow_tasks() else: self._disable_realtime_shadow_tasks() def load_font(self): """Load custom font for ImGui""" io = imgui.get_io() # Load Chinese Glyph Ranges glyph_ranges = None try: if hasattr(io.fonts, 'get_glyph_ranges_chinese_full'): glyph_ranges = io.fonts.get_glyph_ranges_chinese_full() elif hasattr(io.fonts, 'get_glyph_ranges_chinese_simplified_common'): glyph_ranges = io.fonts.get_glyph_ranges_chinese_simplified_common() except Exception as e: print(f"[SSBOEditor] Warning: Could not get Chinese glyph ranges: {e}") try: if self.font_path and os.path.exists(self.font_path): io.fonts.clear() # If glyph_ranges is None, it uses default (Basic Latin) if glyph_ranges: io.fonts.add_font_from_file_ttf(self.font_path, 18.0, glyph_ranges=glyph_ranges) else: io.fonts.add_font_from_file_ttf(self.font_path, 18.0) else: # Fallback to default or common font default_font = os.path.join(os.path.dirname(os.path.dirname(__file__)), "font", "msyh.ttc") if os.path.exists(default_font): io.fonts.clear() io.fonts.add_font_from_file_ttf(default_font, 18.0, glyph_ranges=glyph_ranges) else: io.fonts.clear() io.fonts.add_font_default() except Exception as e: print(f"[SSBOEditor] Font load error: {e}") io.fonts.clear() io.fonts.add_font_default() def load_model(self, model_path): """Load and process a model using hybrid static/dynamic chunks.""" print(f"[SSBOEditor] Loading model: {model_path}") fn = Filename.fromOsSpecific(model_path) source_model = self.base.loader.loadModel(fn) model_name = os.path.basename(model_path) if model_name: source_model.set_name(model_name) self.controller = ObjectController() count = self.controller.bake_ids_and_collect(source_model) self.model = self.controller.model self.model.reparent_to(self.base.render) # Keep this off by default for better overall FPS/scaling with visibility. self.set_realtime_shadow_updates(self.realtime_shadow_updates) # NO rp.set_effect() — use RP default rendering for max FPS # NO SSBO creation — vertex positions are baked # Setup GPU Picking (uses simple vertex-color shader) self.setup_gpu_picking() # Keep pick clone aligned with source model transform. self._sync_pick_model_transform() print(f"[SSBOEditor] Model loaded. Total objects: {count}") # No custom effect needed — RP default rendering for maximum FPS def _inject_ssbo_into_shadow_state(self, effect_path): """Inject SSBO inputs into RP shadow tag state""" try: if not hasattr(self.rp.tag_mgr, 'containers'): return shadow_container = self.rp.tag_mgr.containers.get("shadow") if not shadow_container: return tag_value = self.model.get_tag(shadow_container.tag_name) if not tag_value: return effect = Effect.load(effect_path, {}) if effect is None: return shadow_shader = effect.get_shader_obj("shadow") if shadow_shader is None: return # Since inputs are now on Nodes (Chunks), we just need to ensure the shader is applied. # extra_inputs is no longer needed if the inputs are on the nodes themselves? # Wait, RP might override state. # But specific shader inputs on NodePath have priority over State inputs usually? # Let's try applying without extra inputs first. self.rp.tag_mgr.apply_state( "shadow", self.model, shadow_shader, tag_value, 65) print(f"[SSBO Shadow] Re-applied shadow state (tag='{tag_value}')") except Exception as e: print(f"[SSBO Shadow] Error injecting shadow state: {e}") def setup_gpu_picking(self): """Setup GPU Picking (Basic implementation)""" # ... (Buffer setup code remains same) ... win_props = WindowProperties() win_props.set_size(1, 1) fb_props = FrameBufferProperties() fb_props.set_rgba_bits(8, 8, 8, 8) fb_props.set_depth_bits(16) self.pick_buffer = self.base.graphicsEngine.make_output( self.base.pipe, "pick_buffer", -100, fb_props, win_props, GraphicsPipe.BF_refuse_window, self.base.win.get_gsg(), self.base.win ) if not self.pick_buffer: print("[GPU Picking] Failed to create buffer!") return self.pick_texture = Texture() self.pick_texture.set_minfilter(Texture.FT_nearest) self.pick_texture.set_magfilter(Texture.FT_nearest) self.pick_buffer.add_render_texture(self.pick_texture, GraphicsOutput.RTM_copy_ram) self.pick_cam = Camera("pick_camera") self.pick_cam.set_camera_mask(self.pick_mask) self.pick_cam_np = self.base.cam.attach_new_node(self.pick_cam) self.pick_lens = self.base.camLens.make_copy() self.pick_cam.set_lens(self.pick_lens) dr = self.pick_buffer.make_display_region() dr.set_camera(self.pick_cam_np) # Load pick shader current_dir = os.path.dirname(os.path.abspath(__file__)) pick_vert_path = os.path.join(current_dir, "shaders", "pick_id.vert") pick_frag_path = os.path.join(current_dir, "shaders", "pick_id.frag") try: # Read shader source directly from OS filesystem to avoid # Panda3D VFS case-mismatch issues on Windows. with open(pick_vert_path, 'r', encoding='utf-8') as f: vert_src = f.read().replace('\r', '') with open(pick_frag_path, 'r', encoding='utf-8') as f: frag_src = f.read().replace('\r', '') pick_shader = Shader.make(Shader.SL_GLSL, vert_src, frag_src) pick_scene = getattr(self.controller, "pick_model", None) or self.model if pick_scene and not pick_scene.is_empty(): pick_scene.show(self.pick_mask) self.pick_cam.set_scene(pick_scene or self._empty_pick_scene) initial_state = NodePath("initial") initial_state.set_shader(pick_shader, 100) # Remove global SSBO input, Chunks have their own inputs # initial_state.set_shader_input("transforms", ssbo) self.pick_cam.set_initial_state(initial_state.get_state()) except Exception as e: print(f"[GPU Picking] Warning: pick shaders failed to load: {e}") print("Picking disabled.") return self.pick_buffer.set_active(False) self.pick_buffer.set_clear_color(Vec4(0, 0, 0, 0)) self.pick_buffer.set_clear_color_active(True) def _sync_pick_model_transform(self): """Sync pick-scene clone to current source model world transform.""" if not self.controller or not self.model: return pick_model = getattr(self.controller, "pick_model", None) if pick_model is None: return try: if pick_model.isEmpty(): return except Exception: try: if pick_model.is_empty(): return except Exception: pass try: if hasattr(self.controller, "get_model_world_mat"): world_mat = self.controller.get_model_world_mat() else: world_mat = LMatrix4f(self.model.getNetTransform().getMat()) try: pick_model.set_mat(world_mat) except Exception: pick_model.setMat(world_mat) except Exception: pass def _refresh_ssbo_proxy_center(self): """Update proxy center when source model transform changes.""" if self._ssbo_transform_active: return if not self.controller or not self._ssbo_selected_local_indices: return if self._ssbo_gizmo_proxy is None: return try: if self._ssbo_gizmo_proxy.isEmpty(): return except Exception: return try: center = self.controller.get_selection_center(self._ssbo_selected_local_indices) self._ssbo_gizmo_proxy.set_pos(center) except Exception: pass def _is_model_attached(self): """Whether the SSBO render root is still attached to scene graph.""" if not self.model or self.model.is_empty(): return False parent = self.model.get_parent() return bool(parent) and not parent.is_empty() def _sync_pick_scene_binding(self): """Switch pick camera scene based on current model attachment state.""" if not hasattr(self, "pick_cam") or not self.pick_cam: return if self._is_model_attached() and self.controller: target_scene = getattr(self.controller, "pick_model", None) or self.model else: target_scene = self._empty_pick_scene if not target_scene: target_scene = self._empty_pick_scene if self.pick_cam.get_scene() != target_scene: self.pick_cam.set_scene(target_scene) def pick_object(self, mx, my): if (not self.pick_buffer or not self.pick_texture or not self.pick_lens or not self.controller or not self.model): return False self._sync_pick_model_transform() self.pick_lens.set_fov(0.1) self.pick_lens.set_film_offset(0, 0) self.pick_cam.set_lens(self.pick_lens) near_point = Point3() far_point = Point3() self.base.camLens.extrude(Point2(mx, my), near_point, far_point) self.pick_cam_np.set_pos(0, 0, 0) self.pick_cam_np.look_at(far_point) # Ensure pick transforms are up-to-date before rendering the pick buffer. # The per-frame sync task may not have run yet for this frame. self._sync_pick_transforms() self.pick_buffer.set_active(True) self.base.graphicsEngine.render_frame() self.pick_buffer.set_active(False) ram_image = self.pick_texture.get_ram_image_as("RGBA") if ram_image: data = memoryview(ram_image) if len(data) >= 4: r, g, b, a = data[0], data[1], data[2], data[3] if a > 0 and b == 0: hit_id = r + (g << 8) node_key = self.controller.id_to_name.get(hit_id) if node_key: print(f"[Pick] Hit: ID={hit_id} -> {node_key}") self.select_node(node_key) return self.clear_selection() def on_mouse_click(self): io = imgui.get_io() if io.want_capture_mouse: return # Skip SSBO picking when user is interacting with the TransformGizmo, # otherwise pick_object would clear the selection and detach the gizmo # before the gizmo's own mouse handler fires. if self._transform_gizmo and self._transform_gizmo.is_hovering: return if self.base.mouseWatcherNode.has_mouse(): self._sync_pick_model_transform() self._refresh_ssbo_proxy_center() mpos = self.base.mouseWatcherNode.get_mouse() self.pick_object(mpos.x, mpos.y) def toggle_debug(self): self.debug_mode = not self.debug_mode def bind_transform_gizmo(self, gizmo): """Bind a TransformGizmo so it follows SSBO selection.""" self._transform_gizmo = gizmo def _start_pick_sync_task(self): """Start a per-frame task that syncs pick transforms for selected objects.""" self.base.task_mgr.remove("ssbo_pick_sync") self.base.task_mgr.add(self._pick_sync_task, "ssbo_pick_sync") def _stop_pick_sync_task(self): """Stop the per-frame pick sync task.""" self.base.task_mgr.remove("ssbo_pick_sync") def _pick_sync_task(self, task): """Per-frame: keep pick model transforms in sync with render model.""" self._sync_pick_transforms() return task.cont def _reset_pick_sync_cache(self): self._last_group_sync_mat = None self._last_single_sync_gid = None self._last_single_sync_mat = None def _matrices_close(self, a, b, eps=1e-5): """Small helper for robust matrix change detection.""" for r in range(4): ra = a.get_row(r) rb = b.get_row(r) if (abs(ra[0] - rb[0]) > eps or abs(ra[1] - rb[1]) > eps or abs(ra[2] - rb[2]) > eps or abs(ra[3] - rb[3]) > eps): return False return True def _sync_pick_root_transform(self): """ Keep pick root aligned with the render root transform. This covers transforms applied to the whole imported model (for example, moving box.glb from scene hierarchy). """ if not self.controller or not self.model or not self._is_model_attached(): return pick_root = getattr(self.controller, "pick_model", None) if not pick_root: return if self.model.is_empty() or pick_root.is_empty(): return pick_root.set_mat(self.base.render, self.model.get_mat(self.base.render)) def _sync_pick_transforms(self): """Sync pick model transforms to match render model transforms.""" if not self.controller: return self._sync_pick_root_transform() if not self.selected_ids: return # Group selection can contain thousands of objects. # Only resync when proxy transform has changed. proxy = getattr(self, "_group_proxy", None) if proxy and not proxy.is_empty() and len(self.selected_ids) > 1: proxy_world = proxy.get_mat(self.base.render) if self._last_group_sync_mat and self._matrices_close(proxy_world, self._last_group_sync_mat): return self._last_group_sync_mat = LMatrix4f(proxy_world) else: self._last_group_sync_mat = None if len(self.selected_ids) == 1: gid = self.selected_ids[0] obj_np = self.controller.id_to_object_np.get(gid) pick_np = self.controller.id_to_pick_np.get(gid) if not (obj_np and pick_np and not obj_np.is_empty() and not pick_np.is_empty()): return obj_world_mat = obj_np.get_mat(self.base.render) if ( self._last_single_sync_gid == gid and self._last_single_sync_mat and self._matrices_close(obj_world_mat, self._last_single_sync_mat) ): return self._last_single_sync_gid = gid self._last_single_sync_mat = LMatrix4f(obj_world_mat) pick_world_mat = pick_np.get_mat(self.base.render) if not self._matrices_close(obj_world_mat, pick_world_mat): pick_np.set_mat(self.base.render, obj_world_mat) chunk_id = self.controller.id_to_chunk.get(gid) if chunk_id is not None and chunk_id in self.controller.chunks: self.controller.chunks[chunk_id]["dirty"] = True return self._last_single_sync_gid = None self._last_single_sync_mat = None for gid in self.selected_ids: obj_np = self.controller.id_to_object_np.get(gid) pick_np = self.controller.id_to_pick_np.get(gid) if obj_np and pick_np and not obj_np.is_empty() and not pick_np.is_empty(): obj_world_mat = obj_np.get_mat(self.base.render) pick_world_mat = pick_np.get_mat(self.base.render) if not self._matrices_close(obj_world_mat, pick_world_mat): # Sync by world transform so this stays correct even when # the model root itself has been moved in scene hierarchy. pick_np.set_mat(self.base.render, obj_world_mat) chunk_id = self.controller.id_to_chunk.get(gid) if chunk_id is not None and chunk_id in self.controller.chunks: self.controller.chunks[chunk_id]["dirty"] = True def _update_outline_for_selection(self): if not self._outline_manager: return if not self.controller or not self.selected_ids: self._outline_manager.clear() return is_root_selection = ( self.controller and self.selected_name == getattr(self.controller, "tree_root_key", None) ) if is_root_selection: self._outline_manager.clear() return targets = [] target_limit = max(1, int(getattr(self._outline_manager, "max_targets", 64))) for gid in self.selected_ids: obj_np = self.controller.id_to_object_np.get(gid) if obj_np and not obj_np.is_empty(): targets.append(obj_np) if len(targets) >= target_limit: break self._outline_manager.set_targets(targets) def clear_selection(self): self._stop_pick_sync_task() self._reset_pick_sync_cache() self._cleanup_group_proxy() self.selected_name = None self.selected_ids = [] if self._outline_manager: self._outline_manager.clear() if self.controller: self.controller.set_active_ids([]) if self._transform_gizmo: self._transform_gizmo.detach() def on_model_deleted(self, deleted_node): """Called by app deletion flow when SSBO root model is deleted.""" if not deleted_node or deleted_node.is_empty() or not self.model: return if deleted_node != self.model: return self.clear_selection() self._sync_pick_scene_binding() def _cleanup_group_proxy(self): """Reparent objects back to their chunk and remove the group proxy.""" proxy = getattr(self, '_group_proxy', None) if not proxy: return originals = getattr(self, '_group_original_parents', {}) # Sync pick transforms and mark chunks dirty before reparenting for gid in originals: obj_np = self.controller.id_to_object_np.get(gid) pick_np = self.controller.id_to_pick_np.get(gid) if obj_np and pick_np and not obj_np.is_empty() and not pick_np.is_empty(): pick_np.set_mat(self.base.render, obj_np.get_mat(self.base.render)) chunk_id = self.controller.id_to_chunk.get(gid) if chunk_id is not None and chunk_id in self.controller.chunks: self.controller.chunks[chunk_id]["dirty"] = True # Reparent objects back to their original chunk parents for gid, parent_np in originals.items(): obj_np = self.controller.id_to_object_np.get(gid) if obj_np and not obj_np.is_empty() and parent_np and not parent_np.is_empty(): obj_np.wrt_reparent_to(parent_np) if not proxy.is_empty(): proxy.remove_node() self._group_proxy = None self._group_original_parents = {} def update_selection_mask(self): pass # No selection mask texture needed without custom shader def select_node(self, key): # Clean up previous group proxy before changing selection self._cleanup_group_proxy() self._reset_pick_sync_cache() self.selected_name = key self.selected_ids = self.controller.name_to_ids.get(key, []) is_root_selection = ( self.controller and key == getattr(self.controller, "tree_root_key", None) ) # Root selection should stay lightweight: # keep static chunks active and transform the model root directly. if is_root_selection: self.controller.set_active_ids([]) if self._outline_manager: self._outline_manager.clear() if self._transform_gizmo and self.model and not self.model.is_empty(): self._transform_gizmo.attach(self.model) else: if self._transform_gizmo: self._transform_gizmo.detach() self._stop_pick_sync_task() return self.controller.set_active_ids(self.selected_ids) self._update_outline_for_selection() if not self._transform_gizmo or not self.selected_ids: if self._transform_gizmo: self._transform_gizmo.detach() return if len(self.selected_ids) == 1: # Single object: attach gizmo directly obj_np = self.controller.id_to_object_np.get(self.selected_ids[0]) if obj_np and not obj_np.is_empty(): self._transform_gizmo.attach(obj_np) self._start_pick_sync_task() return # Multiple objects (parent node): create a group proxy so all children # follow the gizmo transform together. from panda3d.core import Vec3 proxy = self.base.render.attach_new_node("ssbo_group_proxy") center = Vec3(0, 0, 0) valid = [] for gid in self.selected_ids: obj_np = self.controller.id_to_object_np.get(gid) if obj_np and not obj_np.is_empty(): center += obj_np.get_pos(self.base.render) valid.append(gid) if not valid: proxy.remove_node() return center /= len(valid) proxy.set_pos(self.base.render, center) self._group_proxy = proxy self._group_original_parents = {} for gid in valid: obj_np = self.controller.id_to_object_np[gid] self._group_original_parents[gid] = obj_np.get_parent() obj_np.wrt_reparent_to(proxy) self._transform_gizmo.attach(proxy) # For huge groups, avoid per-frame full sync; we still sync on demand # right before picking via pick_object(). if len(valid) <= self._pick_sync_bg_limit: self._start_pick_sync_task() else: self._stop_pick_sync_task() def _rebuild_filtered_tree_rows(self): """ Build a flattened tree-row list with depth info for rendering in ImGui, while preserving source-model parent/child hierarchy. """ self.filtered_nodes = [] if not self.controller or not self.controller.tree_root_key: return search_lower = self.search_text.strip().lower() def walk(key, depth): node = self.controller.tree_nodes.get(key) if not node: return False, [] # Skip redundant wrapper nodes (e.g. ROOT under model file root), # while preserving child hierarchy and selection mapping. if self.controller.should_hide_tree_node(key): merged_rows = [] merged_visible = False for child_key in node["children"]: visible, rows = walk(child_key, depth) if visible: merged_visible = True merged_rows.extend(rows) return merged_visible, merged_rows display = self.controller.display_names.get(key, key) obj_count = len(self.controller.name_to_ids.get(key, [])) name_match = (not search_lower) or (search_lower in display.lower()) child_rows = [] child_match = False for child_key in node["children"]: visible, rows = walk(child_key, depth + 1) if visible: child_match = True child_rows.extend(rows) visible = (not search_lower) or name_match or child_match if not visible: return False, [] row = (key, depth, display, obj_count) return True, [row] + child_rows _, rows = walk(self.controller.tree_root_key, 0) self.filtered_nodes = rows def focus_on_selected(self): if self.selected_name and self.selected_ids: first_id = self.selected_ids[0] pos = self.controller.get_world_pos(first_id) dist = 100 self.base.camera.set_pos(pos.x, pos.y - dist, pos.z + dist * 0.5) self.base.camera.look_at(pos) def draw_imgui(self): if not self.controller: return imgui.set_next_window_pos((10, 10), imgui.Cond_.first_use_ever) imgui.set_next_window_size((350, 600), imgui.Cond_.first_use_ever) expanded, opened = imgui.begin("Scene Tree (Component)") if expanded: imgui.text(f"FPS: {globalClock.getAverageFrameRate():.1f}") imgui.separator() changed, self.search_text = imgui.input_text("Search", self.search_text, 256) if imgui.begin_child("ObjectList", (0, 380), child_flags=imgui.ChildFlags_.borders): if self.search_text != self.last_search_text or not self.filtered_nodes: self.last_search_text = self.search_text self._rebuild_filtered_tree_rows() count = len(self.filtered_nodes) clipper = imgui.ListClipper() clipper.begin(count) while clipper.step(): for i in range(clipper.display_start, clipper.display_end): key, depth, display, geom_count = self.filtered_nodes[i] indent = " " * depth label = f"{indent}{display} ({geom_count})##{key}" is_selected = (key == self.selected_name) if imgui.selectable(label, is_selected)[0]: self.select_node(key) imgui.end_child() imgui.separator() if self.selected_name: selected_display = self.controller.display_names.get(self.selected_name, self.selected_name) imgui.text_colored((1, 0.8, 0.2, 1), f"Selected: {selected_display}") if imgui.button("Focus (F)"): self.focus_on_selected() imgui.end() # swap_transforms_task removed - motion blur disabled for performance def update_task(self, task): dt = globalClock.getDt() io = imgui.get_io() self._sync_pick_model_transform() self._refresh_ssbo_proxy_center() self._sync_pick_scene_binding() # Scene-hierarchy transforms may move the whole SSBO model root; keep pick root in sync. self._sync_pick_root_transform() if io.want_capture_keyboard: return task.cont if self.selected_ids and self.controller: speed = 50 * dt acc = Vec3(0, 0, 0) if self.keys.get('arrow_up'): acc.z += speed if self.keys.get('arrow_down'): acc.z -= speed if self.keys.get('arrow_left'): acc.x -= speed if self.keys.get('arrow_right'): acc.x += speed if self.keys.get('z'): acc.y += speed if self.keys.get('x'): acc.y -= speed if acc.length_squared() > 0: is_root_selection = ( self.selected_name == getattr(self.controller, "tree_root_key", None) ) if is_root_selection and self.model and not self.model.is_empty(): next_pos = self.model.get_pos() + acc if hasattr(self.model, "set_fluid_pos"): self.model.set_fluid_pos(next_pos) else: self.model.set_pos(next_pos) self._sync_pick_root_transform() else: for idx in self.selected_ids: self.controller.move_object(idx, acc) return task.cont