模型父子级关系修复
This commit is contained in:
parent
a00b276233
commit
f26b14cb40
34
imgui.ini
34
imgui.ini
@ -25,31 +25,31 @@ Collapsed=0
|
||||
|
||||
[Window][工具栏]
|
||||
Pos=323,20
|
||||
Size=690,32
|
||||
Size=1156,32
|
||||
Collapsed=0
|
||||
DockId=0x0000000D,0
|
||||
|
||||
[Window][场景树]
|
||||
Pos=0,20
|
||||
Size=321,468
|
||||
Size=321,634
|
||||
Collapsed=0
|
||||
DockId=0x00000007,0
|
||||
|
||||
[Window][属性面板]
|
||||
Pos=1015,20
|
||||
Size=365,730
|
||||
Pos=1481,20
|
||||
Size=439,989
|
||||
Collapsed=0
|
||||
DockId=0x00000003,0
|
||||
|
||||
[Window][控制台]
|
||||
Pos=0,490
|
||||
Size=321,260
|
||||
Pos=0,656
|
||||
Size=321,353
|
||||
Collapsed=0
|
||||
DockId=0x00000008,0
|
||||
|
||||
[Window][脚本管理]
|
||||
Pos=1540,20
|
||||
Size=380,390
|
||||
Pos=1481,20
|
||||
Size=439,989
|
||||
Collapsed=0
|
||||
DockId=0x00000003,1
|
||||
|
||||
@ -60,7 +60,7 @@ Collapsed=0
|
||||
|
||||
[Window][WindowOverViewport_11111111]
|
||||
Pos=0,20
|
||||
Size=1380,730
|
||||
Size=1920,989
|
||||
Collapsed=0
|
||||
|
||||
[Window][测试窗口1]
|
||||
@ -84,7 +84,7 @@ Size=400,300
|
||||
Collapsed=0
|
||||
|
||||
[Window][选择路径]
|
||||
Pos=390,125
|
||||
Pos=660,254
|
||||
Size=600,500
|
||||
Collapsed=0
|
||||
|
||||
@ -94,13 +94,13 @@ Size=500,400
|
||||
Collapsed=0
|
||||
|
||||
[Window][导入模型]
|
||||
Pos=390,125
|
||||
Pos=660,254
|
||||
Size=600,500
|
||||
Collapsed=0
|
||||
|
||||
[Window][资源管理器]
|
||||
Pos=323,568
|
||||
Size=690,182
|
||||
Pos=323,827
|
||||
Size=1156,182
|
||||
Collapsed=0
|
||||
DockId=0x00000006,0
|
||||
|
||||
@ -196,17 +196,17 @@ Size=120,384
|
||||
Collapsed=0
|
||||
|
||||
[Docking][Data]
|
||||
DockSpace ID=0x08BD597D Window=0x1BBC0F80 Pos=0,20 Size=1380,730 Split=X
|
||||
DockNode ID=0x00000001 Parent=0x08BD597D SizeRef=1553,989 Split=X
|
||||
DockSpace ID=0x08BD597D Window=0x1BBC0F80 Pos=0,20 Size=1920,989 Split=X
|
||||
DockNode ID=0x00000001 Parent=0x08BD597D SizeRef=1479,989 Split=X
|
||||
DockNode ID=0x00000009 Parent=0x00000001 SizeRef=321,989 Split=Y Selected=0xE0015051
|
||||
DockNode ID=0x00000007 Parent=0x00000009 SizeRef=271,634 Selected=0xE0015051
|
||||
DockNode ID=0x00000008 Parent=0x00000009 SizeRef=271,353 Selected=0x5428E753
|
||||
DockNode ID=0x0000000A Parent=0x00000001 SizeRef=1230,989 Split=Y
|
||||
DockNode ID=0x0000000A Parent=0x00000001 SizeRef=1156,989 Split=Y
|
||||
DockNode ID=0x0000000D Parent=0x0000000A SizeRef=1318,32 HiddenTabBar=1 Selected=0x43A39006
|
||||
DockNode ID=0x0000000E Parent=0x0000000A SizeRef=1318,955 Split=Y
|
||||
DockNode ID=0x00000005 Parent=0x0000000E SizeRef=1341,771 CentralNode=1
|
||||
DockNode ID=0x00000006 Parent=0x0000000E SizeRef=1341,182 Selected=0x3A2E05C3
|
||||
DockNode ID=0x00000002 Parent=0x08BD597D SizeRef=365,989 Split=Y Selected=0x3188AB8D
|
||||
DockNode ID=0x00000002 Parent=0x08BD597D SizeRef=439,989 Split=Y Selected=0x3188AB8D
|
||||
DockNode ID=0x00000003 Parent=0x00000002 SizeRef=351,390 Selected=0x5DB6FF37
|
||||
DockNode ID=0x00000004 Parent=0x00000002 SizeRef=351,597 Selected=0x1EB923B7
|
||||
|
||||
|
||||
@ -0,0 +1,550 @@
|
||||
|
||||
from panda3d.core import (
|
||||
GeomVertexFormat, GeomVertexWriter, GeomVertexReader, GeomVertexRewriter,
|
||||
InternalName, Vec3, Vec4, LMatrix4f, ShaderBuffer, GeomEnums,
|
||||
BoundingSphere, NodePath, GeomNode, Texture, SamplerState,
|
||||
Point3, BoundingBox, Quat
|
||||
)
|
||||
import struct
|
||||
import time
|
||||
|
||||
class ObjectController:
|
||||
"""
|
||||
物体控制器 (No Custom Shader Mode)
|
||||
====================================
|
||||
Uses RP's default rendering (no rp.set_effect) for maximum FPS.
|
||||
Vertex colors baked for picking. Movement modifies vertex data directly.
|
||||
Stores original vertex positions per object for rotation/translation.
|
||||
"""
|
||||
def __init__(self):
|
||||
self.name_to_ids = {}
|
||||
self.id_to_name = {}
|
||||
self.key_to_node = {}
|
||||
self.node_list = []
|
||||
self.display_names = {}
|
||||
self.global_transforms = [] # Original transforms (for center/position)
|
||||
|
||||
self.id_to_chunk = {} # global_id -> (chunk_key, local_idx)
|
||||
self.chunks = {} # chunk_key -> dict with 'node' key
|
||||
|
||||
# Vertex index: local_id -> list of (geom_node_np, geom_idx, [row_indices])
|
||||
self.vertex_index = {}
|
||||
|
||||
# Original vertex positions: local_id -> list of (Vec3,) matching row order
|
||||
self.original_positions = {}
|
||||
|
||||
# Current position offsets: local_id -> Vec3 delta
|
||||
self.position_offsets = {}
|
||||
self.local_to_global_id = {}
|
||||
self.local_transform_state = {}
|
||||
self.local_transform_base_positions = {}
|
||||
self.virtual_tree = None
|
||||
self.virtual_tree_meta = None
|
||||
|
||||
self.model = None
|
||||
self.chunk_node = None # Single chunk node
|
||||
|
||||
def bake_ids_and_collect(self, model):
|
||||
"""
|
||||
Bake IDs into vertex colors, flatten, then build vertex index.
|
||||
|
||||
NO transform reset — vertices keep world-space positions.
|
||||
NO SSBO — uses RP default rendering.
|
||||
"""
|
||||
t0 = time.time()
|
||||
|
||||
geom_nodes = list(model.find_all_matches("**/+GeomNode"))
|
||||
print(f"[控制器] 找到 {len(geom_nodes)} 个 GeomNode")
|
||||
|
||||
self.name_to_ids = {}
|
||||
self.id_to_name = {}
|
||||
self.key_to_node = {}
|
||||
self.node_list = []
|
||||
self.display_names = {}
|
||||
self.global_transforms = []
|
||||
self.id_to_chunk = {}
|
||||
self.chunks = {}
|
||||
self.vertex_index = {}
|
||||
self.original_positions = {}
|
||||
self.position_offsets = {}
|
||||
self.local_to_global_id = {}
|
||||
self.local_transform_state = {}
|
||||
self.local_transform_base_positions = {}
|
||||
self.virtual_tree = None
|
||||
self.virtual_tree_meta = None
|
||||
|
||||
global_id_counter = 0
|
||||
chunk_key = model.get_name() or "default"
|
||||
|
||||
# No chunk wrapper — flatten directly on model (same as load_jyc_flatten.py)
|
||||
self.chunk_node = model
|
||||
self.chunks[chunk_key] = {'node': model, 'base_id': 0}
|
||||
|
||||
# Flatten hierarchy
|
||||
for np in geom_nodes:
|
||||
np.wrt_reparent_to(model)
|
||||
|
||||
local_idx = 0
|
||||
|
||||
for np in geom_nodes:
|
||||
gnode = np.node()
|
||||
|
||||
if gnode.get_num_parents() > 1:
|
||||
parent = np.get_parent()
|
||||
if not parent.is_empty():
|
||||
new_np = np.copy_to(parent)
|
||||
np.detach_node()
|
||||
np = new_np
|
||||
gnode = np.node()
|
||||
|
||||
unique_key = str(np)
|
||||
display_name = np.get_name() or f"Object_{global_id_counter}"
|
||||
|
||||
if unique_key not in self.name_to_ids:
|
||||
self.name_to_ids[unique_key] = []
|
||||
self.key_to_node[unique_key] = np
|
||||
self.node_list.append(unique_key)
|
||||
self.display_names[unique_key] = display_name
|
||||
|
||||
# Save original transform
|
||||
mat_double = np.get_mat()
|
||||
original_transform = LMatrix4f(mat_double)
|
||||
|
||||
for i in range(gnode.get_num_geoms()):
|
||||
geom = gnode.modify_geom(i)
|
||||
vdata = geom.modify_vertex_data()
|
||||
|
||||
if not vdata.has_column("color"):
|
||||
new_format = vdata.get_format().get_union_format(GeomVertexFormat.get_v3c4())
|
||||
vdata.set_format(new_format)
|
||||
|
||||
# Encode Local ID in R/G
|
||||
low = local_idx % 256
|
||||
high = local_idx // 256
|
||||
r = low / 255.0
|
||||
g = high / 255.0
|
||||
|
||||
writer = GeomVertexWriter(vdata, InternalName.make("color"))
|
||||
for row in range(vdata.get_num_rows()):
|
||||
writer.set_row(row)
|
||||
writer.set_data4f(r, g, 0.0, 1.0)
|
||||
|
||||
self.global_transforms.append(original_transform)
|
||||
self.id_to_chunk[global_id_counter] = (chunk_key, local_idx)
|
||||
self.name_to_ids[unique_key].append(global_id_counter)
|
||||
self.id_to_name[global_id_counter] = unique_key
|
||||
self.local_to_global_id[local_idx] = global_id_counter
|
||||
self.position_offsets[local_idx] = Vec3(0, 0, 0)
|
||||
|
||||
global_id_counter += 1
|
||||
local_idx += 1
|
||||
|
||||
# DO NOT reset transform — keep world-space positions
|
||||
|
||||
# Flatten directly on model — NO set_final, allows per-geom frustum culling
|
||||
model.flatten_strong()
|
||||
|
||||
t1 = time.time()
|
||||
print(f"[控制器] Flatten took {(t1-t0)*1000:.0f}ms")
|
||||
|
||||
# Build vertex index AFTER flatten
|
||||
self._build_vertex_index(model)
|
||||
self._init_local_transform_state()
|
||||
self.build_virtual_hierarchy()
|
||||
|
||||
t2 = time.time()
|
||||
print(f"[控制器] Vertex index built in {(t2-t1)*1000:.0f}ms, "
|
||||
f"{len(self.vertex_index)} unique IDs indexed")
|
||||
|
||||
self.model = model
|
||||
self.node_list.sort()
|
||||
return global_id_counter
|
||||
|
||||
def build_virtual_hierarchy(self):
|
||||
"""Build a readonly virtual tree from node_list path keys."""
|
||||
root = {
|
||||
"name": "",
|
||||
"path": "",
|
||||
"children": {},
|
||||
"leaf_key": None,
|
||||
"display_name": "",
|
||||
}
|
||||
max_depth = 0
|
||||
leaf_count = 0
|
||||
|
||||
for key in self.node_list:
|
||||
if not key:
|
||||
continue
|
||||
parts = [p for p in str(key).split("/") if p]
|
||||
if not parts:
|
||||
continue
|
||||
max_depth = max(max_depth, len(parts))
|
||||
cursor = root
|
||||
path_acc = ""
|
||||
for i, part in enumerate(parts):
|
||||
path_acc = f"{path_acc}/{part}" if path_acc else part
|
||||
child = cursor["children"].get(part)
|
||||
if child is None:
|
||||
child = {
|
||||
"name": part,
|
||||
"path": path_acc,
|
||||
"children": {},
|
||||
"leaf_key": None,
|
||||
"display_name": part,
|
||||
}
|
||||
cursor["children"][part] = child
|
||||
cursor = child
|
||||
if i == len(parts) - 1:
|
||||
cursor["leaf_key"] = key
|
||||
cursor["display_name"] = self.display_names.get(key, part)
|
||||
leaf_count += 1
|
||||
|
||||
self.virtual_tree = root
|
||||
self.virtual_tree_meta = {"max_depth": max_depth, "leaf_count": leaf_count}
|
||||
return root
|
||||
|
||||
def get_virtual_hierarchy(self):
|
||||
"""Return cached virtual tree; build on demand."""
|
||||
if self.virtual_tree is None:
|
||||
return self.build_virtual_hierarchy()
|
||||
return self.virtual_tree
|
||||
|
||||
def _build_vertex_index(self, chunk_root):
|
||||
"""
|
||||
After flatten, batch-read all vertex data with numpy to build:
|
||||
local_id -> [(geom_node_np, geom_idx, row_indices_array)]
|
||||
Also stores original vertex positions per object (as numpy arrays).
|
||||
"""
|
||||
import numpy as np
|
||||
|
||||
for gn_np in chunk_root.find_all_matches("**/+GeomNode"):
|
||||
gnode = gn_np.node()
|
||||
for gi in range(gnode.get_num_geoms()):
|
||||
geom = gnode.get_geom(gi)
|
||||
vdata = geom.get_vertex_data()
|
||||
num_rows = vdata.get_num_rows()
|
||||
|
||||
if num_rows == 0:
|
||||
continue
|
||||
|
||||
# Find vertex and color column info
|
||||
fmt = vdata.get_format()
|
||||
|
||||
# Get position column
|
||||
pos_col = fmt.get_column(InternalName.get_vertex())
|
||||
if pos_col is None:
|
||||
continue
|
||||
pos_array_idx = fmt.get_array_with(InternalName.get_vertex())
|
||||
pos_start = pos_col.get_start()
|
||||
|
||||
# Get color column
|
||||
color_col = fmt.get_column(InternalName.make("color"))
|
||||
if color_col is None:
|
||||
continue
|
||||
color_array_idx = fmt.get_array_with(InternalName.make("color"))
|
||||
color_start = color_col.get_start()
|
||||
|
||||
# Read raw position array
|
||||
pos_array_format = fmt.get_array(pos_array_idx)
|
||||
pos_stride = pos_array_format.get_stride()
|
||||
pos_handle = vdata.get_array(pos_array_idx).get_handle()
|
||||
pos_raw = bytes(pos_handle.get_data())
|
||||
pos_buf = np.frombuffer(pos_raw, dtype=np.uint8).reshape(num_rows, pos_stride)
|
||||
|
||||
# Extract xyz positions (3 floats starting at pos_start)
|
||||
positions = np.ndarray((num_rows, 3), dtype=np.float32,
|
||||
buffer=pos_buf[:, pos_start:pos_start+12].tobytes())
|
||||
|
||||
# Read raw color array
|
||||
color_array_format = fmt.get_array(color_array_idx)
|
||||
color_stride = color_array_format.get_stride()
|
||||
|
||||
if color_array_idx == pos_array_idx:
|
||||
color_buf = pos_buf
|
||||
else:
|
||||
color_handle = vdata.get_array(color_array_idx).get_handle()
|
||||
color_raw = bytes(color_handle.get_data())
|
||||
color_buf = np.frombuffer(color_raw, dtype=np.uint8).reshape(num_rows, color_stride)
|
||||
|
||||
# Decode color format to get ID
|
||||
# Color can be stored as float32 RGBA or unorm8 RGBA
|
||||
num_components = color_col.get_num_components()
|
||||
component_bytes = color_col.get_component_bytes()
|
||||
|
||||
if component_bytes == 4: # float32 per component
|
||||
color_data = np.ndarray((num_rows, num_components), dtype=np.float32,
|
||||
buffer=color_buf[:, color_start:color_start+num_components*4].tobytes())
|
||||
r_vals = (color_data[:, 0] * 255.0 + 0.5).astype(np.int32)
|
||||
g_vals = (color_data[:, 1] * 255.0 + 0.5).astype(np.int32)
|
||||
elif component_bytes == 1: # uint8 per component
|
||||
color_bytes = color_buf[:, color_start:color_start+num_components].copy()
|
||||
r_vals = color_bytes[:, 0].astype(np.int32)
|
||||
g_vals = color_bytes[:, 1].astype(np.int32)
|
||||
else:
|
||||
# Fallback: skip this geom
|
||||
continue
|
||||
|
||||
local_ids = r_vals + (g_vals << 8)
|
||||
|
||||
# Group rows by local_id using argsort (O(N log N) instead of O(N×K))
|
||||
sort_idx = np.argsort(local_ids)
|
||||
sorted_ids = local_ids[sort_idx]
|
||||
sorted_positions = positions[sort_idx]
|
||||
|
||||
# Find group boundaries
|
||||
boundaries = np.where(np.diff(sorted_ids) != 0)[0] + 1
|
||||
|
||||
# Split into groups
|
||||
id_groups = np.split(sort_idx, boundaries)
|
||||
pos_groups = np.split(sorted_positions, boundaries)
|
||||
group_ids = sorted_ids[np.concatenate([[0], boundaries])]
|
||||
|
||||
for k in range(len(group_ids)):
|
||||
uid = int(group_ids[k])
|
||||
rows = id_groups[k]
|
||||
pos = pos_groups[k]
|
||||
|
||||
if uid not in self.vertex_index:
|
||||
self.vertex_index[uid] = []
|
||||
self.original_positions[uid] = []
|
||||
|
||||
self.vertex_index[uid].append((gn_np, gi, rows))
|
||||
self.original_positions[uid].append(pos.copy())
|
||||
|
||||
def _init_local_transform_state(self):
|
||||
"""Initialize transform state for each local_idx after vertex index is ready."""
|
||||
self.local_transform_state = {}
|
||||
self.local_transform_base_positions = {}
|
||||
|
||||
for local_idx in self.vertex_index.keys():
|
||||
self.local_transform_base_positions[local_idx] = self.original_positions.get(local_idx, [])
|
||||
self.local_transform_state[local_idx] = {
|
||||
"offset": Vec3(0, 0, 0),
|
||||
"quat": Quat.identQuat(),
|
||||
"scale": Vec3(1, 1, 1),
|
||||
"pivot": self.get_local_pivot(local_idx),
|
||||
}
|
||||
|
||||
def get_local_indices_from_global_ids(self, global_ids):
|
||||
"""Map global ids to unique local indices."""
|
||||
local_indices = []
|
||||
if not global_ids:
|
||||
return local_indices
|
||||
seen = set()
|
||||
for global_id in global_ids:
|
||||
mapping = self.id_to_chunk.get(global_id)
|
||||
if not mapping:
|
||||
continue
|
||||
_, local_idx = mapping
|
||||
if local_idx in seen:
|
||||
continue
|
||||
if local_idx not in self.vertex_index:
|
||||
continue
|
||||
seen.add(local_idx)
|
||||
local_indices.append(local_idx)
|
||||
return local_indices
|
||||
|
||||
def get_local_pivot(self, local_idx):
|
||||
"""Get pivot for one local object (world-space center)."""
|
||||
global_id = self.local_to_global_id.get(local_idx)
|
||||
if global_id is None:
|
||||
return Vec3(0, 0, 0)
|
||||
return self.get_object_center(global_id)
|
||||
|
||||
def get_selection_center(self, local_indices):
|
||||
"""Get center point for a multi-object selection."""
|
||||
if not local_indices:
|
||||
return Vec3(0, 0, 0)
|
||||
acc = Vec3(0, 0, 0)
|
||||
valid = 0
|
||||
for local_idx in local_indices:
|
||||
state = self.local_transform_state.get(local_idx)
|
||||
if not state:
|
||||
continue
|
||||
acc += state.get("pivot", Vec3(0, 0, 0)) + state.get("offset", Vec3(0, 0, 0))
|
||||
valid += 1
|
||||
if valid == 0:
|
||||
return Vec3(0, 0, 0)
|
||||
return acc / float(valid)
|
||||
|
||||
def begin_transform_session(self, local_indices):
|
||||
"""Create immutable baseline snapshot for one gizmo drag session."""
|
||||
if not local_indices:
|
||||
return {"locals": {}}
|
||||
|
||||
locals_snapshot = {}
|
||||
for local_idx in local_indices:
|
||||
base_state = self.local_transform_state.get(local_idx)
|
||||
if not base_state:
|
||||
continue
|
||||
entries = self.vertex_index.get(local_idx, [])
|
||||
base_positions = self.local_transform_base_positions.get(local_idx, [])
|
||||
locals_snapshot[local_idx] = {
|
||||
"offset": Vec3(base_state["offset"]),
|
||||
"quat": Quat(base_state["quat"]),
|
||||
"scale": Vec3(base_state["scale"]),
|
||||
"pivot": Vec3(base_state["pivot"]),
|
||||
"entries": entries,
|
||||
"base_positions": base_positions,
|
||||
}
|
||||
return {"locals": locals_snapshot}
|
||||
|
||||
def apply_transform_session(self, snapshot, delta_pos, delta_quat, delta_scale):
|
||||
"""Apply transform delta to all local indices in snapshot and rewrite vertices."""
|
||||
import numpy as np
|
||||
|
||||
if not snapshot or "locals" not in snapshot:
|
||||
return
|
||||
if delta_pos is None:
|
||||
delta_pos = Vec3(0, 0, 0)
|
||||
if delta_quat is None:
|
||||
delta_quat = Quat.identQuat()
|
||||
if delta_scale is None:
|
||||
delta_scale = Vec3(1, 1, 1)
|
||||
|
||||
dscale = np.array([delta_scale.x, delta_scale.y, delta_scale.z], dtype=np.float32)
|
||||
dpos = np.array([delta_pos.x, delta_pos.y, delta_pos.z], dtype=np.float32)
|
||||
|
||||
for local_idx, local_data in snapshot["locals"].items():
|
||||
base_offset = local_data["offset"]
|
||||
base_quat = local_data["quat"]
|
||||
base_scale = local_data["scale"]
|
||||
pivot = local_data["pivot"]
|
||||
|
||||
final_offset = Vec3(base_offset) + delta_pos
|
||||
final_quat = Quat(delta_quat * base_quat)
|
||||
final_scale = Vec3(
|
||||
base_scale.x * delta_scale.x,
|
||||
base_scale.y * delta_scale.y,
|
||||
base_scale.z * delta_scale.z,
|
||||
)
|
||||
rot_mat = self._quat_to_np_mat3(final_quat)
|
||||
|
||||
self.local_transform_state[local_idx]["offset"] = final_offset
|
||||
self.local_transform_state[local_idx]["quat"] = final_quat
|
||||
self.local_transform_state[local_idx]["scale"] = final_scale
|
||||
self.position_offsets[local_idx] = final_offset
|
||||
|
||||
pivot_np = np.array([pivot.x, pivot.y, pivot.z], dtype=np.float32)
|
||||
base_s = np.array([base_scale.x, base_scale.y, base_scale.z], dtype=np.float32)
|
||||
total_scale = base_s * dscale
|
||||
total_offset = np.array([base_offset.x, base_offset.y, base_offset.z], dtype=np.float32) + dpos
|
||||
|
||||
entries = local_data["entries"]
|
||||
base_positions = local_data["base_positions"]
|
||||
for i, (gn_np, gi, rows) in enumerate(entries):
|
||||
if i >= len(base_positions):
|
||||
continue
|
||||
orig_pos = base_positions[i]
|
||||
if orig_pos is None or len(orig_pos) == 0:
|
||||
continue
|
||||
centered = orig_pos - pivot_np
|
||||
scaled = centered * total_scale
|
||||
rotated = scaled @ rot_mat.T
|
||||
new_pos = rotated + pivot_np + total_offset
|
||||
|
||||
gnode = gn_np.node()
|
||||
geom = gnode.modify_geom(gi)
|
||||
vdata = geom.modify_vertex_data()
|
||||
writer = GeomVertexWriter(vdata, "vertex")
|
||||
|
||||
for j in range(len(rows)):
|
||||
writer.set_row(int(rows[j]))
|
||||
writer.set_data3f(float(new_pos[j, 0]), float(new_pos[j, 1]), float(new_pos[j, 2]))
|
||||
|
||||
def _quat_to_np_mat3(self, quat):
|
||||
"""Convert Panda3D Quat to 3x3 numpy rotation matrix."""
|
||||
import numpy as np
|
||||
q = Quat(quat)
|
||||
q.normalize()
|
||||
w = float(q.getR())
|
||||
x = float(q.getI())
|
||||
y = float(q.getJ())
|
||||
z = float(q.getK())
|
||||
|
||||
xx = x * x
|
||||
yy = y * y
|
||||
zz = z * z
|
||||
xy = x * y
|
||||
xz = x * z
|
||||
yz = y * z
|
||||
wx = w * x
|
||||
wy = w * y
|
||||
wz = w * z
|
||||
|
||||
return np.array([
|
||||
[1.0 - 2.0 * (yy + zz), 2.0 * (xy - wz), 2.0 * (xz + wy)],
|
||||
[2.0 * (xy + wz), 1.0 - 2.0 * (xx + zz), 2.0 * (yz - wx)],
|
||||
[2.0 * (xz - wy), 2.0 * (yz + wx), 1.0 - 2.0 * (xx + yy)],
|
||||
], dtype=np.float32)
|
||||
|
||||
def create_ssbo(self):
|
||||
"""No SSBO needed — using RP default rendering."""
|
||||
return None
|
||||
|
||||
def move_object(self, global_id, delta):
|
||||
"""
|
||||
Move an object by modifying vertex positions directly.
|
||||
delta: Vec3 translation to apply.
|
||||
Uses numpy for batch vertex updates.
|
||||
"""
|
||||
import numpy as np
|
||||
|
||||
if global_id not in self.id_to_chunk:
|
||||
return
|
||||
|
||||
_, local_idx = self.id_to_chunk[global_id]
|
||||
|
||||
if local_idx not in self.vertex_index:
|
||||
return
|
||||
|
||||
# Accumulate offset
|
||||
self.position_offsets[local_idx] = self.position_offsets.get(local_idx, Vec3(0)) + delta
|
||||
offset = self.position_offsets[local_idx]
|
||||
offset_arr = np.array([offset.x, offset.y, offset.z], dtype=np.float32)
|
||||
|
||||
# Update each (geom_node, geom_idx, rows) group
|
||||
entries = self.vertex_index[local_idx]
|
||||
originals = self.original_positions[local_idx]
|
||||
|
||||
for i, (gn_np, gi, rows) in enumerate(entries):
|
||||
orig_pos = originals[i] # numpy array (N, 3)
|
||||
new_pos = orig_pos + offset_arr # vectorized add
|
||||
|
||||
gnode = gn_np.node()
|
||||
geom = gnode.modify_geom(gi)
|
||||
vdata = geom.modify_vertex_data()
|
||||
writer = GeomVertexWriter(vdata, "vertex")
|
||||
|
||||
for j in range(len(rows)):
|
||||
writer.set_row(int(rows[j]))
|
||||
writer.set_data3f(float(new_pos[j, 0]), float(new_pos[j, 1]), float(new_pos[j, 2]))
|
||||
|
||||
def get_world_pos(self, global_id):
|
||||
"""Get current world position of an object."""
|
||||
if global_id not in self.id_to_chunk:
|
||||
return Vec3(0, 0, 0)
|
||||
_, local_idx = self.id_to_chunk[global_id]
|
||||
|
||||
original_mat = self.global_transforms[global_id]
|
||||
original_pos = original_mat.get_row3(3)
|
||||
offset = self.position_offsets.get(local_idx, Vec3(0))
|
||||
|
||||
return Vec3(original_pos) + offset
|
||||
|
||||
def get_object_center(self, global_id):
|
||||
"""Get the original center position of an object (for rotation pivot)."""
|
||||
if global_id >= len(self.global_transforms):
|
||||
return Vec3(0, 0, 0)
|
||||
mat = self.global_transforms[global_id]
|
||||
return Vec3(mat.get_row3(3))
|
||||
|
||||
def get_transform(self, global_id):
|
||||
"""Get original transform."""
|
||||
if global_id >= len(self.global_transforms):
|
||||
return LMatrix4f.ident_mat()
|
||||
return self.global_transforms[global_id]
|
||||
|
||||
@property
|
||||
def transforms(self):
|
||||
return self.global_transforms
|
||||
617
ssbo_component/backup_merge_20260225_125202/ssbo_editor.py.bak
Normal file
617
ssbo_component/backup_merge_20260225_125202/ssbo_editor.py.bak
Normal file
@ -0,0 +1,617 @@
|
||||
|
||||
import sys
|
||||
import os
|
||||
import struct
|
||||
import time
|
||||
from panda3d.core import (
|
||||
Filename, loadPrcFileData, GeomVertexFormat,
|
||||
GeomVertexWriter, InternalName, Shader, Texture, SamplerState,
|
||||
Vec3, Vec4, Point2, Point3, LMatrix4f, ShaderBuffer, GeomEnums, OmniBoundingVolume, Quat,
|
||||
TransparencyAttrib, BoundingSphere, NodePath,
|
||||
GraphicsEngine, WindowProperties, FrameBufferProperties,
|
||||
GraphicsPipe, GraphicsOutput, Camera, DisplayRegion, OrthographicLens,
|
||||
BoundingBox
|
||||
)
|
||||
|
||||
import p3dimgui.backend as p3dimgui_backend
|
||||
import p3dimgui.shaders as p3dimgui_shaders
|
||||
from imgui_bundle import imgui
|
||||
from rpcore.effect import Effect
|
||||
|
||||
# Work around p3dimgui import-order issue where backend may import an unrelated
|
||||
# top-level "shaders" module and miss these globals.
|
||||
if not hasattr(p3dimgui_backend, "VERT_SHADER"):
|
||||
p3dimgui_backend.VERT_SHADER = p3dimgui_shaders.VERT_SHADER
|
||||
if not hasattr(p3dimgui_backend, "FRAG_SHADER"):
|
||||
p3dimgui_backend.FRAG_SHADER = p3dimgui_shaders.FRAG_SHADER
|
||||
|
||||
ImGuiBackend = p3dimgui_backend.ImGuiBackend
|
||||
|
||||
from .ssbo_controller import ObjectController
|
||||
|
||||
class SSBOEditor:
|
||||
"""
|
||||
SSBO Editor Component
|
||||
====================
|
||||
Encapsulates the SSBO rendering, ImGui editor, and interaction logic.
|
||||
Can be integrated into any ShowBase application using RenderPipeline.
|
||||
"""
|
||||
|
||||
def __init__(self, base_app, render_pipeline, model_path=None, font_path=None):
|
||||
self.base = base_app
|
||||
self.rp = render_pipeline
|
||||
self.controller = None
|
||||
self.model = None
|
||||
self.ssbo = None
|
||||
self.font_path = font_path
|
||||
# Picking resources may be created later when a model is loaded.
|
||||
self.pick_buffer = None
|
||||
self.pick_texture = None
|
||||
self.pick_cam = None
|
||||
self.pick_cam_np = None
|
||||
self.pick_lens = None
|
||||
|
||||
# Internal State
|
||||
self.selected_name = None
|
||||
self.selected_ids = []
|
||||
self.search_text = ""
|
||||
self.last_search_text = None
|
||||
self.filtered_nodes = []
|
||||
self.debug_mode = False
|
||||
self.keys = {}
|
||||
self._ssbo_transform_active = False
|
||||
self._ssbo_selected_local_indices = []
|
||||
self._ssbo_transform_snapshot = None
|
||||
self._ssbo_gizmo_proxy = None
|
||||
self._ssbo_proxy_start = {"pos": None, "quat": None, "scale": None}
|
||||
self._bound_transform_gizmo = None
|
||||
|
||||
# Initialize ImGui Backend if not already present
|
||||
if not hasattr(self.base, 'imgui_backend'):
|
||||
print("[SSBOEditor] Initializing ImGui Backend...")
|
||||
self.base.imgui_backend = ImGuiBackend()
|
||||
|
||||
self.load_font()
|
||||
|
||||
# Register Events
|
||||
self.base.accept("imgui-new-frame", self.draw_imgui)
|
||||
self.base.accept("f", self.focus_on_selected)
|
||||
self.base.accept("d", self.toggle_debug)
|
||||
self.base.accept("mouse1", self.on_mouse_click)
|
||||
|
||||
# Register Input Tasks
|
||||
for key in ['arrow_up', 'arrow_down', 'arrow_left', 'arrow_right', 'z', 'x']:
|
||||
self.base.accept(key, self.keys.__setitem__, [key, True])
|
||||
self.base.accept(f"{key}-up", self.keys.__setitem__, [key, False])
|
||||
|
||||
# Add Tasks
|
||||
self.base.taskMgr.add(self.update_task, "update_task")
|
||||
|
||||
# Load Model if provided
|
||||
if model_path:
|
||||
self.load_model(model_path)
|
||||
|
||||
def load_font(self):
|
||||
"""Load custom font for ImGui"""
|
||||
io = imgui.get_io()
|
||||
|
||||
# Load Chinese Glyph Ranges
|
||||
glyph_ranges = None
|
||||
try:
|
||||
if hasattr(io.fonts, 'get_glyph_ranges_chinese_full'):
|
||||
glyph_ranges = io.fonts.get_glyph_ranges_chinese_full()
|
||||
elif hasattr(io.fonts, 'get_glyph_ranges_chinese_simplified_common'):
|
||||
glyph_ranges = io.fonts.get_glyph_ranges_chinese_simplified_common()
|
||||
except Exception as e:
|
||||
print(f"[SSBOEditor] Warning: Could not get Chinese glyph ranges: {e}")
|
||||
|
||||
try:
|
||||
if self.font_path and os.path.exists(self.font_path):
|
||||
io.fonts.clear()
|
||||
# If glyph_ranges is None, it uses default (Basic Latin)
|
||||
if glyph_ranges:
|
||||
io.fonts.add_font_from_file_ttf(self.font_path, 18.0, glyph_ranges=glyph_ranges)
|
||||
else:
|
||||
io.fonts.add_font_from_file_ttf(self.font_path, 18.0)
|
||||
else:
|
||||
# Fallback to default or common font
|
||||
default_font = os.path.join(os.path.dirname(os.path.dirname(__file__)), "font", "msyh.ttc")
|
||||
if os.path.exists(default_font):
|
||||
io.fonts.clear()
|
||||
io.fonts.add_font_from_file_ttf(default_font, 18.0, glyph_ranges=glyph_ranges)
|
||||
else:
|
||||
io.fonts.clear()
|
||||
io.fonts.add_font_default()
|
||||
except Exception as e:
|
||||
print(f"[SSBOEditor] Font load error: {e}")
|
||||
io.fonts.clear()
|
||||
io.fonts.add_font_default()
|
||||
|
||||
def load_model(self, model_path):
|
||||
"""Load and process a model — NO custom shader, uses RP default rendering."""
|
||||
print(f"[SSBOEditor] Loading model: {model_path}")
|
||||
fn = Filename.fromOsSpecific(model_path)
|
||||
self.model = self.base.loader.loadModel(fn)
|
||||
|
||||
self.controller = ObjectController()
|
||||
count = self.controller.bake_ids_and_collect(self.model)
|
||||
self._ssbo_transform_active = False
|
||||
self._ssbo_selected_local_indices = []
|
||||
self._ssbo_transform_snapshot = None
|
||||
self._cleanup_ssbo_proxy()
|
||||
|
||||
self.model.reparent_to(self.base.render)
|
||||
|
||||
# NO rp.set_effect() — use RP default rendering for max FPS
|
||||
# NO SSBO creation — vertex positions are baked
|
||||
|
||||
# Setup GPU Picking (uses simple vertex-color shader)
|
||||
self.setup_gpu_picking()
|
||||
|
||||
print(f"[SSBOEditor] Model loaded. Total objects: {count}")
|
||||
|
||||
# No custom effect needed — RP default rendering for maximum FPS
|
||||
|
||||
def _inject_ssbo_into_shadow_state(self, effect_path):
|
||||
"""Inject SSBO inputs into RP shadow tag state"""
|
||||
try:
|
||||
if not hasattr(self.rp.tag_mgr, 'containers'): return
|
||||
|
||||
shadow_container = self.rp.tag_mgr.containers.get("shadow")
|
||||
if not shadow_container: return
|
||||
|
||||
tag_value = self.model.get_tag(shadow_container.tag_name)
|
||||
if not tag_value: return
|
||||
|
||||
effect = Effect.load(effect_path, {})
|
||||
if effect is None: return
|
||||
|
||||
shadow_shader = effect.get_shader_obj("shadow")
|
||||
if shadow_shader is None: return
|
||||
|
||||
# Since inputs are now on Nodes (Chunks), we just need to ensure the shader is applied.
|
||||
# extra_inputs is no longer needed if the inputs are on the nodes themselves?
|
||||
# Wait, RP might override state.
|
||||
# But specific shader inputs on NodePath have priority over State inputs usually?
|
||||
# Let's try applying without extra inputs first.
|
||||
|
||||
self.rp.tag_mgr.apply_state(
|
||||
"shadow", self.model, shadow_shader,
|
||||
tag_value, 65)
|
||||
|
||||
print(f"[SSBO Shadow] Re-applied shadow state (tag='{tag_value}')")
|
||||
except Exception as e:
|
||||
print(f"[SSBO Shadow] Error injecting shadow state: {e}")
|
||||
|
||||
def setup_gpu_picking(self):
|
||||
"""Setup GPU Picking (Basic implementation)"""
|
||||
# ... (Buffer setup code remains same) ...
|
||||
win_props = WindowProperties()
|
||||
win_props.set_size(1, 1)
|
||||
fb_props = FrameBufferProperties()
|
||||
fb_props.set_rgba_bits(8, 8, 8, 8)
|
||||
fb_props.set_depth_bits(16)
|
||||
|
||||
self.pick_buffer = self.base.graphicsEngine.make_output(
|
||||
self.base.pipe, "pick_buffer", -100,
|
||||
fb_props, win_props,
|
||||
GraphicsPipe.BF_refuse_window,
|
||||
self.base.win.get_gsg(), self.base.win
|
||||
)
|
||||
|
||||
if not self.pick_buffer:
|
||||
print("[GPU Picking] Failed to create buffer!")
|
||||
return
|
||||
|
||||
self.pick_texture = Texture()
|
||||
self.pick_texture.set_minfilter(Texture.FT_nearest)
|
||||
self.pick_texture.set_magfilter(Texture.FT_nearest)
|
||||
self.pick_buffer.add_render_texture(self.pick_texture, GraphicsOutput.RTM_copy_ram)
|
||||
|
||||
self.pick_cam = Camera("pick_camera")
|
||||
self.pick_cam_np = self.base.cam.attach_new_node(self.pick_cam)
|
||||
self.pick_lens = self.base.camLens.make_copy()
|
||||
self.pick_cam.set_lens(self.pick_lens)
|
||||
|
||||
dr = self.pick_buffer.make_display_region()
|
||||
dr.set_camera(self.pick_cam_np)
|
||||
|
||||
# Load pick shader
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
pick_vert = os.path.join(current_dir, "shaders", "pick_id.vert")
|
||||
pick_frag = os.path.join(current_dir, "shaders", "pick_id.frag")
|
||||
|
||||
pick_vert = Filename.fromOsSpecific(pick_vert).getFullpath()
|
||||
pick_frag = Filename.fromOsSpecific(pick_frag).getFullpath()
|
||||
|
||||
try:
|
||||
pick_shader = Shader.load(
|
||||
Shader.SL_GLSL,
|
||||
pick_vert,
|
||||
pick_frag
|
||||
)
|
||||
self.pick_cam.set_scene(self.model)
|
||||
initial_state = NodePath("initial")
|
||||
initial_state.set_shader(pick_shader, 100)
|
||||
# Remove global SSBO input, Chunks have their own inputs
|
||||
# initial_state.set_shader_input("transforms", ssbo)
|
||||
self.pick_cam.set_initial_state(initial_state.get_state())
|
||||
except Exception as e:
|
||||
print(f"[GPU Picking] Warning: pick shaders failed to load: {e}")
|
||||
print("Picking disabled.")
|
||||
return
|
||||
|
||||
self.pick_buffer.set_active(False)
|
||||
self.pick_buffer.set_clear_color(Vec4(0, 0, 0, 0))
|
||||
self.pick_buffer.set_clear_color_active(True)
|
||||
|
||||
def pick_object(self, mx, my):
|
||||
if (not self.pick_buffer or not self.pick_texture or not self.pick_lens or
|
||||
not self.controller or not self.model):
|
||||
return False
|
||||
|
||||
self.pick_lens.set_fov(0.1)
|
||||
self.pick_lens.set_film_offset(0, 0)
|
||||
self.pick_cam.set_lens(self.pick_lens)
|
||||
|
||||
near_point = Point3()
|
||||
far_point = Point3()
|
||||
self.base.camLens.extrude(Point2(mx, my), near_point, far_point)
|
||||
|
||||
self.pick_cam_np.set_pos(0, 0, 0)
|
||||
self.pick_cam_np.look_at(far_point)
|
||||
|
||||
self.pick_buffer.set_active(True)
|
||||
self.base.graphicsEngine.render_frame()
|
||||
self.pick_buffer.set_active(False)
|
||||
self.base.graphicsEngine.extract_texture_data(
|
||||
self.pick_texture, self.base.win.get_gsg()
|
||||
)
|
||||
|
||||
ram_image = self.pick_texture.get_ram_image_as("RGBA")
|
||||
if ram_image:
|
||||
data = memoryview(ram_image)
|
||||
if len(data) >= 4:
|
||||
r, g, b, a = data[0], data[1], data[2], data[3]
|
||||
if a > 0:
|
||||
hit_id = r + (g << 8)
|
||||
node_key = self.controller.id_to_name.get(hit_id)
|
||||
if node_key:
|
||||
print(f"[Pick] Hit: ID={hit_id} -> {node_key}")
|
||||
self.select_node(node_key)
|
||||
return True
|
||||
|
||||
self.selected_name = None
|
||||
self.selected_ids = []
|
||||
return False
|
||||
|
||||
|
||||
def on_mouse_click(self):
|
||||
io = imgui.get_io()
|
||||
if io.want_capture_mouse:
|
||||
return
|
||||
if self.base.mouseWatcherNode.has_mouse():
|
||||
mpos = self.base.mouseWatcherNode.get_mouse()
|
||||
# If clicking gizmo, skip SSBO pick.
|
||||
if self._try_start_gizmo_drag(mpos.x, mpos.y):
|
||||
return
|
||||
prev_selected = self.selected_name
|
||||
hit = self.pick_object(mpos.x, mpos.y)
|
||||
# SSBO miss must clear current selection.
|
||||
if not hit:
|
||||
self._sync_selection_none()
|
||||
# Always fallback to legacy ray pick when SSBO misses.
|
||||
# This keeps scene selection usable if SSBO ID mapping is incomplete.
|
||||
self._fallback_legacy_pick(mpos.x, mpos.y)
|
||||
elif prev_selected != self.selected_name:
|
||||
# Ensure selection visuals refresh when SSBO selection changes.
|
||||
self._sync_selection_from_key(self.selected_name)
|
||||
|
||||
def toggle_debug(self):
|
||||
self.debug_mode = not self.debug_mode
|
||||
|
||||
def clear_selection(self):
|
||||
pass # No selection mask texture needed without custom shader
|
||||
|
||||
def update_selection_mask(self):
|
||||
pass # No selection mask texture needed without custom shader
|
||||
|
||||
def select_node(self, key):
|
||||
if not self.controller or key not in self.controller.name_to_ids:
|
||||
return
|
||||
self.selected_name = key
|
||||
self.selected_ids = self.controller.name_to_ids.get(key, [])
|
||||
self._sync_selection_from_key(key)
|
||||
|
||||
def _sync_selection_from_key(self, key):
|
||||
"""Sync SSBO picked key to legacy SelectionSystem."""
|
||||
try:
|
||||
if hasattr(self.base, "selection") and self.base.selection:
|
||||
kind, target = self._resolve_ssbo_selection_target(key)
|
||||
if kind == "proxy":
|
||||
target_np = target
|
||||
else:
|
||||
target_np = target if target is not None else self.model
|
||||
if target_np is None or target_np.isEmpty():
|
||||
target_np = self.model
|
||||
self.base.selection.updateSelection(target_np)
|
||||
except Exception as e:
|
||||
print(f"[SSBOEditor] selection sync failed: {e}")
|
||||
|
||||
def _sync_selection_none(self):
|
||||
"""Clear legacy SelectionSystem selection."""
|
||||
try:
|
||||
self._ssbo_transform_active = False
|
||||
self._ssbo_selected_local_indices = []
|
||||
self._ssbo_transform_snapshot = None
|
||||
self._cleanup_ssbo_proxy()
|
||||
if hasattr(self.base, "selection") and self.base.selection:
|
||||
self.base.selection.updateSelection(None)
|
||||
except Exception as e:
|
||||
print(f"[SSBOEditor] clear selection sync failed: {e}")
|
||||
|
||||
def bind_transform_gizmo(self, transform_gizmo):
|
||||
"""Bind TransformGizmo drag hooks so SSBO sub-object transforms can follow gizmo."""
|
||||
self._bound_transform_gizmo = transform_gizmo
|
||||
if not transform_gizmo:
|
||||
return
|
||||
hooks = {
|
||||
"move": {
|
||||
"drag_start": [self._on_ssbo_gizmo_drag_start],
|
||||
"drag_move": [self._on_ssbo_gizmo_drag_move],
|
||||
"drag_end": [self._on_ssbo_gizmo_drag_end],
|
||||
},
|
||||
"rotate": {
|
||||
"drag_start": [self._on_ssbo_gizmo_drag_start],
|
||||
"drag_move": [self._on_ssbo_gizmo_drag_move],
|
||||
"drag_end": [self._on_ssbo_gizmo_drag_end],
|
||||
},
|
||||
"scale": {
|
||||
"drag_start": [self._on_ssbo_gizmo_drag_start],
|
||||
"drag_move": [self._on_ssbo_gizmo_drag_move],
|
||||
"drag_end": [self._on_ssbo_gizmo_drag_end],
|
||||
},
|
||||
}
|
||||
try:
|
||||
if hasattr(transform_gizmo, "set_event_hooks"):
|
||||
transform_gizmo.set_event_hooks(hooks, replace=False)
|
||||
print("[SSBOEditor] TransformGizmo hooks bound")
|
||||
except Exception as e:
|
||||
print(f"[SSBOEditor] bind transform gizmo failed: {e}")
|
||||
|
||||
def _resolve_ssbo_selection_target(self, key):
|
||||
"""Resolve selected SSBO key to proxy node (preferred) or regular node."""
|
||||
self._ssbo_transform_active = False
|
||||
self._ssbo_transform_snapshot = None
|
||||
self._ssbo_selected_local_indices = []
|
||||
|
||||
if not self.controller or not key:
|
||||
return "node", self.model
|
||||
global_ids = self.controller.name_to_ids.get(key, [])
|
||||
local_indices = self.controller.get_local_indices_from_global_ids(global_ids)
|
||||
self._ssbo_selected_local_indices = local_indices
|
||||
if local_indices:
|
||||
print(f"[SSBOEditor] selection locals={len(local_indices)} key={key}")
|
||||
center = self.controller.get_selection_center(local_indices)
|
||||
proxy = self._ensure_ssbo_proxy(center)
|
||||
return "proxy", proxy
|
||||
target_np = self.controller.key_to_node.get(key)
|
||||
if target_np is None or target_np.isEmpty():
|
||||
target_np = self.model
|
||||
return "node", target_np
|
||||
|
||||
def _ensure_ssbo_proxy(self, center):
|
||||
if self._ssbo_gizmo_proxy is None or self._ssbo_gizmo_proxy.isEmpty():
|
||||
self._ssbo_gizmo_proxy = self.base.render.attach_new_node("ssbo_transform_proxy")
|
||||
self._ssbo_gizmo_proxy.setTag("is_ssbo_proxy", "1")
|
||||
self._ssbo_gizmo_proxy.set_pos(center)
|
||||
self._ssbo_gizmo_proxy.set_hpr(0, 0, 0)
|
||||
self._ssbo_gizmo_proxy.set_scale(1, 1, 1)
|
||||
return self._ssbo_gizmo_proxy
|
||||
|
||||
def _cleanup_ssbo_proxy(self):
|
||||
if self._ssbo_gizmo_proxy and not self._ssbo_gizmo_proxy.isEmpty():
|
||||
self._ssbo_gizmo_proxy.removeNode()
|
||||
self._ssbo_gizmo_proxy = None
|
||||
|
||||
def _on_ssbo_gizmo_drag_start(self, payload):
|
||||
try:
|
||||
target = payload.get("target") if payload else None
|
||||
if not target or target != self._ssbo_gizmo_proxy:
|
||||
self._ssbo_transform_active = False
|
||||
return
|
||||
if not self.controller or not self._ssbo_selected_local_indices:
|
||||
self._ssbo_transform_active = False
|
||||
return
|
||||
self._ssbo_transform_snapshot = self.controller.begin_transform_session(
|
||||
self._ssbo_selected_local_indices
|
||||
)
|
||||
self._ssbo_proxy_start = {
|
||||
"pos": Vec3(target.getPos(self.base.render)),
|
||||
"quat": Quat(target.getQuat(self.base.render)),
|
||||
"scale": Vec3(target.getScale()),
|
||||
}
|
||||
self._ssbo_transform_active = True
|
||||
print(f"[SSBOEditor] drag_start locals={len(self._ssbo_selected_local_indices)}")
|
||||
except Exception as e:
|
||||
self._ssbo_transform_active = False
|
||||
print(f"[SSBOEditor] drag_start bridge failed: {e}")
|
||||
|
||||
def _on_ssbo_gizmo_drag_move(self, payload):
|
||||
try:
|
||||
if not self._ssbo_transform_active:
|
||||
return
|
||||
target = payload.get("target") if payload else None
|
||||
if not target or target != self._ssbo_gizmo_proxy:
|
||||
return
|
||||
start_pos = self._ssbo_proxy_start.get("pos")
|
||||
start_quat = self._ssbo_proxy_start.get("quat")
|
||||
start_scale = self._ssbo_proxy_start.get("scale")
|
||||
if start_pos is None or start_quat is None or start_scale is None:
|
||||
return
|
||||
|
||||
curr_pos = Vec3(target.getPos(self.base.render))
|
||||
curr_quat = Quat(target.getQuat(self.base.render))
|
||||
curr_scale = Vec3(target.getScale())
|
||||
|
||||
delta_pos = curr_pos - start_pos
|
||||
inv_start_quat = Quat(start_quat)
|
||||
inv_start_quat.invertInPlace()
|
||||
delta_quat = curr_quat * inv_start_quat
|
||||
delta_scale = Vec3(
|
||||
curr_scale.x / start_scale.x if abs(start_scale.x) > 1e-8 else 1.0,
|
||||
curr_scale.y / start_scale.y if abs(start_scale.y) > 1e-8 else 1.0,
|
||||
curr_scale.z / start_scale.z if abs(start_scale.z) > 1e-8 else 1.0,
|
||||
)
|
||||
|
||||
self.controller.apply_transform_session(
|
||||
self._ssbo_transform_snapshot,
|
||||
delta_pos,
|
||||
delta_quat,
|
||||
delta_scale,
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"[SSBOEditor] drag_move bridge failed: {e}")
|
||||
|
||||
def _on_ssbo_gizmo_drag_end(self, payload):
|
||||
try:
|
||||
if self._ssbo_transform_active:
|
||||
print(f"[SSBOEditor] drag_end locals={len(self._ssbo_selected_local_indices)}")
|
||||
self._ssbo_transform_active = False
|
||||
self._ssbo_transform_snapshot = None
|
||||
except Exception as e:
|
||||
print(f"[SSBOEditor] drag_end bridge failed: {e}")
|
||||
|
||||
def _fallback_legacy_pick(self, mx, my):
|
||||
"""Fallback to legacy ray picking when SSBO misses."""
|
||||
try:
|
||||
if not hasattr(self.base, "event_handler") or not self.base.event_handler:
|
||||
return
|
||||
win_w, win_h = self.base.win.getSize()
|
||||
x = (mx + 1.0) * 0.5 * win_w
|
||||
y = (1.0 - my) * 0.5 * win_h
|
||||
self.base.event_handler.mousePressEventLeft({"x": x, "y": y})
|
||||
except Exception as e:
|
||||
print(f"[SSBOEditor] legacy fallback pick failed: {e}")
|
||||
|
||||
def _try_start_gizmo_drag(self, mouse_x=None, mouse_y=None):
|
||||
"""Try to start gizmo drag using the existing SelectionSystem pipeline."""
|
||||
try:
|
||||
new_transform = getattr(self.base, "newTransform", None)
|
||||
if (
|
||||
new_transform is not None and
|
||||
mouse_x is not None and
|
||||
mouse_y is not None and
|
||||
self._is_mouse_on_new_gizmo(new_transform, mouse_x, mouse_y)
|
||||
):
|
||||
return True
|
||||
selection = getattr(self.base, "selection", None)
|
||||
if not selection or not selection.gizmo:
|
||||
return False
|
||||
win_w, win_h = self.base.win.getSize()
|
||||
mpos = self.base.mouseWatcherNode.get_mouse()
|
||||
x = (mpos.x + 1.0) * 0.5 * win_w
|
||||
y = (1.0 - mpos.y) * 0.5 * win_h
|
||||
|
||||
axis = selection.gizmoHighlightAxis or selection.checkGizmoClick(x, y)
|
||||
if axis:
|
||||
selection.startGizmoDrag(axis, x, y)
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"[SSBOEditor] gizmo drag start failed: {e}")
|
||||
return False
|
||||
|
||||
def _is_mouse_on_new_gizmo(self, new_transform, mouse_x, mouse_y):
|
||||
"""Refresh and query hover state for TransformGizmo on current click position."""
|
||||
try:
|
||||
mouse_pos = Point3(mouse_x, mouse_y, 0.0)
|
||||
for gizmo_name in ("move_gizmo", "rotate_gizmo", "scale_gizmo"):
|
||||
gizmo = getattr(new_transform, gizmo_name, None)
|
||||
if not gizmo or not getattr(gizmo, "attached", False):
|
||||
continue
|
||||
hover_updater = getattr(gizmo, "_update_hover_highlight", None)
|
||||
if callable(hover_updater):
|
||||
hover_updater(mouse_pos)
|
||||
return bool(getattr(new_transform, "is_hovering", False))
|
||||
except Exception as e:
|
||||
print(f"[SSBOEditor] new gizmo hover check failed: {e}")
|
||||
return False
|
||||
|
||||
def focus_on_selected(self):
|
||||
if self.selected_name and self.selected_ids:
|
||||
first_id = self.selected_ids[0]
|
||||
pos = self.controller.get_world_pos(first_id)
|
||||
dist = 100
|
||||
self.base.camera.set_pos(pos.x, pos.y - dist, pos.z + dist * 0.5)
|
||||
self.base.camera.look_at(pos)
|
||||
|
||||
def draw_imgui(self):
|
||||
if not self.controller: return
|
||||
|
||||
imgui.set_next_window_pos((10, 10), imgui.Cond_.first_use_ever)
|
||||
imgui.set_next_window_size((350, 600), imgui.Cond_.first_use_ever)
|
||||
|
||||
expanded, opened = imgui.begin("Scene Tree (Component)")
|
||||
if expanded:
|
||||
imgui.text(f"FPS: {globalClock.getAverageFrameRate():.1f}")
|
||||
imgui.separator()
|
||||
|
||||
changed, self.search_text = imgui.input_text("Search", self.search_text, 256)
|
||||
|
||||
if imgui.begin_child("ObjectList", (0, 380), child_flags=imgui.ChildFlags_.borders):
|
||||
if self.search_text != self.last_search_text:
|
||||
self.last_search_text = self.search_text
|
||||
search_lower = self.search_text.lower()
|
||||
self.filtered_nodes = []
|
||||
for key in self.controller.node_list:
|
||||
display = self.controller.display_names.get(key, key.split('/')[-1])
|
||||
if not search_lower or (search_lower in display.lower() or search_lower in key.lower()):
|
||||
geom_count = len(self.controller.name_to_ids.get(key, []))
|
||||
self.filtered_nodes.append((key, display, geom_count))
|
||||
|
||||
# If list is empty initially (no search), show all
|
||||
if not self.search_text and not self.filtered_nodes:
|
||||
if len(self.filtered_nodes) != len(self.controller.node_list):
|
||||
self.filtered_nodes = [(k, self.controller.display_names.get(k, k), len(self.controller.name_to_ids.get(k,[]))) for k in self.controller.node_list]
|
||||
|
||||
count = len(self.filtered_nodes)
|
||||
clipper = imgui.ListClipper()
|
||||
clipper.begin(count)
|
||||
while clipper.step():
|
||||
for i in range(clipper.display_start, clipper.display_end):
|
||||
key, display, geom_count = self.filtered_nodes[i]
|
||||
label = f"{display} ({geom_count})"
|
||||
is_selected = (key == self.selected_name)
|
||||
if imgui.selectable(label, is_selected)[0]:
|
||||
self.select_node(key)
|
||||
imgui.end_child()
|
||||
|
||||
imgui.separator()
|
||||
if self.selected_name:
|
||||
imgui.text_colored((1, 0.8, 0.2, 1), f"Selected: {self.selected_name}")
|
||||
if imgui.button("Focus (F)"): self.focus_on_selected()
|
||||
imgui.end()
|
||||
|
||||
# swap_transforms_task removed - motion blur disabled for performance
|
||||
|
||||
def update_task(self, task):
|
||||
dt = globalClock.getDt()
|
||||
io = imgui.get_io()
|
||||
|
||||
if io.want_capture_keyboard: return task.cont
|
||||
|
||||
if self.selected_ids and self.controller:
|
||||
speed = 50 * dt
|
||||
acc = Vec3(0, 0, 0)
|
||||
if self.keys.get('arrow_up'): acc.z += speed
|
||||
if self.keys.get('arrow_down'): acc.z -= speed
|
||||
if self.keys.get('arrow_left'): acc.x -= speed
|
||||
if self.keys.get('arrow_right'): acc.x += speed
|
||||
if self.keys.get('z'): acc.y += speed
|
||||
if self.keys.get('x'): acc.y -= speed
|
||||
|
||||
if acc.length_squared() > 0:
|
||||
for idx in self.selected_ids:
|
||||
self.controller.move_object(idx, acc)
|
||||
|
||||
return task.cont
|
||||
@ -44,6 +44,34 @@ class ObjectController:
|
||||
self.model = None
|
||||
self.pick_model = None
|
||||
self.chunk_node = None # Single chunk node
|
||||
self._source_model_name = ""
|
||||
self._source_model_stem = ""
|
||||
|
||||
def _build_original_hierarchy_key(self, np, model_root):
|
||||
"""Capture hierarchy path before flatten/reparent."""
|
||||
parts = []
|
||||
cur = np
|
||||
while cur and not cur.is_empty() and cur != model_root:
|
||||
name = cur.get_name() or ""
|
||||
if name:
|
||||
parts.append(name)
|
||||
cur = cur.get_parent()
|
||||
parts.reverse()
|
||||
if not parts:
|
||||
return np.get_name() or "Unnamed"
|
||||
return "/".join(parts)
|
||||
|
||||
def _is_wrapper_segment(self, segment):
|
||||
s = (segment or "").strip().lower()
|
||||
if not s:
|
||||
return True
|
||||
if s in ("root",):
|
||||
return True
|
||||
if self._source_model_name and s == self._source_model_name:
|
||||
return True
|
||||
if self._source_model_stem and s == self._source_model_stem:
|
||||
return True
|
||||
return False
|
||||
|
||||
def bake_ids_and_collect(self, model):
|
||||
"""
|
||||
@ -74,6 +102,9 @@ class ObjectController:
|
||||
self.virtual_tree = None
|
||||
self.virtual_tree_meta = None
|
||||
self.pick_model = None
|
||||
model_name = (model.get_name() or "").strip()
|
||||
self._source_model_name = model_name.lower()
|
||||
self._source_model_stem = model_name.rsplit(".", 1)[0].lower() if "." in model_name else model_name.lower()
|
||||
|
||||
global_id_counter = 0
|
||||
chunk_key = model.get_name() or "default"
|
||||
@ -82,6 +113,11 @@ class ObjectController:
|
||||
self.chunk_node = model
|
||||
self.chunks[chunk_key] = {'node': model, 'base_id': 0}
|
||||
|
||||
# Cache original hierarchy path BEFORE flatten/reparent.
|
||||
original_keys = {}
|
||||
for np in geom_nodes:
|
||||
original_keys[id(np)] = self._build_original_hierarchy_key(np, model)
|
||||
|
||||
# Flatten hierarchy
|
||||
for np in geom_nodes:
|
||||
np.wrt_reparent_to(model)
|
||||
@ -99,7 +135,7 @@ class ObjectController:
|
||||
np = new_np
|
||||
gnode = np.node()
|
||||
|
||||
unique_key = str(np)
|
||||
unique_key = original_keys.get(id(np), str(np))
|
||||
display_name = np.get_name() or f"Object_{global_id_counter}"
|
||||
|
||||
if unique_key not in self.name_to_ids:
|
||||
@ -191,6 +227,8 @@ class ObjectController:
|
||||
"children": {},
|
||||
"leaf_key": None,
|
||||
"display_name": "",
|
||||
"group_key": None,
|
||||
"aggregate_ids": [],
|
||||
}
|
||||
max_depth = 0
|
||||
leaf_count = 0
|
||||
@ -199,6 +237,9 @@ class ObjectController:
|
||||
if not key:
|
||||
continue
|
||||
parts = [p for p in str(key).split("/") if p]
|
||||
# Hide importer wrapper roots (e.g. model filename / ROOT) but keep real object hierarchy.
|
||||
while len(parts) > 1 and self._is_wrapper_segment(parts[0]):
|
||||
parts = parts[1:]
|
||||
if not parts:
|
||||
continue
|
||||
max_depth = max(max_depth, len(parts))
|
||||
@ -214,6 +255,8 @@ class ObjectController:
|
||||
"children": {},
|
||||
"leaf_key": None,
|
||||
"display_name": part,
|
||||
"group_key": None,
|
||||
"aggregate_ids": [],
|
||||
}
|
||||
cursor["children"][part] = child
|
||||
cursor = child
|
||||
@ -222,6 +265,32 @@ class ObjectController:
|
||||
cursor["display_name"] = self.display_names.get(key, part)
|
||||
leaf_count += 1
|
||||
|
||||
# Build aggregate id groups for non-leaf selection (parent moves children).
|
||||
def _aggregate(node):
|
||||
agg = []
|
||||
leaf_key = node.get("leaf_key")
|
||||
if leaf_key:
|
||||
agg.extend(self.name_to_ids.get(leaf_key, []))
|
||||
for child in node.get("children", {}).values():
|
||||
agg.extend(_aggregate(child))
|
||||
# Stable unique ids
|
||||
uniq = []
|
||||
seen = set()
|
||||
for gid in agg:
|
||||
if gid in seen:
|
||||
continue
|
||||
seen.add(gid)
|
||||
uniq.append(gid)
|
||||
node["aggregate_ids"] = uniq
|
||||
if node.get("path") and uniq:
|
||||
group_key = f"__group__::{node['path']}"
|
||||
node["group_key"] = group_key
|
||||
self.name_to_ids[group_key] = uniq
|
||||
self.display_names[group_key] = node.get("display_name", node.get("name", ""))
|
||||
return uniq
|
||||
|
||||
_aggregate(root)
|
||||
|
||||
self.virtual_tree = root
|
||||
self.virtual_tree_meta = {"max_depth": max_depth, "leaf_count": leaf_count}
|
||||
return root
|
||||
|
||||
@ -528,6 +528,7 @@ class EditorPanels:
|
||||
path = tree_node.get("path", "")
|
||||
display = tree_node.get("display_name") or tree_node.get("name") or path
|
||||
leaf_key = tree_node.get("leaf_key")
|
||||
group_key = tree_node.get("group_key")
|
||||
children = tree_node.get("children", {}) or {}
|
||||
|
||||
label = f"{display}##{unique_id_prefix}_{path}"
|
||||
@ -543,8 +544,19 @@ class EditorPanels:
|
||||
|
||||
# Non-leaf: tree node only for hierarchy display.
|
||||
opened = imgui.tree_node(label)
|
||||
# Clicking non-leaf row selects its aggregate group so parent transform affects children.
|
||||
if group_key and imgui.is_item_clicked(0):
|
||||
ssbo_editor.select_node(group_key)
|
||||
if hasattr(self.app, "lui_manager"):
|
||||
self.app.lui_manager.selected_index = -1
|
||||
if opened:
|
||||
# If this node is also a selectable leaf, render selectable entry first.
|
||||
if group_key:
|
||||
is_group_selected = (getattr(ssbo_editor, "selected_name", None) == group_key)
|
||||
if imgui.selectable(f"[整体] {display}##group_{unique_id_prefix}_{path}", is_group_selected)[0]:
|
||||
ssbo_editor.select_node(group_key)
|
||||
if hasattr(self.app, "lui_manager"):
|
||||
self.app.lui_manager.selected_index = -1
|
||||
if leaf_key:
|
||||
is_selected = (getattr(ssbo_editor, "selected_name", None) == leaf_key)
|
||||
if imgui.selectable(f"[节点] {display}##leaf_{unique_id_prefix}_{path}", is_selected)[0]:
|
||||
@ -904,6 +916,27 @@ class EditorPanels:
|
||||
if hasattr(self.app, 'selection') and self.app.selection and hasattr(self.app.selection, 'selectedNode'):
|
||||
selected_node = self.app.selection.selectedNode
|
||||
|
||||
# SSBO mode may select a proxy node for gizmo operations.
|
||||
# Resolve proxy back to a real scene node so property panel stays meaningful.
|
||||
try:
|
||||
if (selected_node and not selected_node.isEmpty() and
|
||||
selected_node.hasTag("is_ssbo_proxy")):
|
||||
ssbo_editor = getattr(self.app, "ssbo_editor", None)
|
||||
controller = getattr(ssbo_editor, "controller", None) if ssbo_editor else None
|
||||
if ssbo_editor and controller:
|
||||
resolved = None
|
||||
if getattr(ssbo_editor, "selected_ids", None):
|
||||
first_gid = ssbo_editor.selected_ids[0]
|
||||
key = controller.id_to_name.get(first_gid)
|
||||
if key:
|
||||
resolved = controller.key_to_node.get(key)
|
||||
if (resolved is None or resolved.isEmpty()) and getattr(ssbo_editor, "selected_name", None):
|
||||
resolved = controller.key_to_node.get(ssbo_editor.selected_name)
|
||||
if resolved and not resolved.isEmpty():
|
||||
selected_node = resolved
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if selected_node and not selected_node.isEmpty():
|
||||
self._draw_node_properties(selected_node)
|
||||
else:
|
||||
|
||||
Loading…
Reference in New Issue
Block a user