
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, Material, MaterialAttrib, ColorAttrib, TextureAttrib, PNMImage
)

# 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 _fixBlackMaterials(self, model):
        try:
            from panda3d.core import MaterialAttrib, Material, GeomNode
            
            for geom_path in model.findAllMatches('**/+GeomNode'):
                geom_node = geom_path.node()
                
                node_state = geom_path.getState()
                if node_state.hasAttrib(MaterialAttrib.getClassType()):
                    mat_attrib = node_state.getAttrib(MaterialAttrib.getClassType())
                    mat = mat_attrib.getMaterial()
                    if mat:
                        is_black = False
                        if mat.hasBaseColor():
                            c = mat.getBaseColor()
                            if c.x <= 0.05 and c.y <= 0.05 and c.z <= 0.05:
                                is_black = True
                        elif mat.hasDiffuse():
                            c = mat.getDiffuse()
                            if c.x <= 0.05 and c.y <= 0.05 and c.z <= 0.05:
                                is_black = True
                        
                        if is_black or not (mat.hasBaseColor() or mat.hasDiffuse()):
                            new_mat = Material(mat) 
                            new_mat.setBaseColor((0.8, 0.8, 0.8, 1.0))
                            new_mat.setDiffuse((0.8, 0.8, 0.8, 1.0))
                            geom_path.setState(node_state.setAttrib(MaterialAttrib.make(new_mat)))
                
                for i in range(geom_node.getNumGeoms()):
                    geom_state = geom_node.getGeomState(i)
                    if geom_state.hasAttrib(MaterialAttrib.getClassType()):
                        mat_attrib = geom_state.getAttrib(MaterialAttrib.getClassType())
                        mat = mat_attrib.getMaterial()
                        if mat:
                            is_black = False
                            if mat.hasBaseColor():
                                c = mat.getBaseColor()
                                if c.x <= 0.05 and c.y <= 0.05 and c.z <= 0.05:
                                    is_black = True
                            elif mat.hasDiffuse():
                                c = mat.getDiffuse()
                                if c.x <= 0.05 and c.y <= 0.05 and c.z <= 0.05:
                                    is_black = True
                                    
                            if is_black or not (mat.hasBaseColor() or mat.hasDiffuse()):
                                new_mat = Material(mat)
                                new_mat.setBaseColor((0.8, 0.8, 0.8, 1.0))
                                new_mat.setDiffuse((0.8, 0.8, 0.8, 1.0))
                                geom_node.setGeomState(i, geom_state.setAttrib(MaterialAttrib.make(new_mat)))
                    else:
                        new_mat = Material()
                        new_mat.setBaseColor((0.8, 0.8, 0.8, 1.0))
                        new_mat.setDiffuse((0.8, 0.8, 0.8, 1.0))
                        new_mat.setSpecular((0.2, 0.2, 0.2, 1.0))
                        new_mat.setRoughness(0.8)
                        geom_node.setGeomState(i, geom_state.addAttrib(MaterialAttrib.make(new_mat)))

            model.clearColor()
            
        except Exception as e:
            print(f'修复黑色模型材质时出错: {e}')

    def load_model(self, model_path):
        """Load and process a model using hybrid static/dynamic chunks."""
        print(f"[SSBOEditor] Loading model: {model_path}")
        source_model = None
        last_error = None
        for fn in self._build_filename_candidates(model_path):
            try:
                source_model = self.base.loader.loadModel(fn)
                if source_model and not source_model.is_empty():
                    break
            except Exception as e:
                last_error = e
                source_model = None
        if not source_model or source_model.is_empty():
            if last_error:
                raise RuntimeError(f"Failed to load model '{model_path}': {last_error}")
            raise RuntimeError(f"Failed to load model '{model_path}'")
        self._fixBlackMaterials(source_model)
        self._repair_missing_textures(source_model, model_path)
        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}")

    def _build_filename_candidates(self, path_text):
        """Build Filename candidates with wide-char first for Windows CJK paths."""
        candidates = []
        seen = set()
        for ctor_name in ("fromOsSpecificW", "from_os_specific_w", "fromOsSpecific", "from_os_specific"):
            ctor = getattr(Filename, ctor_name, None)
            if not ctor:
                continue
            try:
                fn = ctor(path_text)
                key = fn.get_fullpath()
                if key in seen:
                    continue
                seen.add(key)
                candidates.append(fn)
            except Exception:
                continue
        if not candidates:
            try:
                candidates.append(Filename(path_text))
            except Exception:
                pass
        return candidates

    def _load_texture_from_path(self, texture_path):
        """Load texture with robust path constructors."""
        for fn in self._build_filename_candidates(texture_path):
            try:
                tex = self.base.loader.loadTexture(fn)
                if tex:
                    return tex
            except Exception:
                continue
        return None

    def _build_texture_search_dirs(self, model_path):
        """Build candidate directories for missing texture recovery."""
        dirs = []
        model_dir = os.path.dirname(os.path.abspath(model_path))
        project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))

        def add_dir(path):
            if not path:
                return
            path = os.path.normpath(path)
            if path in dirs:
                return
            if os.path.isdir(path):
                dirs.append(path)

        add_dir(model_dir)

        try:
            if os.path.isdir(model_dir):
                for item in os.listdir(model_dir):
                    if item.lower().endswith('.fbm'):
                        fbm_dir = os.path.join(model_dir, item)
                        if os.path.isdir(fbm_dir):
                            add_dir(fbm_dir)
                            for sub in ("textures", "texture", "tex", "assets", "materials"):
                                add_dir(os.path.join(fbm_dir, sub))
        except Exception:
            pass

        for sub in ("textures", "texture", "tex", "assets", "materials"):
            add_dir(os.path.join(model_dir, sub))

        parent = model_dir
        for _ in range(2):
            parent = os.path.dirname(parent)
            if not parent:
                break
            for sub in ("textures", "texture", "tex", "assets", "materials"):
                add_dir(os.path.join(parent, sub))

        add_dir(os.path.join(project_root, "Resources"))
        add_dir(os.path.join(project_root, "Resources", "textures"))
        add_dir(os.path.join(project_root, "Resources", "materials"))
        add_dir(os.path.join(project_root, "Resources", "models"))
        return dirs

    def _index_texture_files(self, dirs, limit=30000):
        """Index texture files by basename for fast lookup."""
        texture_exts = {".png", ".jpg", ".jpeg", ".tga", ".bmp", ".dds", ".ktx", ".ktx2", ".webp"}
        index = {}
        scanned = 0
        for root_dir in dirs:
            try:
                for root, _, files in os.walk(root_dir):
                    for filename in files:
                        ext = os.path.splitext(filename)[1].lower()
                        if ext not in texture_exts:
                            continue
                        key = filename.lower()
                        if key not in index:
                            index[key] = os.path.join(root, filename)
                        scanned += 1
                        if scanned >= limit:
                            return index
            except Exception:
                continue
        return index

    def _repair_missing_textures(self, model_np, model_path):
        """
        Repair broken texture paths by basename search; if unresolved, clear that
        missing texture binding to avoid black PBR sampling.
        """
        if not model_np or model_np.is_empty():
            return

        search_dirs = self._build_texture_search_dirs(model_path)
        texture_index = self._index_texture_files(search_dirs)

        fixed = 0
        cleared = 0
        white_tex = self._get_white_fallback_texture()
        nodes = [model_np] + list(model_np.find_all_matches("**"))
        for node in nodes:
            if not node or node.is_empty():
                continue

            try:
                stages = node.find_all_texture_stages()
            except Exception:
                try:
                    stages = node.findAllTextureStages()
                except Exception:
                    continue

            try:
                stage_count = stages.get_num_texture_stages()
                stage_at = stages.get_texture_stage
            except Exception:
                try:
                    stage_count = stages.getNumTextureStages()
                    stage_at = stages.getTextureStage
                except Exception:
                    continue

            for i in range(stage_count):
                stage = stage_at(i)
                if not stage:
                    continue

                try:
                    tex = node.get_texture(stage)
                except Exception:
                    tex = node.getTexture(stage)
                if not tex:
                    continue

                if self._texture_is_valid(tex):
                    continue

                basename = self._extract_texture_basename(tex)
                if not basename:
                    continue

                replacement = texture_index.get(basename.lower())
                if replacement:
                    try:
                        new_tex = self._load_texture_from_path(replacement)
                        if new_tex:
                            try:
                                node.set_texture(stage, new_tex, 1)
                            except Exception:
                                node.setTexture(stage, new_tex, 1)
                            fixed += 1
                            continue
                    except Exception:
                        pass

                # Missing texture with no replacement: CLEAR the texture binding.
                # Do NOT use a white fallback texture, because if this is a Metallic or Roughness
                # map, pure white will force Metallic=1.0 and Roughness=1.0, which turns models black!
                try:
                    try:
                        node.clear_texture(stage)
                    except Exception:
                        node.clearTexture(stage)
                    cleared += 1
                except Exception:
                    pass

            # GeomState-level texture bindings (common in imported FBX/GLTF):
            # inspect and apply the same fallback on this GeomNode path.
            try:
                gnode = node.node()
                get_num_geoms = getattr(gnode, "get_num_geoms", None) or getattr(gnode, "getNumGeoms", None)
                get_geom_state = getattr(gnode, "get_geom_state", None) or getattr(gnode, "getGeomState", None)
                geom_count = int(get_num_geoms()) if callable(get_num_geoms) else 0
            except Exception:
                geom_count = 0

            for gi in range(geom_count):
                try:
                    state = get_geom_state(gi)
                except Exception:
                    continue
                if state is None:
                    continue

                tattr = None
                try:
                    if state.has_attrib(TextureAttrib.get_class_type()):
                        tattr = state.get_attrib(TextureAttrib.get_class_type())
                except Exception:
                    try:
                        if state.hasAttrib(TextureAttrib.getClassType()):
                            tattr = state.getAttrib(TextureAttrib.getClassType())
                    except Exception:
                        tattr = None
                if not tattr:
                    continue

                try:
                    num_on = tattr.get_num_on_stages()
                    get_stage = tattr.get_on_stage
                    get_tex = tattr.get_on_texture
                except Exception:
                    try:
                        num_on = tattr.getNumOnStages()
                        get_stage = tattr.getOnStage
                        get_tex = tattr.getOnTexture
                    except Exception:
                        continue

                for si in range(int(num_on)):
                    try:
                        stage = get_stage(si)
                        tex = get_tex(stage)
                    except Exception:
                        continue
                    if not stage or not tex:
                        continue
                    if self._texture_is_valid(tex):
                        continue

                    basename = self._extract_texture_basename(tex)
                    replacement = texture_index.get(basename.lower()) if basename else None
                    if replacement:
                        try:
                            new_tex = self._load_texture_from_path(replacement)
                            if new_tex:
                                try:
                                    node.set_texture(stage, new_tex, 100000)
                                except Exception:
                                    node.setTexture(stage, new_tex, 100000)
                                fixed += 1
                                continue
                        except Exception:
                            pass

                    try:
                        try:
                            node.clear_texture(stage)
                        except Exception:
                            node.clearTexture(stage)
                        cleared += 1
                    except Exception:
                        pass

        if fixed or cleared:
            print(f"[SSBOEditor] Texture repair: fixed={fixed}, cleared_missing={cleared}")
        self._apply_nonblack_material_fallback(model_np)

    def _texture_is_valid(self, tex):
        if not tex:
            return False
        tex_path = self._extract_texture_os_path(tex)
        if tex_path and os.path.exists(tex_path):
            return True
        if tex_path:
            # Some valid textures use Panda VFS virtual paths; keep them RAM-checkable.
            path_norm = tex_path.replace("\\", "/")
            if path_norm.startswith("/$$") or path_norm.startswith("$$"):
                pass
            else:
                # File-backed texture with missing source path should be considered invalid,
                # even when Panda keeps a tiny fallback image in RAM.
                return False
        try:
            if tex.has_ram_image():
                return tex.get_ram_image_size() > 0
        except Exception:
            try:
                if tex.hasRamImage():
                    return tex.getRamImageSize() > 0
            except Exception:
                pass
        return False

    def _extract_texture_os_path(self, tex):
        tex_path = ""
        try:
            if tex.has_fullpath():
                fullpath = tex.get_fullpath()
                try:
                    tex_path = fullpath.to_os_specific()
                except Exception:
                    try:
                        tex_path = fullpath.toOsSpecific()
                    except Exception:
                        tex_path = str(fullpath)
        except Exception:
            try:
                if tex.hasFullpath():
                    fullpath = tex.getFullpath()
                    try:
                        tex_path = fullpath.toOsSpecific()
                    except Exception:
                        tex_path = str(fullpath)
            except Exception:
                tex_path = ""

        tex_path = str(tex_path or "").strip()
        if not tex_path:
            return ""

        tex_path = os.path.normpath(tex_path)
        if os.path.exists(tex_path):
            return tex_path

        # Convert Panda internal drive path (/d/foo/bar) to Windows path if needed.
        if len(tex_path) >= 3 and tex_path[0] in ("/", "\\") and tex_path[1].isalpha() and tex_path[2] in ("/", "\\"):
            drive_path = f"{tex_path[1]}:{tex_path[2:]}"
            drive_path = os.path.normpath(drive_path)
            if os.path.exists(drive_path):
                return drive_path
        return tex_path

    def _extract_texture_basename(self, tex):
        tex_path = self._extract_texture_os_path(tex)
        if tex_path:
            return os.path.basename(tex_path.replace("\\", "/"))
        try:
            name = tex.get_name()
        except Exception:
            try:
                name = tex.getName()
            except Exception:
                name = ""
        return os.path.basename(str(name).replace("\\", "/"))

    def _get_white_fallback_texture(self):
        tex = getattr(self, "_ssbo_white_fallback_tex", None)
        if tex:
            return tex
        try:
            img = PNMImage(2, 2, 4)
            img.fill(1.0, 1.0, 1.0)
            img.alpha_fill(1.0)
            tex = Texture("ssbo_white_fallback")
            tex.load(img)
            tex.set_minfilter(Texture.FT_nearest)
            tex.set_magfilter(Texture.FT_nearest)
            self._ssbo_white_fallback_tex = tex
            return tex
        except Exception:
            return None

    def _node_has_valid_texture(self, node):
        if not node or node.is_empty():
            return False
        try:
            stages = node.find_all_texture_stages()
            stage_count = stages.get_num_texture_stages()
            stage_at = stages.get_texture_stage
        except Exception:
            try:
                stages = node.findAllTextureStages()
                stage_count = stages.getNumTextureStages()
                stage_at = stages.getTextureStage
            except Exception:
                return False
        for i in range(stage_count):
            stage = stage_at(i)
            if not stage:
                continue
            try:
                tex = node.get_texture(stage)
            except Exception:
                tex = node.getTexture(stage)
            if self._texture_is_valid(tex):
                return True

        # GeomState TextureAttrib bindings can hold the effective textures.
        try:
            gnode = node.node()
            get_num_geoms = getattr(gnode, "get_num_geoms", None) or getattr(gnode, "getNumGeoms", None)
            get_geom_state = getattr(gnode, "get_geom_state", None) or getattr(gnode, "getGeomState", None)
            geom_count = int(get_num_geoms()) if callable(get_num_geoms) else 0
        except Exception:
            geom_count = 0

        for gi in range(geom_count):
            try:
                state = get_geom_state(gi)
            except Exception:
                continue
            if state is None:
                continue

            tattr = None
            try:
                if state.has_attrib(TextureAttrib.get_class_type()):
                    tattr = state.get_attrib(TextureAttrib.get_class_type())
            except Exception:
                try:
                    if state.hasAttrib(TextureAttrib.getClassType()):
                        tattr = state.getAttrib(TextureAttrib.getClassType())
                except Exception:
                    tattr = None
            if not tattr:
                continue

            try:
                num_on = tattr.get_num_on_stages()
                get_stage = tattr.get_on_stage
                get_tex = tattr.get_on_texture
            except Exception:
                try:
                    num_on = tattr.getNumOnStages()
                    get_stage = tattr.getOnStage
                    get_tex = tattr.getOnTexture
                except Exception:
                    continue

            for si in range(int(num_on)):
                try:
                    stage = get_stage(si)
                    tex = get_tex(stage)
                except Exception:
                    continue
                if self._texture_is_valid(tex):
                    return True
        return False

    def _is_node_material_dark(self, node):
        """Heuristic: detect near-black material/color state."""
        if not node or node.is_empty():
            return False
        try:
            state = node.get_state()
        except Exception:
            try:
                state = node.getState()
            except Exception:
                return True

        def _vec_dark(v):
            try:
                return max(float(v[0]), float(v[1]), float(v[2])) < 0.08
            except Exception:
                try:
                    return max(float(v.x), float(v.y), float(v.z)) < 0.08
                except Exception:
                    return False

        # Material color
        mat = None
        try:
            if state.has_attrib(MaterialAttrib.get_class_type()):
                mat_attr = state.get_attrib(MaterialAttrib.get_class_type())
                mat = mat_attr.get_material()
        except Exception:
            try:
                if state.hasAttrib(MaterialAttrib.getClassType()):
                    mat_attr = state.getAttrib(MaterialAttrib.getClassType())
                    mat = mat_attr.getMaterial()
            except Exception:
                mat = None

        if mat:
            for has_name, get_name in (
                ("has_base_color", "get_base_color"),
                ("has_diffuse", "get_diffuse"),
                ("hasBaseColor", "getBaseColor"),
                ("hasDiffuse", "getDiffuse"),
            ):
                has_fn = getattr(mat, has_name, None)
                get_fn = getattr(mat, get_name, None)
                if callable(has_fn) and callable(get_fn):
                    try:
                        if has_fn():
                            return _vec_dark(get_fn())
                    except Exception:
                        continue
            return False

        # ColorAttrib fallback
        try:
            if state.has_attrib(ColorAttrib.get_class_type()):
                color_attr = state.get_attrib(ColorAttrib.get_class_type())
                if not color_attr.is_off():
                    return _vec_dark(color_attr.get_color())
        except Exception:
            try:
                if state.hasAttrib(ColorAttrib.getClassType()):
                    color_attr = state.getAttrib(ColorAttrib.getClassType())
                    if not color_attr.isOff():
                        return _vec_dark(color_attr.getColor())
            except Exception:
                pass
        return True

    def _apply_nonblack_material_fallback(self, model_np):
        """If a geom has no texture and is effectively black, apply a neutral material."""
        if not model_np or model_np.is_empty():
            return
        neutral = Material()
        neutral.set_base_color((0.75, 0.75, 0.75, 1.0)) if hasattr(neutral, "set_base_color") else neutral.setBaseColor((0.75, 0.75, 0.75, 1.0))
        neutral.set_diffuse((0.75, 0.75, 0.75, 1.0)) if hasattr(neutral, "set_diffuse") else neutral.setDiffuse((0.75, 0.75, 0.75, 1.0))
        neutral.set_ambient((0.22, 0.22, 0.22, 1.0)) if hasattr(neutral, "set_ambient") else neutral.setAmbient((0.22, 0.22, 0.22, 1.0))
        neutral.set_specular((0.1, 0.1, 0.1, 1.0)) if hasattr(neutral, "set_specular") else neutral.setSpecular((0.1, 0.1, 0.1, 1.0))
        neutral.set_shininess(8.0) if hasattr(neutral, "set_shininess") else neutral.setShininess(8.0)

        patched = 0
        for geom_np in model_np.find_all_matches("**/+GeomNode"):
            if self._node_has_valid_texture(geom_np):
                continue
            if not self._is_node_material_dark(geom_np):
                continue
            try:
                try:
                    geom_np.set_material(neutral, 1)
                except Exception:
                    geom_np.setMaterial(neutral, 1)
                try:
                    geom_np.set_color_scale(1.0, 1.0, 1.0, 1.0)
                except Exception:
                    geom_np.setColorScale(1.0, 1.0, 1.0, 1.0)
                patched += 1
            except Exception:
                continue
        if patched:
            print(f"[SSBOEditor] Applied non-black fallback material to {patched} geom nodes.")

    # 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
