diff --git a/imgui.ini b/imgui.ini index a5a960c5..aa58c012 100644 --- a/imgui.ini +++ b/imgui.ini @@ -25,31 +25,31 @@ Collapsed=0 [Window][工具栏] Pos=323,20 -Size=690,32 +Size=1156,32 Collapsed=0 DockId=0x0000000D,0 [Window][场景树] Pos=0,20 -Size=321,468 +Size=321,634 Collapsed=0 DockId=0x00000007,0 [Window][属性面板] -Pos=1015,20 -Size=365,730 +Pos=1481,20 +Size=439,989 Collapsed=0 DockId=0x00000003,0 [Window][控制台] -Pos=0,490 -Size=321,260 +Pos=0,656 +Size=321,353 Collapsed=0 DockId=0x00000008,0 [Window][脚本管理] -Pos=1540,20 -Size=380,390 +Pos=1481,20 +Size=439,989 Collapsed=0 DockId=0x00000003,1 @@ -60,7 +60,7 @@ Collapsed=0 [Window][WindowOverViewport_11111111] Pos=0,20 -Size=1380,730 +Size=1920,989 Collapsed=0 [Window][测试窗口1] @@ -84,7 +84,7 @@ Size=400,300 Collapsed=0 [Window][选择路径] -Pos=390,125 +Pos=660,254 Size=600,500 Collapsed=0 @@ -94,13 +94,13 @@ Size=500,400 Collapsed=0 [Window][导入模型] -Pos=390,125 +Pos=660,254 Size=600,500 Collapsed=0 [Window][资源管理器] -Pos=323,568 -Size=690,182 +Pos=323,827 +Size=1156,182 Collapsed=0 DockId=0x00000006,0 @@ -196,17 +196,17 @@ Size=120,384 Collapsed=0 [Docking][Data] -DockSpace ID=0x08BD597D Window=0x1BBC0F80 Pos=0,20 Size=1380,730 Split=X - DockNode ID=0x00000001 Parent=0x08BD597D SizeRef=1553,989 Split=X +DockSpace ID=0x08BD597D Window=0x1BBC0F80 Pos=0,20 Size=1920,989 Split=X + DockNode ID=0x00000001 Parent=0x08BD597D SizeRef=1479,989 Split=X DockNode ID=0x00000009 Parent=0x00000001 SizeRef=321,989 Split=Y Selected=0xE0015051 DockNode ID=0x00000007 Parent=0x00000009 SizeRef=271,634 Selected=0xE0015051 DockNode ID=0x00000008 Parent=0x00000009 SizeRef=271,353 Selected=0x5428E753 - DockNode ID=0x0000000A Parent=0x00000001 SizeRef=1230,989 Split=Y + DockNode ID=0x0000000A Parent=0x00000001 SizeRef=1156,989 Split=Y DockNode ID=0x0000000D Parent=0x0000000A SizeRef=1318,32 HiddenTabBar=1 Selected=0x43A39006 DockNode ID=0x0000000E Parent=0x0000000A SizeRef=1318,955 Split=Y DockNode ID=0x00000005 Parent=0x0000000E SizeRef=1341,771 CentralNode=1 DockNode ID=0x00000006 Parent=0x0000000E SizeRef=1341,182 Selected=0x3A2E05C3 - DockNode ID=0x00000002 Parent=0x08BD597D SizeRef=365,989 Split=Y Selected=0x3188AB8D + DockNode ID=0x00000002 Parent=0x08BD597D SizeRef=439,989 Split=Y Selected=0x3188AB8D DockNode ID=0x00000003 Parent=0x00000002 SizeRef=351,390 Selected=0x5DB6FF37 DockNode ID=0x00000004 Parent=0x00000002 SizeRef=351,597 Selected=0x1EB923B7 diff --git a/ssbo_component/backup_merge_20260225_125202/ssbo_controller.py.bak b/ssbo_component/backup_merge_20260225_125202/ssbo_controller.py.bak new file mode 100644 index 00000000..d6d97b4e --- /dev/null +++ b/ssbo_component/backup_merge_20260225_125202/ssbo_controller.py.bak @@ -0,0 +1,550 @@ + +from panda3d.core import ( + GeomVertexFormat, GeomVertexWriter, GeomVertexReader, GeomVertexRewriter, + InternalName, Vec3, Vec4, LMatrix4f, ShaderBuffer, GeomEnums, + BoundingSphere, NodePath, GeomNode, Texture, SamplerState, + Point3, BoundingBox, Quat +) +import struct +import time + +class ObjectController: + """ + 物体控制器 (No Custom Shader Mode) + ==================================== + Uses RP's default rendering (no rp.set_effect) for maximum FPS. + Vertex colors baked for picking. Movement modifies vertex data directly. + Stores original vertex positions per object for rotation/translation. + """ + def __init__(self): + self.name_to_ids = {} + self.id_to_name = {} + self.key_to_node = {} + self.node_list = [] + self.display_names = {} + self.global_transforms = [] # Original transforms (for center/position) + + self.id_to_chunk = {} # global_id -> (chunk_key, local_idx) + self.chunks = {} # chunk_key -> dict with 'node' key + + # Vertex index: local_id -> list of (geom_node_np, geom_idx, [row_indices]) + self.vertex_index = {} + + # Original vertex positions: local_id -> list of (Vec3,) matching row order + self.original_positions = {} + + # Current position offsets: local_id -> Vec3 delta + self.position_offsets = {} + self.local_to_global_id = {} + self.local_transform_state = {} + self.local_transform_base_positions = {} + self.virtual_tree = None + self.virtual_tree_meta = None + + self.model = None + self.chunk_node = None # Single chunk node + + def bake_ids_and_collect(self, model): + """ + Bake IDs into vertex colors, flatten, then build vertex index. + + NO transform reset — vertices keep world-space positions. + NO SSBO — uses RP default rendering. + """ + t0 = time.time() + + geom_nodes = list(model.find_all_matches("**/+GeomNode")) + print(f"[控制器] 找到 {len(geom_nodes)} 个 GeomNode") + + self.name_to_ids = {} + self.id_to_name = {} + self.key_to_node = {} + self.node_list = [] + self.display_names = {} + self.global_transforms = [] + self.id_to_chunk = {} + self.chunks = {} + self.vertex_index = {} + self.original_positions = {} + self.position_offsets = {} + self.local_to_global_id = {} + self.local_transform_state = {} + self.local_transform_base_positions = {} + self.virtual_tree = None + self.virtual_tree_meta = None + + global_id_counter = 0 + chunk_key = model.get_name() or "default" + + # No chunk wrapper — flatten directly on model (same as load_jyc_flatten.py) + self.chunk_node = model + self.chunks[chunk_key] = {'node': model, 'base_id': 0} + + # Flatten hierarchy + for np in geom_nodes: + np.wrt_reparent_to(model) + + local_idx = 0 + + for np in geom_nodes: + gnode = np.node() + + if gnode.get_num_parents() > 1: + parent = np.get_parent() + if not parent.is_empty(): + new_np = np.copy_to(parent) + np.detach_node() + np = new_np + gnode = np.node() + + unique_key = str(np) + display_name = np.get_name() or f"Object_{global_id_counter}" + + if unique_key not in self.name_to_ids: + self.name_to_ids[unique_key] = [] + self.key_to_node[unique_key] = np + self.node_list.append(unique_key) + self.display_names[unique_key] = display_name + + # Save original transform + mat_double = np.get_mat() + original_transform = LMatrix4f(mat_double) + + for i in range(gnode.get_num_geoms()): + geom = gnode.modify_geom(i) + vdata = geom.modify_vertex_data() + + if not vdata.has_column("color"): + new_format = vdata.get_format().get_union_format(GeomVertexFormat.get_v3c4()) + vdata.set_format(new_format) + + # Encode Local ID in R/G + low = local_idx % 256 + high = local_idx // 256 + r = low / 255.0 + g = high / 255.0 + + writer = GeomVertexWriter(vdata, InternalName.make("color")) + for row in range(vdata.get_num_rows()): + writer.set_row(row) + writer.set_data4f(r, g, 0.0, 1.0) + + self.global_transforms.append(original_transform) + self.id_to_chunk[global_id_counter] = (chunk_key, local_idx) + self.name_to_ids[unique_key].append(global_id_counter) + self.id_to_name[global_id_counter] = unique_key + self.local_to_global_id[local_idx] = global_id_counter + self.position_offsets[local_idx] = Vec3(0, 0, 0) + + global_id_counter += 1 + local_idx += 1 + + # DO NOT reset transform — keep world-space positions + + # Flatten directly on model — NO set_final, allows per-geom frustum culling + model.flatten_strong() + + t1 = time.time() + print(f"[控制器] Flatten took {(t1-t0)*1000:.0f}ms") + + # Build vertex index AFTER flatten + self._build_vertex_index(model) + self._init_local_transform_state() + self.build_virtual_hierarchy() + + t2 = time.time() + print(f"[控制器] Vertex index built in {(t2-t1)*1000:.0f}ms, " + f"{len(self.vertex_index)} unique IDs indexed") + + self.model = model + self.node_list.sort() + return global_id_counter + + def build_virtual_hierarchy(self): + """Build a readonly virtual tree from node_list path keys.""" + root = { + "name": "", + "path": "", + "children": {}, + "leaf_key": None, + "display_name": "", + } + max_depth = 0 + leaf_count = 0 + + for key in self.node_list: + if not key: + continue + parts = [p for p in str(key).split("/") if p] + if not parts: + continue + max_depth = max(max_depth, len(parts)) + cursor = root + path_acc = "" + for i, part in enumerate(parts): + path_acc = f"{path_acc}/{part}" if path_acc else part + child = cursor["children"].get(part) + if child is None: + child = { + "name": part, + "path": path_acc, + "children": {}, + "leaf_key": None, + "display_name": part, + } + cursor["children"][part] = child + cursor = child + if i == len(parts) - 1: + cursor["leaf_key"] = key + cursor["display_name"] = self.display_names.get(key, part) + leaf_count += 1 + + self.virtual_tree = root + self.virtual_tree_meta = {"max_depth": max_depth, "leaf_count": leaf_count} + return root + + def get_virtual_hierarchy(self): + """Return cached virtual tree; build on demand.""" + if self.virtual_tree is None: + return self.build_virtual_hierarchy() + return self.virtual_tree + + def _build_vertex_index(self, chunk_root): + """ + After flatten, batch-read all vertex data with numpy to build: + local_id -> [(geom_node_np, geom_idx, row_indices_array)] + Also stores original vertex positions per object (as numpy arrays). + """ + import numpy as np + + for gn_np in chunk_root.find_all_matches("**/+GeomNode"): + gnode = gn_np.node() + for gi in range(gnode.get_num_geoms()): + geom = gnode.get_geom(gi) + vdata = geom.get_vertex_data() + num_rows = vdata.get_num_rows() + + if num_rows == 0: + continue + + # Find vertex and color column info + fmt = vdata.get_format() + + # Get position column + pos_col = fmt.get_column(InternalName.get_vertex()) + if pos_col is None: + continue + pos_array_idx = fmt.get_array_with(InternalName.get_vertex()) + pos_start = pos_col.get_start() + + # Get color column + color_col = fmt.get_column(InternalName.make("color")) + if color_col is None: + continue + color_array_idx = fmt.get_array_with(InternalName.make("color")) + color_start = color_col.get_start() + + # Read raw position array + pos_array_format = fmt.get_array(pos_array_idx) + pos_stride = pos_array_format.get_stride() + pos_handle = vdata.get_array(pos_array_idx).get_handle() + pos_raw = bytes(pos_handle.get_data()) + pos_buf = np.frombuffer(pos_raw, dtype=np.uint8).reshape(num_rows, pos_stride) + + # Extract xyz positions (3 floats starting at pos_start) + positions = np.ndarray((num_rows, 3), dtype=np.float32, + buffer=pos_buf[:, pos_start:pos_start+12].tobytes()) + + # Read raw color array + color_array_format = fmt.get_array(color_array_idx) + color_stride = color_array_format.get_stride() + + if color_array_idx == pos_array_idx: + color_buf = pos_buf + else: + color_handle = vdata.get_array(color_array_idx).get_handle() + color_raw = bytes(color_handle.get_data()) + color_buf = np.frombuffer(color_raw, dtype=np.uint8).reshape(num_rows, color_stride) + + # Decode color format to get ID + # Color can be stored as float32 RGBA or unorm8 RGBA + num_components = color_col.get_num_components() + component_bytes = color_col.get_component_bytes() + + if component_bytes == 4: # float32 per component + color_data = np.ndarray((num_rows, num_components), dtype=np.float32, + buffer=color_buf[:, color_start:color_start+num_components*4].tobytes()) + r_vals = (color_data[:, 0] * 255.0 + 0.5).astype(np.int32) + g_vals = (color_data[:, 1] * 255.0 + 0.5).astype(np.int32) + elif component_bytes == 1: # uint8 per component + color_bytes = color_buf[:, color_start:color_start+num_components].copy() + r_vals = color_bytes[:, 0].astype(np.int32) + g_vals = color_bytes[:, 1].astype(np.int32) + else: + # Fallback: skip this geom + continue + + local_ids = r_vals + (g_vals << 8) + + # Group rows by local_id using argsort (O(N log N) instead of O(N×K)) + sort_idx = np.argsort(local_ids) + sorted_ids = local_ids[sort_idx] + sorted_positions = positions[sort_idx] + + # Find group boundaries + boundaries = np.where(np.diff(sorted_ids) != 0)[0] + 1 + + # Split into groups + id_groups = np.split(sort_idx, boundaries) + pos_groups = np.split(sorted_positions, boundaries) + group_ids = sorted_ids[np.concatenate([[0], boundaries])] + + for k in range(len(group_ids)): + uid = int(group_ids[k]) + rows = id_groups[k] + pos = pos_groups[k] + + if uid not in self.vertex_index: + self.vertex_index[uid] = [] + self.original_positions[uid] = [] + + self.vertex_index[uid].append((gn_np, gi, rows)) + self.original_positions[uid].append(pos.copy()) + + def _init_local_transform_state(self): + """Initialize transform state for each local_idx after vertex index is ready.""" + self.local_transform_state = {} + self.local_transform_base_positions = {} + + for local_idx in self.vertex_index.keys(): + self.local_transform_base_positions[local_idx] = self.original_positions.get(local_idx, []) + self.local_transform_state[local_idx] = { + "offset": Vec3(0, 0, 0), + "quat": Quat.identQuat(), + "scale": Vec3(1, 1, 1), + "pivot": self.get_local_pivot(local_idx), + } + + def get_local_indices_from_global_ids(self, global_ids): + """Map global ids to unique local indices.""" + local_indices = [] + if not global_ids: + return local_indices + seen = set() + for global_id in global_ids: + mapping = self.id_to_chunk.get(global_id) + if not mapping: + continue + _, local_idx = mapping + if local_idx in seen: + continue + if local_idx not in self.vertex_index: + continue + seen.add(local_idx) + local_indices.append(local_idx) + return local_indices + + def get_local_pivot(self, local_idx): + """Get pivot for one local object (world-space center).""" + global_id = self.local_to_global_id.get(local_idx) + if global_id is None: + return Vec3(0, 0, 0) + return self.get_object_center(global_id) + + def get_selection_center(self, local_indices): + """Get center point for a multi-object selection.""" + if not local_indices: + return Vec3(0, 0, 0) + acc = Vec3(0, 0, 0) + valid = 0 + for local_idx in local_indices: + state = self.local_transform_state.get(local_idx) + if not state: + continue + acc += state.get("pivot", Vec3(0, 0, 0)) + state.get("offset", Vec3(0, 0, 0)) + valid += 1 + if valid == 0: + return Vec3(0, 0, 0) + return acc / float(valid) + + def begin_transform_session(self, local_indices): + """Create immutable baseline snapshot for one gizmo drag session.""" + if not local_indices: + return {"locals": {}} + + locals_snapshot = {} + for local_idx in local_indices: + base_state = self.local_transform_state.get(local_idx) + if not base_state: + continue + entries = self.vertex_index.get(local_idx, []) + base_positions = self.local_transform_base_positions.get(local_idx, []) + locals_snapshot[local_idx] = { + "offset": Vec3(base_state["offset"]), + "quat": Quat(base_state["quat"]), + "scale": Vec3(base_state["scale"]), + "pivot": Vec3(base_state["pivot"]), + "entries": entries, + "base_positions": base_positions, + } + return {"locals": locals_snapshot} + + def apply_transform_session(self, snapshot, delta_pos, delta_quat, delta_scale): + """Apply transform delta to all local indices in snapshot and rewrite vertices.""" + import numpy as np + + if not snapshot or "locals" not in snapshot: + return + if delta_pos is None: + delta_pos = Vec3(0, 0, 0) + if delta_quat is None: + delta_quat = Quat.identQuat() + if delta_scale is None: + delta_scale = Vec3(1, 1, 1) + + dscale = np.array([delta_scale.x, delta_scale.y, delta_scale.z], dtype=np.float32) + dpos = np.array([delta_pos.x, delta_pos.y, delta_pos.z], dtype=np.float32) + + for local_idx, local_data in snapshot["locals"].items(): + base_offset = local_data["offset"] + base_quat = local_data["quat"] + base_scale = local_data["scale"] + pivot = local_data["pivot"] + + final_offset = Vec3(base_offset) + delta_pos + final_quat = Quat(delta_quat * base_quat) + final_scale = Vec3( + base_scale.x * delta_scale.x, + base_scale.y * delta_scale.y, + base_scale.z * delta_scale.z, + ) + rot_mat = self._quat_to_np_mat3(final_quat) + + self.local_transform_state[local_idx]["offset"] = final_offset + self.local_transform_state[local_idx]["quat"] = final_quat + self.local_transform_state[local_idx]["scale"] = final_scale + self.position_offsets[local_idx] = final_offset + + pivot_np = np.array([pivot.x, pivot.y, pivot.z], dtype=np.float32) + base_s = np.array([base_scale.x, base_scale.y, base_scale.z], dtype=np.float32) + total_scale = base_s * dscale + total_offset = np.array([base_offset.x, base_offset.y, base_offset.z], dtype=np.float32) + dpos + + entries = local_data["entries"] + base_positions = local_data["base_positions"] + for i, (gn_np, gi, rows) in enumerate(entries): + if i >= len(base_positions): + continue + orig_pos = base_positions[i] + if orig_pos is None or len(orig_pos) == 0: + continue + centered = orig_pos - pivot_np + scaled = centered * total_scale + rotated = scaled @ rot_mat.T + new_pos = rotated + pivot_np + total_offset + + gnode = gn_np.node() + geom = gnode.modify_geom(gi) + vdata = geom.modify_vertex_data() + writer = GeomVertexWriter(vdata, "vertex") + + for j in range(len(rows)): + writer.set_row(int(rows[j])) + writer.set_data3f(float(new_pos[j, 0]), float(new_pos[j, 1]), float(new_pos[j, 2])) + + def _quat_to_np_mat3(self, quat): + """Convert Panda3D Quat to 3x3 numpy rotation matrix.""" + import numpy as np + q = Quat(quat) + q.normalize() + w = float(q.getR()) + x = float(q.getI()) + y = float(q.getJ()) + z = float(q.getK()) + + xx = x * x + yy = y * y + zz = z * z + xy = x * y + xz = x * z + yz = y * z + wx = w * x + wy = w * y + wz = w * z + + return np.array([ + [1.0 - 2.0 * (yy + zz), 2.0 * (xy - wz), 2.0 * (xz + wy)], + [2.0 * (xy + wz), 1.0 - 2.0 * (xx + zz), 2.0 * (yz - wx)], + [2.0 * (xz - wy), 2.0 * (yz + wx), 1.0 - 2.0 * (xx + yy)], + ], dtype=np.float32) + + def create_ssbo(self): + """No SSBO needed — using RP default rendering.""" + return None + + def move_object(self, global_id, delta): + """ + Move an object by modifying vertex positions directly. + delta: Vec3 translation to apply. + Uses numpy for batch vertex updates. + """ + import numpy as np + + if global_id not in self.id_to_chunk: + return + + _, local_idx = self.id_to_chunk[global_id] + + if local_idx not in self.vertex_index: + return + + # Accumulate offset + self.position_offsets[local_idx] = self.position_offsets.get(local_idx, Vec3(0)) + delta + offset = self.position_offsets[local_idx] + offset_arr = np.array([offset.x, offset.y, offset.z], dtype=np.float32) + + # Update each (geom_node, geom_idx, rows) group + entries = self.vertex_index[local_idx] + originals = self.original_positions[local_idx] + + for i, (gn_np, gi, rows) in enumerate(entries): + orig_pos = originals[i] # numpy array (N, 3) + new_pos = orig_pos + offset_arr # vectorized add + + gnode = gn_np.node() + geom = gnode.modify_geom(gi) + vdata = geom.modify_vertex_data() + writer = GeomVertexWriter(vdata, "vertex") + + for j in range(len(rows)): + writer.set_row(int(rows[j])) + writer.set_data3f(float(new_pos[j, 0]), float(new_pos[j, 1]), float(new_pos[j, 2])) + + def get_world_pos(self, global_id): + """Get current world position of an object.""" + if global_id not in self.id_to_chunk: + return Vec3(0, 0, 0) + _, local_idx = self.id_to_chunk[global_id] + + original_mat = self.global_transforms[global_id] + original_pos = original_mat.get_row3(3) + offset = self.position_offsets.get(local_idx, Vec3(0)) + + return Vec3(original_pos) + offset + + def get_object_center(self, global_id): + """Get the original center position of an object (for rotation pivot).""" + if global_id >= len(self.global_transforms): + return Vec3(0, 0, 0) + mat = self.global_transforms[global_id] + return Vec3(mat.get_row3(3)) + + def get_transform(self, global_id): + """Get original transform.""" + if global_id >= len(self.global_transforms): + return LMatrix4f.ident_mat() + return self.global_transforms[global_id] + + @property + def transforms(self): + return self.global_transforms diff --git a/ssbo_component/backup_merge_20260225_125202/ssbo_editor.py.bak b/ssbo_component/backup_merge_20260225_125202/ssbo_editor.py.bak new file mode 100644 index 00000000..e82948f2 --- /dev/null +++ b/ssbo_component/backup_merge_20260225_125202/ssbo_editor.py.bak @@ -0,0 +1,617 @@ + +import sys +import os +import struct +import time +from panda3d.core import ( + Filename, loadPrcFileData, GeomVertexFormat, + GeomVertexWriter, InternalName, Shader, Texture, SamplerState, + Vec3, Vec4, Point2, Point3, LMatrix4f, ShaderBuffer, GeomEnums, OmniBoundingVolume, Quat, + TransparencyAttrib, BoundingSphere, NodePath, + GraphicsEngine, WindowProperties, FrameBufferProperties, + GraphicsPipe, GraphicsOutput, Camera, DisplayRegion, OrthographicLens, + BoundingBox +) + +import p3dimgui.backend as p3dimgui_backend +import p3dimgui.shaders as p3dimgui_shaders +from imgui_bundle import imgui +from rpcore.effect import Effect + +# Work around p3dimgui import-order issue where backend may import an unrelated +# top-level "shaders" module and miss these globals. +if not hasattr(p3dimgui_backend, "VERT_SHADER"): + p3dimgui_backend.VERT_SHADER = p3dimgui_shaders.VERT_SHADER +if not hasattr(p3dimgui_backend, "FRAG_SHADER"): + p3dimgui_backend.FRAG_SHADER = p3dimgui_shaders.FRAG_SHADER + +ImGuiBackend = p3dimgui_backend.ImGuiBackend + +from .ssbo_controller import ObjectController + +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 + # Picking resources may be created later when a model is loaded. + self.pick_buffer = None + self.pick_texture = None + self.pick_cam = None + self.pick_cam_np = None + self.pick_lens = 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._ssbo_transform_active = False + self._ssbo_selected_local_indices = [] + self._ssbo_transform_snapshot = None + self._ssbo_gizmo_proxy = None + self._ssbo_proxy_start = {"pos": None, "quat": None, "scale": None} + self._bound_transform_gizmo = None + + # 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 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 — NO custom shader, uses RP default rendering.""" + print(f"[SSBOEditor] Loading model: {model_path}") + fn = Filename.fromOsSpecific(model_path) + self.model = self.base.loader.loadModel(fn) + + self.controller = ObjectController() + count = self.controller.bake_ids_and_collect(self.model) + self._ssbo_transform_active = False + self._ssbo_selected_local_indices = [] + self._ssbo_transform_snapshot = None + self._cleanup_ssbo_proxy() + + self.model.reparent_to(self.base.render) + + # 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() + + 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_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 = os.path.join(current_dir, "shaders", "pick_id.vert") + pick_frag = os.path.join(current_dir, "shaders", "pick_id.frag") + + pick_vert = Filename.fromOsSpecific(pick_vert).getFullpath() + pick_frag = Filename.fromOsSpecific(pick_frag).getFullpath() + + try: + pick_shader = Shader.load( + Shader.SL_GLSL, + pick_vert, + pick_frag + ) + self.pick_cam.set_scene(self.model) + 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 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.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) + + self.pick_buffer.set_active(True) + self.base.graphicsEngine.render_frame() + self.pick_buffer.set_active(False) + self.base.graphicsEngine.extract_texture_data( + self.pick_texture, self.base.win.get_gsg() + ) + + 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: + 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 True + + self.selected_name = None + self.selected_ids = [] + return False + + + def on_mouse_click(self): + io = imgui.get_io() + if io.want_capture_mouse: + return + if self.base.mouseWatcherNode.has_mouse(): + mpos = self.base.mouseWatcherNode.get_mouse() + # If clicking gizmo, skip SSBO pick. + if self._try_start_gizmo_drag(mpos.x, mpos.y): + return + prev_selected = self.selected_name + hit = self.pick_object(mpos.x, mpos.y) + # SSBO miss must clear current selection. + if not hit: + self._sync_selection_none() + # Always fallback to legacy ray pick when SSBO misses. + # This keeps scene selection usable if SSBO ID mapping is incomplete. + self._fallback_legacy_pick(mpos.x, mpos.y) + elif prev_selected != self.selected_name: + # Ensure selection visuals refresh when SSBO selection changes. + self._sync_selection_from_key(self.selected_name) + + def toggle_debug(self): + self.debug_mode = not self.debug_mode + + def clear_selection(self): + pass # No selection mask texture needed without custom shader + + def update_selection_mask(self): + pass # No selection mask texture needed without custom shader + + def select_node(self, key): + if not self.controller or key not in self.controller.name_to_ids: + return + self.selected_name = key + self.selected_ids = self.controller.name_to_ids.get(key, []) + self._sync_selection_from_key(key) + + def _sync_selection_from_key(self, key): + """Sync SSBO picked key to legacy SelectionSystem.""" + try: + if hasattr(self.base, "selection") and self.base.selection: + kind, target = self._resolve_ssbo_selection_target(key) + if kind == "proxy": + target_np = target + else: + target_np = target if target is not None else self.model + if target_np is None or target_np.isEmpty(): + target_np = self.model + self.base.selection.updateSelection(target_np) + except Exception as e: + print(f"[SSBOEditor] selection sync failed: {e}") + + def _sync_selection_none(self): + """Clear legacy SelectionSystem selection.""" + try: + self._ssbo_transform_active = False + self._ssbo_selected_local_indices = [] + self._ssbo_transform_snapshot = None + self._cleanup_ssbo_proxy() + if hasattr(self.base, "selection") and self.base.selection: + self.base.selection.updateSelection(None) + except Exception as e: + print(f"[SSBOEditor] clear selection sync failed: {e}") + + def bind_transform_gizmo(self, transform_gizmo): + """Bind TransformGizmo drag hooks so SSBO sub-object transforms can follow gizmo.""" + self._bound_transform_gizmo = transform_gizmo + if not transform_gizmo: + return + hooks = { + "move": { + "drag_start": [self._on_ssbo_gizmo_drag_start], + "drag_move": [self._on_ssbo_gizmo_drag_move], + "drag_end": [self._on_ssbo_gizmo_drag_end], + }, + "rotate": { + "drag_start": [self._on_ssbo_gizmo_drag_start], + "drag_move": [self._on_ssbo_gizmo_drag_move], + "drag_end": [self._on_ssbo_gizmo_drag_end], + }, + "scale": { + "drag_start": [self._on_ssbo_gizmo_drag_start], + "drag_move": [self._on_ssbo_gizmo_drag_move], + "drag_end": [self._on_ssbo_gizmo_drag_end], + }, + } + try: + if hasattr(transform_gizmo, "set_event_hooks"): + transform_gizmo.set_event_hooks(hooks, replace=False) + print("[SSBOEditor] TransformGizmo hooks bound") + except Exception as e: + print(f"[SSBOEditor] bind transform gizmo failed: {e}") + + def _resolve_ssbo_selection_target(self, key): + """Resolve selected SSBO key to proxy node (preferred) or regular node.""" + self._ssbo_transform_active = False + self._ssbo_transform_snapshot = None + self._ssbo_selected_local_indices = [] + + if not self.controller or not key: + return "node", self.model + global_ids = self.controller.name_to_ids.get(key, []) + local_indices = self.controller.get_local_indices_from_global_ids(global_ids) + self._ssbo_selected_local_indices = local_indices + if local_indices: + print(f"[SSBOEditor] selection locals={len(local_indices)} key={key}") + center = self.controller.get_selection_center(local_indices) + proxy = self._ensure_ssbo_proxy(center) + return "proxy", proxy + target_np = self.controller.key_to_node.get(key) + if target_np is None or target_np.isEmpty(): + target_np = self.model + return "node", target_np + + def _ensure_ssbo_proxy(self, center): + if self._ssbo_gizmo_proxy is None or self._ssbo_gizmo_proxy.isEmpty(): + self._ssbo_gizmo_proxy = self.base.render.attach_new_node("ssbo_transform_proxy") + self._ssbo_gizmo_proxy.setTag("is_ssbo_proxy", "1") + self._ssbo_gizmo_proxy.set_pos(center) + self._ssbo_gizmo_proxy.set_hpr(0, 0, 0) + self._ssbo_gizmo_proxy.set_scale(1, 1, 1) + return self._ssbo_gizmo_proxy + + def _cleanup_ssbo_proxy(self): + if self._ssbo_gizmo_proxy and not self._ssbo_gizmo_proxy.isEmpty(): + self._ssbo_gizmo_proxy.removeNode() + self._ssbo_gizmo_proxy = None + + def _on_ssbo_gizmo_drag_start(self, payload): + try: + target = payload.get("target") if payload else None + if not target or target != self._ssbo_gizmo_proxy: + self._ssbo_transform_active = False + return + if not self.controller or not self._ssbo_selected_local_indices: + self._ssbo_transform_active = False + return + self._ssbo_transform_snapshot = self.controller.begin_transform_session( + self._ssbo_selected_local_indices + ) + self._ssbo_proxy_start = { + "pos": Vec3(target.getPos(self.base.render)), + "quat": Quat(target.getQuat(self.base.render)), + "scale": Vec3(target.getScale()), + } + self._ssbo_transform_active = True + print(f"[SSBOEditor] drag_start locals={len(self._ssbo_selected_local_indices)}") + except Exception as e: + self._ssbo_transform_active = False + print(f"[SSBOEditor] drag_start bridge failed: {e}") + + def _on_ssbo_gizmo_drag_move(self, payload): + try: + if not self._ssbo_transform_active: + return + target = payload.get("target") if payload else None + if not target or target != self._ssbo_gizmo_proxy: + return + start_pos = self._ssbo_proxy_start.get("pos") + start_quat = self._ssbo_proxy_start.get("quat") + start_scale = self._ssbo_proxy_start.get("scale") + if start_pos is None or start_quat is None or start_scale is None: + return + + curr_pos = Vec3(target.getPos(self.base.render)) + curr_quat = Quat(target.getQuat(self.base.render)) + curr_scale = Vec3(target.getScale()) + + delta_pos = curr_pos - start_pos + inv_start_quat = Quat(start_quat) + inv_start_quat.invertInPlace() + delta_quat = curr_quat * inv_start_quat + delta_scale = Vec3( + curr_scale.x / start_scale.x if abs(start_scale.x) > 1e-8 else 1.0, + curr_scale.y / start_scale.y if abs(start_scale.y) > 1e-8 else 1.0, + curr_scale.z / start_scale.z if abs(start_scale.z) > 1e-8 else 1.0, + ) + + self.controller.apply_transform_session( + self._ssbo_transform_snapshot, + delta_pos, + delta_quat, + delta_scale, + ) + except Exception as e: + print(f"[SSBOEditor] drag_move bridge failed: {e}") + + def _on_ssbo_gizmo_drag_end(self, payload): + try: + if self._ssbo_transform_active: + print(f"[SSBOEditor] drag_end locals={len(self._ssbo_selected_local_indices)}") + self._ssbo_transform_active = False + self._ssbo_transform_snapshot = None + except Exception as e: + print(f"[SSBOEditor] drag_end bridge failed: {e}") + + def _fallback_legacy_pick(self, mx, my): + """Fallback to legacy ray picking when SSBO misses.""" + try: + if not hasattr(self.base, "event_handler") or not self.base.event_handler: + return + win_w, win_h = self.base.win.getSize() + x = (mx + 1.0) * 0.5 * win_w + y = (1.0 - my) * 0.5 * win_h + self.base.event_handler.mousePressEventLeft({"x": x, "y": y}) + except Exception as e: + print(f"[SSBOEditor] legacy fallback pick failed: {e}") + + def _try_start_gizmo_drag(self, mouse_x=None, mouse_y=None): + """Try to start gizmo drag using the existing SelectionSystem pipeline.""" + try: + new_transform = getattr(self.base, "newTransform", None) + if ( + new_transform is not None and + mouse_x is not None and + mouse_y is not None and + self._is_mouse_on_new_gizmo(new_transform, mouse_x, mouse_y) + ): + return True + selection = getattr(self.base, "selection", None) + if not selection or not selection.gizmo: + return False + win_w, win_h = self.base.win.getSize() + mpos = self.base.mouseWatcherNode.get_mouse() + x = (mpos.x + 1.0) * 0.5 * win_w + y = (1.0 - mpos.y) * 0.5 * win_h + + axis = selection.gizmoHighlightAxis or selection.checkGizmoClick(x, y) + if axis: + selection.startGizmoDrag(axis, x, y) + return True + except Exception as e: + print(f"[SSBOEditor] gizmo drag start failed: {e}") + return False + + def _is_mouse_on_new_gizmo(self, new_transform, mouse_x, mouse_y): + """Refresh and query hover state for TransformGizmo on current click position.""" + try: + mouse_pos = Point3(mouse_x, mouse_y, 0.0) + for gizmo_name in ("move_gizmo", "rotate_gizmo", "scale_gizmo"): + gizmo = getattr(new_transform, gizmo_name, None) + if not gizmo or not getattr(gizmo, "attached", False): + continue + hover_updater = getattr(gizmo, "_update_hover_highlight", None) + if callable(hover_updater): + hover_updater(mouse_pos) + return bool(getattr(new_transform, "is_hovering", False)) + except Exception as e: + print(f"[SSBOEditor] new gizmo hover check failed: {e}") + return False + + 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: + self.last_search_text = self.search_text + search_lower = self.search_text.lower() + self.filtered_nodes = [] + for key in self.controller.node_list: + display = self.controller.display_names.get(key, key.split('/')[-1]) + if not search_lower or (search_lower in display.lower() or search_lower in key.lower()): + geom_count = len(self.controller.name_to_ids.get(key, [])) + self.filtered_nodes.append((key, display, geom_count)) + + # If list is empty initially (no search), show all + if not self.search_text and not self.filtered_nodes: + if len(self.filtered_nodes) != len(self.controller.node_list): + self.filtered_nodes = [(k, self.controller.display_names.get(k, k), len(self.controller.name_to_ids.get(k,[]))) for k in self.controller.node_list] + + 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, display, geom_count = self.filtered_nodes[i] + label = f"{display} ({geom_count})" + 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: + imgui.text_colored((1, 0.8, 0.2, 1), f"Selected: {self.selected_name}") + 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() + + 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: + for idx in self.selected_ids: + self.controller.move_object(idx, acc) + + return task.cont diff --git a/ssbo_component/ssbo_controller.py b/ssbo_component/ssbo_controller.py index 04ea04be..35844e51 100644 --- a/ssbo_component/ssbo_controller.py +++ b/ssbo_component/ssbo_controller.py @@ -44,6 +44,34 @@ class ObjectController: self.model = None self.pick_model = None self.chunk_node = None # Single chunk node + self._source_model_name = "" + self._source_model_stem = "" + + def _build_original_hierarchy_key(self, np, model_root): + """Capture hierarchy path before flatten/reparent.""" + parts = [] + cur = np + while cur and not cur.is_empty() and cur != model_root: + name = cur.get_name() or "" + if name: + parts.append(name) + cur = cur.get_parent() + parts.reverse() + if not parts: + return np.get_name() or "Unnamed" + return "/".join(parts) + + def _is_wrapper_segment(self, segment): + s = (segment or "").strip().lower() + if not s: + return True + if s in ("root",): + return True + if self._source_model_name and s == self._source_model_name: + return True + if self._source_model_stem and s == self._source_model_stem: + return True + return False def bake_ids_and_collect(self, model): """ @@ -74,6 +102,9 @@ class ObjectController: self.virtual_tree = None self.virtual_tree_meta = None self.pick_model = None + model_name = (model.get_name() or "").strip() + self._source_model_name = model_name.lower() + self._source_model_stem = model_name.rsplit(".", 1)[0].lower() if "." in model_name else model_name.lower() global_id_counter = 0 chunk_key = model.get_name() or "default" @@ -82,6 +113,11 @@ class ObjectController: self.chunk_node = model self.chunks[chunk_key] = {'node': model, 'base_id': 0} + # Cache original hierarchy path BEFORE flatten/reparent. + original_keys = {} + for np in geom_nodes: + original_keys[id(np)] = self._build_original_hierarchy_key(np, model) + # Flatten hierarchy for np in geom_nodes: np.wrt_reparent_to(model) @@ -99,7 +135,7 @@ class ObjectController: np = new_np gnode = np.node() - unique_key = str(np) + unique_key = original_keys.get(id(np), str(np)) display_name = np.get_name() or f"Object_{global_id_counter}" if unique_key not in self.name_to_ids: @@ -191,6 +227,8 @@ class ObjectController: "children": {}, "leaf_key": None, "display_name": "", + "group_key": None, + "aggregate_ids": [], } max_depth = 0 leaf_count = 0 @@ -199,6 +237,9 @@ class ObjectController: if not key: continue parts = [p for p in str(key).split("/") if p] + # Hide importer wrapper roots (e.g. model filename / ROOT) but keep real object hierarchy. + while len(parts) > 1 and self._is_wrapper_segment(parts[0]): + parts = parts[1:] if not parts: continue max_depth = max(max_depth, len(parts)) @@ -214,6 +255,8 @@ class ObjectController: "children": {}, "leaf_key": None, "display_name": part, + "group_key": None, + "aggregate_ids": [], } cursor["children"][part] = child cursor = child @@ -222,6 +265,32 @@ class ObjectController: cursor["display_name"] = self.display_names.get(key, part) leaf_count += 1 + # Build aggregate id groups for non-leaf selection (parent moves children). + def _aggregate(node): + agg = [] + leaf_key = node.get("leaf_key") + if leaf_key: + agg.extend(self.name_to_ids.get(leaf_key, [])) + for child in node.get("children", {}).values(): + agg.extend(_aggregate(child)) + # Stable unique ids + uniq = [] + seen = set() + for gid in agg: + if gid in seen: + continue + seen.add(gid) + uniq.append(gid) + node["aggregate_ids"] = uniq + if node.get("path") and uniq: + group_key = f"__group__::{node['path']}" + node["group_key"] = group_key + self.name_to_ids[group_key] = uniq + self.display_names[group_key] = node.get("display_name", node.get("name", "")) + return uniq + + _aggregate(root) + self.virtual_tree = root self.virtual_tree_meta = {"max_depth": max_depth, "leaf_count": leaf_count} return root diff --git a/ui/panels/editor_panels.py b/ui/panels/editor_panels.py index a8c5ce94..6aaf1837 100644 --- a/ui/panels/editor_panels.py +++ b/ui/panels/editor_panels.py @@ -528,6 +528,7 @@ class EditorPanels: path = tree_node.get("path", "") display = tree_node.get("display_name") or tree_node.get("name") or path leaf_key = tree_node.get("leaf_key") + group_key = tree_node.get("group_key") children = tree_node.get("children", {}) or {} label = f"{display}##{unique_id_prefix}_{path}" @@ -543,8 +544,19 @@ class EditorPanels: # Non-leaf: tree node only for hierarchy display. opened = imgui.tree_node(label) + # Clicking non-leaf row selects its aggregate group so parent transform affects children. + if group_key and imgui.is_item_clicked(0): + ssbo_editor.select_node(group_key) + if hasattr(self.app, "lui_manager"): + self.app.lui_manager.selected_index = -1 if opened: # If this node is also a selectable leaf, render selectable entry first. + if group_key: + is_group_selected = (getattr(ssbo_editor, "selected_name", None) == group_key) + if imgui.selectable(f"[整体] {display}##group_{unique_id_prefix}_{path}", is_group_selected)[0]: + ssbo_editor.select_node(group_key) + if hasattr(self.app, "lui_manager"): + self.app.lui_manager.selected_index = -1 if leaf_key: is_selected = (getattr(ssbo_editor, "selected_name", None) == leaf_key) if imgui.selectable(f"[节点] {display}##leaf_{unique_id_prefix}_{path}", is_selected)[0]: @@ -896,13 +908,34 @@ class EditorPanels: self.app.lui_manager, lui_selected_index ) - return + return # --- Scene Node Properties --- # 获取当前选中的节点 selected_node = None if hasattr(self.app, 'selection') and self.app.selection and hasattr(self.app.selection, 'selectedNode'): selected_node = self.app.selection.selectedNode + + # SSBO mode may select a proxy node for gizmo operations. + # Resolve proxy back to a real scene node so property panel stays meaningful. + try: + if (selected_node and not selected_node.isEmpty() and + selected_node.hasTag("is_ssbo_proxy")): + ssbo_editor = getattr(self.app, "ssbo_editor", None) + controller = getattr(ssbo_editor, "controller", None) if ssbo_editor else None + if ssbo_editor and controller: + resolved = None + if getattr(ssbo_editor, "selected_ids", None): + first_gid = ssbo_editor.selected_ids[0] + key = controller.id_to_name.get(first_gid) + if key: + resolved = controller.key_to_node.get(key) + if (resolved is None or resolved.isEmpty()) and getattr(ssbo_editor, "selected_name", None): + resolved = controller.key_to_node.get(ssbo_editor.selected_name) + if resolved and not resolved.isEmpty(): + selected_node = resolved + except Exception: + pass if selected_node and not selected_node.isEmpty(): self._draw_node_properties(selected_node)