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:
commit
2ac08b0582
53
imgui.ini
53
imgui.ini
@ -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
|
||||
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user