Merge remote-tracking branch 'origin/geng' into IMgui_hu

# Conflicts:
#	imgui.ini
#	ssbo_component/ssbo_controller.py
#	ssbo_component/ssbo_editor.py
This commit is contained in:
Hector 2026-02-28 09:37:33 +08:00
commit 2ac08b0582
5 changed files with 921 additions and 432 deletions

View File

@ -24,26 +24,26 @@ Size=832,45
Collapsed=0
[Window][工具栏]
Pos=391,20
Size=653,32
Pos=278,20
Size=1295,32
Collapsed=0
DockId=0x0000000D,0
[Window][场景树]
Pos=0,20
Size=389,468
Size=276,622
Collapsed=0
DockId=0x00000007,0
[Window][属性面板]
Pos=1046,20
Size=334,730
Pos=1575,20
Size=345,971
Collapsed=0
DockId=0x00000003,0
[Window][控制台]
Pos=0,490
Size=389,260
Pos=0,644
Size=276,347
Collapsed=0
DockId=0x00000008,0
@ -60,7 +60,7 @@ Collapsed=0
[Window][WindowOverViewport_11111111]
Pos=0,20
Size=1380,730
Size=1920,971
Collapsed=0
[Window][测试窗口1]
@ -84,7 +84,7 @@ Size=400,300
Collapsed=0
[Window][选择路径]
Pos=660,254
Pos=660,245
Size=600,500
Collapsed=0
@ -94,13 +94,13 @@ Size=500,400
Collapsed=0
[Window][导入模型]
Pos=660,254
Pos=660,245
Size=600,500
Collapsed=0
[Window][资源管理器]
Pos=391,405
Size=653,345
Pos=278,657
Size=1295,334
Collapsed=0
DockId=0x00000006,0
@ -150,8 +150,8 @@ Size=101,226
Collapsed=0
[Window][LUI编辑器]
Pos=1586,399
Size=334,610
Pos=1628,412
Size=292,597
Collapsed=0
DockId=0x00000004,0
@ -200,23 +200,18 @@ Pos=660,304
Size=600,400
Collapsed=0
[Window][Web面板]
Pos=474,121
Size=942,580
Collapsed=0
[Docking][Data]
DockSpace ID=0x08BD597D Window=0x1BBC0F80 Pos=0,20 Size=1380,730 Split=X
DockNode ID=0x00000001 Parent=0x08BD597D SizeRef=1584,989 Split=X
DockNode ID=0x00000009 Parent=0x00000001 SizeRef=389,989 Split=Y Selected=0xE0015051
DockSpace ID=0x08BD597D Window=0x1BBC0F80 Pos=0,20 Size=1920,971 Split=X
DockNode ID=0x00000001 Parent=0x08BD597D SizeRef=1573,989 Split=X
DockNode ID=0x00000009 Parent=0x00000001 SizeRef=276,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=1193,989 Split=Y
DockNode ID=0x0000000A Parent=0x00000001 SizeRef=755,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,608 CentralNode=1
DockNode ID=0x00000006 Parent=0x0000000E SizeRef=1341,345 Selected=0x3A2E05C3
DockNode ID=0x00000002 Parent=0x08BD597D SizeRef=334,989 Split=Y Selected=0x3188AB8D
DockNode ID=0x00000003 Parent=0x00000002 SizeRef=351,377 Selected=0x5DB6FF37
DockNode ID=0x00000004 Parent=0x00000002 SizeRef=351,610 Selected=0x1EB923B7
DockNode ID=0x0000000E Parent=0x0000000A SizeRef=1318,937 Split=Y
DockNode ID=0x00000005 Parent=0x0000000E SizeRef=1341,601 CentralNode=1
DockNode ID=0x00000006 Parent=0x0000000E SizeRef=1341,334 Selected=0x3A2E05C3
DockNode ID=0x00000002 Parent=0x08BD597D SizeRef=345,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

View File

@ -1,39 +1,34 @@

import math
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.
混合架构控制器 (Chunked Static + Dynamic Editing)
================================================
- 默认: 每个 chunk 使用 flatten 后的静态表示
- 编辑: 被选中对象所属 chunk 切换为动态表示直接改 NodePath 变换
- 提交: 离开 chunk 时仅重建该 chunk 的静态表示
"""
def __init__(self):
def __init__(self, chunk_size=64, chunk_world_size=40.0):
self.chunk_size = max(8, int(chunk_size))
self.chunk_world_size = max(8.0, float(chunk_world_size))
self._reset_state()
def _reset_state(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.global_transforms = []
self.position_offsets = {}
self.local_to_global_id = {}
self.local_transform_state = {}
@ -41,12 +36,57 @@ class ObjectController:
self.pick_vertex_index = {}
self.virtual_tree = None
self.virtual_tree_meta = None
self.model = None
self.pick_model = None
self.chunk_node = None # Single chunk node
self._source_model_name = ""
self._source_model_stem = ""
self.id_to_chunk = {} # global_id -> chunk_id
self.id_to_object_np = {} # global_id -> dynamic object nodepath
self.id_to_pick_np = {} # global_id -> pick-scene nodepath
# chunk_id -> {
# "dynamic_np": NodePath,
# "static_np": NodePath or None,
# "members": [global_id],
# "dirty": bool,
# "dynamic_enabled": bool
# }
self.chunks = {}
self.active_chunks = set()
self._next_chunk_id = 0
# spatial cell key -> [chunk_id, ...]
self._cell_to_chunks = {}
# UI hierarchy metadata (matches source model parent/child structure)
self.tree_root_key = None
self.tree_nodes = {}
self._path_to_tree_key = {}
def _register_tree_node(self, key, display_name, parent_key):
self.tree_nodes[key] = {
"name": display_name,
"parent": parent_key,
"children": [],
"local_ids": [],
}
self.display_names[key] = display_name
self.name_to_ids[key] = []
if parent_key is not None and parent_key in self.tree_nodes:
self.tree_nodes[parent_key]["children"].append(key)
def _build_scene_tree(self, root_np):
"""Capture source model hierarchy for UI (independent from render batching)."""
self.tree_root_key = "0"
def walk(np, parent_key, key):
display_name = np.get_name() or "Unnamed"
self._register_tree_node(key, display_name, parent_key)
self._path_to_tree_key[str(np)] = key
children = list(np.get_children())
for i, child in enumerate(children):
walk(child, key, f"{key}/{i}")
walk(root_np, None, self.tree_root_key)
def _get_model_world_mat(self):
"""Return current model net transform matrix (to top/root)."""
@ -160,6 +200,71 @@ class ObjectController:
if not parts:
return np.get_name() or "Unnamed"
return "/".join(parts)
def _aggregate_tree_ids(self, key):
node = self.tree_nodes[key]
agg_ids = list(node["local_ids"])
for child_key in node["children"]:
agg_ids.extend(self._aggregate_tree_ids(child_key))
self.name_to_ids[key] = agg_ids
return agg_ids
def _build_tree_preorder(self, key, out):
out.append(key)
for child_key in self.tree_nodes[key]["children"]:
self._build_tree_preorder(child_key, out)
def should_hide_tree_node(self, key):
"""
Hide a redundant wrapper node directly below the file root, e.g. ROOT.
This keeps `model.glb` as the visible root in the UI.
"""
node = self.tree_nodes.get(key)
if not node:
return False
if node["parent"] != self.tree_root_key:
return False
name = (node["name"] or "").strip().lower()
if name != "root":
return False
# Keep node visible if it actually carries direct geoms.
if node["local_ids"]:
return False
return len(node["children"]) > 0
def _encode_id_color(self, vdata, object_id):
if not vdata.has_column("color"):
new_fmt = vdata.get_format().get_union_format(GeomVertexFormat.get_v3c4())
vdata.set_format(new_fmt)
low = object_id & 0xFF
high = (object_id >> 8) & 0xFF
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)
def _ensure_chunk(self, root_np, chunk_id):
if chunk_id in self.chunks:
return self.chunks[chunk_id]
dynamic_np = root_np.attach_new_node(f"chunk_{chunk_id:04d}_dynamic")
dynamic_np.stash()
chunk_data = {
"dynamic_np": dynamic_np,
"static_np": None,
"members": [],
"dirty": False,
"dynamic_enabled": False,
}
self.chunks[chunk_id] = chunk_data
return chunk_data
def _is_wrapper_segment(self, segment):
s = (segment or "").strip().lower()
@ -206,14 +311,14 @@ class ObjectController:
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"
# 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}
# Cache original hierarchy path BEFORE flatten/reparent.
original_keys = {}
for np in geom_nodes:
@ -222,12 +327,12 @@ class ObjectController:
# 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():
@ -235,57 +340,57 @@ class ObjectController:
np.detach_node()
np = new_np
gnode = np.node()
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:
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()
@ -321,6 +426,114 @@ class ObjectController:
writer.set_row(row)
writer.set_data4f(r, g, b, a)
def _build_tree_preorder(self, key, out):
out.append(key)
for child_key in self.tree_nodes[key]["children"]:
self._build_tree_preorder(child_key, out)
def should_hide_tree_node(self, key):
"""
Hide a redundant wrapper node directly below the file root, e.g. ROOT.
This keeps `model.glb` as the visible root in the UI.
"""
node = self.tree_nodes.get(key)
if not node:
return False
if node["parent"] != self.tree_root_key:
return False
name = (node["name"] or "").strip().lower()
if name != "root":
return False
# Keep node visible if it actually carries direct geoms.
if node["local_ids"]:
return False
return len(node["children"]) > 0
def _encode_id_color(self, vdata, object_id):
if not vdata.has_column("color"):
new_fmt = vdata.get_format().get_union_format(GeomVertexFormat.get_v3c4())
vdata.set_format(new_fmt)
low = object_id & 0xFF
high = (object_id >> 8) & 0xFF
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)
def _ensure_chunk(self, root_np, chunk_id):
if chunk_id in self.chunks:
return self.chunks[chunk_id]
dynamic_np = root_np.attach_new_node(f"chunk_{chunk_id:04d}_dynamic")
dynamic_np.stash()
chunk_data = {
"dynamic_np": dynamic_np,
"static_np": None,
"members": [],
"dirty": False,
"dynamic_enabled": False,
}
self.chunks[chunk_id] = chunk_data
return chunk_data
def _get_cell_key_from_pos(self, pos):
inv = 1.0 / self.chunk_world_size
return (
int(math.floor(pos.x * inv)),
int(math.floor(pos.y * inv)),
int(math.floor(pos.z * inv)),
)
def _allocate_spatial_chunk(self, root_np, world_pos):
"""
Allocate object into a spatially-local chunk for better frustum culling.
Objects in the same world cell are grouped together, and overflow creates
another chunk for that same cell.
"""
cell_key = self._get_cell_key_from_pos(world_pos)
chunk_ids = self._cell_to_chunks.setdefault(cell_key, [])
for chunk_id in chunk_ids:
chunk = self.chunks.get(chunk_id)
if chunk and len(chunk["members"]) < self.chunk_size:
return chunk_id, chunk
chunk_id = self._next_chunk_id
self._next_chunk_id += 1
chunk_ids.append(chunk_id)
return chunk_id, self._ensure_chunk(root_np, chunk_id)
def _rebuild_static_chunk(self, chunk_id):
chunk = self.chunks.get(chunk_id)
if not chunk:
return
old_static = chunk.get("static_np")
if old_static and not old_static.is_empty():
old_static.remove_node()
static_np = chunk["dynamic_np"].copy_to(self.model)
static_np.set_name(f"chunk_{chunk_id:04d}_static")
static_np.unstash()
static_np.flatten_strong()
chunk["static_np"] = static_np
chunk["dirty"] = False
# Keep visibility coherent with current mode after rebuild.
if chunk["dynamic_enabled"]:
static_np.stash()
else:
static_np.unstash()
def build_virtual_hierarchy(self):
"""Build a readonly virtual tree from node_list path keys."""
root = {
@ -505,6 +718,124 @@ class ObjectController:
self.vertex_index[uid].append((gn_np, gi, rows))
self.original_positions[uid].append(pos.copy())
def _set_chunk_dynamic(self, chunk_id, enabled):
chunk = self.chunks.get(chunk_id)
if not chunk:
return
if enabled:
if chunk["dynamic_enabled"]:
return
chunk["dynamic_np"].unstash()
if chunk["static_np"] and not chunk["static_np"].is_empty():
chunk["static_np"].stash()
chunk["dynamic_enabled"] = True
self.active_chunks.add(chunk_id)
return
if not chunk["dynamic_enabled"]:
return
if chunk["static_np"] and not chunk["static_np"].is_empty():
chunk["static_np"].unstash()
chunk["dynamic_np"].stash()
chunk["dynamic_enabled"] = False
self.active_chunks.discard(chunk_id)
def set_active_ids(self, active_ids):
"""切换编辑激活集合,仅保留 active_ids 对应 chunk 为动态模式。"""
target_chunks = {self.id_to_chunk[obj_id] for obj_id in active_ids if obj_id in self.id_to_chunk}
# Demote no-longer-active chunks. Dirty chunks are re-baked before demotion.
for chunk_id in list(self.active_chunks):
if chunk_id in target_chunks:
continue
if self.chunks[chunk_id]["dirty"]:
self._rebuild_static_chunk(chunk_id)
self._set_chunk_dynamic(chunk_id, False)
# Promote target chunks.
for chunk_id in target_chunks:
self._set_chunk_dynamic(chunk_id, True)
def bake_ids_and_collect(self, model):
"""
构建混合架构:
1) 把每个 geom 拆成可独立编辑的动态对象
2) chunk 生成 flatten 后的静态副本
"""
t0 = time.time()
self._reset_state()
geom_nodes = list(model.find_all_matches("**/+GeomNode"))
print(f"[控制器] 找到 {len(geom_nodes)} 个 GeomNode")
# Build hierarchy metadata first so UI can mirror source model tree.
self._build_scene_tree(model)
root_name = model.get_name() or "scene"
scene_root = NodePath(root_name)
pick_root = NodePath(root_name + "_pick")
self.model = scene_root
self.pick_model = pick_root
global_id = 0
for np in geom_nodes:
gnode = np.node()
owner_key = self._path_to_tree_key.get(str(np), self.tree_root_key)
world_mat = LMatrix4f(np.get_mat(model))
for gi in range(gnode.get_num_geoms()):
# Render geometry stays untouched (keep original material/color behavior).
render_geom = gnode.get_geom(gi).make_copy()
render_gnode = GeomNode(f"obj_{global_id}")
render_gnode.add_geom(render_geom, gnode.get_geom_state(gi))
# Picking geometry gets encoded ID in vertex color.
pick_geom = gnode.get_geom(gi).make_copy()
pick_vdata = pick_geom.modify_vertex_data()
self._encode_id_color(pick_vdata, global_id)
pick_gnode = GeomNode(f"pick_{global_id}")
pick_gnode.add_geom(pick_geom, gnode.get_geom_state(gi))
world_pos = world_mat.get_row3(3)
chunk_id, chunk = self._allocate_spatial_chunk(scene_root, world_pos)
obj_np = chunk["dynamic_np"].attach_new_node(render_gnode)
obj_np.set_mat(world_mat)
pick_np = pick_root.attach_new_node(pick_gnode)
pick_np.set_mat(world_mat)
chunk["members"].append(global_id)
self.id_to_chunk[global_id] = chunk_id
self.id_to_object_np[global_id] = obj_np
self.id_to_pick_np[global_id] = pick_np
self.tree_nodes[owner_key]["local_ids"].append(global_id)
self.id_to_name[global_id] = owner_key
self.global_transforms.append(LMatrix4f(world_mat))
self.position_offsets[global_id] = Vec3(0, 0, 0)
global_id += 1
t1 = time.time()
print(f"[控制器] Dynamic object build took {(t1 - t0) * 1000:.0f}ms")
for chunk_id in sorted(self.chunks):
self._rebuild_static_chunk(chunk_id)
self._set_chunk_dynamic(chunk_id, False)
t2 = time.time()
print(f"[控制器] Static chunk flatten took {(t2 - t1) * 1000:.0f}ms")
print(f"[控制器] Built {len(self.chunks)} chunks, {global_id} objects")
print(f"[控制器] Spatial chunking: cell={self.chunk_world_size:.1f}, max_members={self.chunk_size}")
# Fill per-node aggregate IDs and build deterministic preorder list for UI.
self._aggregate_tree_ids(self.tree_root_key)
self.node_list = []
self._build_tree_preorder(self.tree_root_key, self.node_list)
model.remove_node()
return global_id
def _build_pick_vertex_index(self, pick_root):
"""
Build local_id -> [(geom_node_np, geom_idx, row_indices_array)] for pick model.
@ -758,7 +1089,7 @@ class ObjectController:
], dtype=np.float32)
def create_ssbo(self):
"""No SSBO needed — using RP default rendering."""
"""No SSBO needed in hybrid mode."""
return None
def move_object(self, global_id, delta):
@ -801,8 +1132,7 @@ class ObjectController:
self._apply_vertices_to_pick(local_idx, i, new_pos)
def get_world_pos(self, global_id):
"""Get current world position of an object."""
if global_id not in self.id_to_chunk:
if global_id not in self.id_to_object_np or not self.model:
return Vec3(0, 0, 0)
_, local_idx = self.id_to_chunk[global_id]
@ -814,14 +1144,12 @@ class ObjectController:
return self._local_point_to_world(local_pos)
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]

View File

@ -3,30 +3,51 @@ 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, Quat,
Vec3, Vec4, Point2, Point3, LMatrix4f, ShaderBuffer, GeomEnums, OmniBoundingVolume,
TransparencyAttrib, BoundingSphere, NodePath,
GraphicsEngine, WindowProperties, FrameBufferProperties,
GraphicsPipe, GraphicsOutput, Camera, DisplayRegion, OrthographicLens,
BoundingBox
BoundingBox, BitMask32
)
import p3dimgui.backend as p3dimgui_backend
import p3dimgui.shaders as p3dimgui_shaders
# 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
# 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:
@ -44,12 +65,7 @@ class SSBOEditor:
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
self._transform_gizmo = None
# Internal State
self.selected_name = None
@ -59,12 +75,19 @@ class SSBOEditor:
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
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
# Initialize ImGui Backend if not already present
if not hasattr(self.base, 'imgui_backend'):
@ -91,6 +114,61 @@ class SSBOEditor:
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()
@ -128,20 +206,23 @@ class SSBOEditor:
io.fonts.add_font_default()
def load_model(self, model_path):
"""Load and process a model — NO custom shader, uses RP default rendering."""
"""Load and process a model using hybrid static/dynamic chunks."""
print(f"[SSBOEditor] Loading model: {model_path}")
fn = Filename.fromOsSpecific(model_path)
self.model = self.base.loader.loadModel(fn)
source_model = self.base.loader.loadModel(fn)
model_name = os.path.basename(model_path)
if model_name:
source_model.set_name(model_name)
self.controller = ObjectController()
count = self.controller.bake_ids_and_collect(self.model)
self._ssbo_transform_active = False
self._ssbo_selected_local_indices = []
self._ssbo_transform_snapshot = None
self._cleanup_ssbo_proxy()
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
@ -149,7 +230,7 @@ class SSBOEditor:
self.setup_gpu_picking()
# Keep pick clone aligned with source model transform.
self._sync_pick_model_transform()
print(f"[SSBOEditor] Model loaded. Total objects: {count}")
# No custom effect needed — RP default rendering for maximum FPS
@ -211,6 +292,7 @@ class SSBOEditor:
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)
@ -220,22 +302,21 @@ class SSBOEditor:
# 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()
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:
pick_shader = Shader.load(
Shader.SL_GLSL,
pick_vert,
pick_frag
)
pick_scene = getattr(self.controller, "pick_model", None) if self.controller else None
if pick_scene is None:
pick_scene = self.model
self.pick_cam.set_scene(pick_scene)
# 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
@ -297,6 +378,29 @@ class SSBOEditor:
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):
@ -314,292 +418,327 @@ class SSBOEditor:
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)
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:
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 True
return
self.selected_name = None
self.selected_ids = []
return False
self.clear_selection()
def on_mouse_click(self):
io = imgui.get_io()
if io.want_capture_mouse:
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()
# 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)
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 clear_selection(self):
pass # No selection mask texture needed without custom shader
self._stop_pick_sync_task()
self._reset_pick_sync_cache()
self._cleanup_group_proxy()
self.selected_name = None
self.selected_ids = []
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):
if not self.controller or key not in self.controller.name_to_ids:
return
# 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, [])
self._sync_selection_from_key(key)
is_root_selection = (
self.controller and
key == getattr(self.controller, "tree_root_key", None)
)
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:
# 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._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
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 = []
self.controller.set_active_ids(self.selected_ids)
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
if not self._transform_gizmo or not self.selected_ids:
if self._transform_gizmo:
self._transform_gizmo.detach()
return
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
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
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
# 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
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}")
center /= len(valid)
proxy.set_pos(self.base.render, center)
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
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)
curr_pos = Vec3(target.getPos(self.base.render))
curr_quat = Quat(target.getQuat(self.base.render))
curr_scale = Vec3(target.getScale())
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()
delta_pos_world = curr_pos - start_pos
inv_start_quat = Quat(start_quat)
inv_start_quat.invertInPlace()
delta_quat_world = 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,
)
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
# TransformGizmo drag deltas are in world space.
# SSBO vertex transforms are applied in model-local space.
delta_pos = delta_pos_world
delta_quat = delta_quat_world
if hasattr(self.controller, "world_vector_to_model_local"):
delta_pos = self.controller.world_vector_to_model_local(delta_pos_world)
if hasattr(self.controller, "world_quat_delta_to_model_local"):
delta_quat = self.controller.world_quat_delta_to_model_local(delta_quat_world)
search_lower = self.search_text.strip().lower()
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 walk(key, depth):
node = self.controller.tree_nodes.get(key)
if not node:
return False, []
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}")
# 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
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}")
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())
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
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)
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
visible = (not search_lower) or name_match or child_match
if not visible:
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
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:
@ -623,28 +762,18 @@ class SSBOEditor:
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:
if self.search_text != self.last_search_text or not self.filtered_nodes:
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]
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, display, geom_count = self.filtered_nodes[i]
label = f"{display} ({geom_count})"
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)
@ -652,7 +781,8 @@ class SSBOEditor:
imgui.separator()
if self.selected_name:
imgui.text_colored((1, 0.8, 0.2, 1), f"Selected: {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()
@ -663,7 +793,10 @@ class SSBOEditor:
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:
@ -677,7 +810,18 @@ class SSBOEditor:
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)
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

View File

@ -735,6 +735,9 @@ class AppActions:
node_name = node.getName() or "未命名节点"
parent = node.getParent()
ssbo_editor = getattr(self, "ssbo_editor", None)
ssbo_model = getattr(ssbo_editor, "model", None) if ssbo_editor else None
deleting_ssbo_root = bool(ssbo_model and (node == ssbo_model))
# 创建删除命令
if hasattr(self, 'command_manager') and self.command_manager:
@ -747,6 +750,12 @@ class AppActions:
print(f"[删除] 命令管理器不可用,直接删除节点: {node_name}")
self._perform_node_cleanup(node)
node.removeNode()
if deleting_ssbo_root and ssbo_editor:
try:
ssbo_editor.on_model_deleted(node)
except Exception as e:
print(f"[SSBO] 删除模型后清理失败: {e}")
print(f"[删除] 成功删除节点: {node_name}")
return True
@ -1027,14 +1036,9 @@ class AppActions:
def _import_model_for_runtime(self, file_path, prefer_scene_manager=False):
"""Import model through the active runtime path.
SSBO mode: load via SSBOEditor only (avoid duplicate SceneManager model).
SSBO mode: load via SSBOEditor always (regardless of prefer_scene_manager).
Legacy mode: load via SceneManager.
"""
if prefer_scene_manager:
if hasattr(self, 'scene_manager') and self.scene_manager:
return self.scene_manager.importModel(file_path)
return None
if self.use_ssbo_mouse_picking and getattr(self, 'ssbo_editor', None):
try:
# Clear selection/gizmo first to avoid dangling references to soon-to-be removed nodes.

View File

@ -610,8 +610,33 @@ class EditorPanels:
# 处理节点选择
if imgui.is_item_clicked():
# In SSBO mode, clicking model root should finally bind gizmo to
# SSBO group proxy (not legacy model root). So run legacy
# selection first, then force SSBO root selection at the end.
force_ssbo_root_key = None
ssbo_editor_ref = None
try:
ssbo_editor = getattr(self.app, "ssbo_editor", None)
controller = getattr(ssbo_editor, "controller", None) if ssbo_editor else None
ssbo_model = getattr(ssbo_editor, "model", None) if ssbo_editor else None
root_key = getattr(controller, "tree_root_key", None) if controller else None
if (
ssbo_editor and controller and ssbo_model and node == ssbo_model
and root_key and root_key in controller.tree_nodes
):
force_ssbo_root_key = root_key
ssbo_editor_ref = ssbo_editor
except Exception:
pass
if hasattr(self.app, 'selection') and self.app.selection:
self.app.selection.updateSelection(node)
if force_ssbo_root_key and ssbo_editor_ref:
try:
ssbo_editor_ref.select_node(force_ssbo_root_key)
except Exception:
pass
# Clear LUI selection when a scene node is selected
if hasattr(self.app, 'lui_manager'):
self.app.lui_manager.selected_index = -1
@ -655,7 +680,7 @@ class EditorPanels:
imgui.pop_style_color()
def _draw_ssbo_virtual_children(self, node):
"""Draw SSBO controller nodes as virtual children for scene tree."""
"""Draw SSBO controller tree_nodes as virtual children for scene tree."""
ssbo_editor = getattr(self.app, "ssbo_editor", None)
if not ssbo_editor:
return False
@ -664,66 +689,59 @@ class EditorPanels:
if not model or model != node or not controller:
return False
tree_root = controller.get_virtual_hierarchy() if hasattr(controller, "get_virtual_hierarchy") else None
if not tree_root or not tree_root.get("children"):
root_key = getattr(controller, "tree_root_key", None)
if not root_key or root_key not in controller.tree_nodes:
imgui.text_disabled("(无可用子节点)")
return True
for name in sorted(tree_root["children"].keys()):
child = tree_root["children"][name]
self._draw_ssbo_virtual_tree_node(ssbo_editor, child, "ssbo_root")
root_node = controller.tree_nodes[root_key]
if not root_node["children"]:
imgui.text_disabled("(无可用子节点)")
return True
for child_key in root_node["children"]:
self._draw_ssbo_virtual_tree_node(ssbo_editor, controller, child_key)
return True
def _draw_ssbo_virtual_tree_node(self, ssbo_editor, tree_node, unique_id_prefix, depth=0):
"""Recursively draw virtual SSBO hierarchy in scene tree."""
if not tree_node:
def _draw_ssbo_virtual_tree_node(self, ssbo_editor, controller, key):
"""Recursively draw SSBO tree_nodes hierarchy in scene tree."""
node_data = controller.tree_nodes.get(key)
if not node_data:
return
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}"
# Skip redundant wrapper nodes (e.g. ROOT), show their children instead.
if controller.should_hide_tree_node(key):
for child_key in node_data["children"]:
self._draw_ssbo_virtual_tree_node(ssbo_editor, controller, child_key)
return
# Leaf: selectable to trigger SSBO selection.
if not children and leaf_key:
is_selected = (getattr(ssbo_editor, "selected_name", None) == leaf_key)
display = controller.display_names.get(key, key)
obj_count = len(controller.name_to_ids.get(key, []))
children = node_data["children"]
is_selected = (getattr(ssbo_editor, "selected_name", None) == key)
if not children:
# Leaf node: selectable
label = f"{display} ({obj_count})##{key}"
if imgui.selectable(label, is_selected)[0]:
ssbo_editor.select_node(leaf_key)
ssbo_editor.select_node(key)
if hasattr(self.app, "lui_manager"):
self.app.lui_manager.selected_index = -1
return
# 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]:
ssbo_editor.select_node(leaf_key)
if hasattr(self.app, "lui_manager"):
self.app.lui_manager.selected_index = -1
for child_name in sorted(children.keys()):
self._draw_ssbo_virtual_tree_node(
ssbo_editor,
children[child_name],
unique_id_prefix,
depth + 1,
)
imgui.tree_pop()
else:
# Branch node: tree node
flags = imgui.TreeNodeFlags_.open_on_arrow
if is_selected:
flags |= imgui.TreeNodeFlags_.selected
label = f"{display} ({obj_count})##{key}"
opened = imgui.tree_node_ex(label, flags)
if imgui.is_item_clicked(0):
ssbo_editor.select_node(key)
if hasattr(self.app, "lui_manager"):
self.app.lui_manager.selected_index = -1
if opened:
for child_key in children:
self._draw_ssbo_virtual_tree_node(ssbo_editor, controller, child_key)
imgui.tree_pop()
def _show_node_context_menu(self, node, name, node_type):
"""显示节点右键菜单"""
self.app._context_menu_node = True