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 time class ObjectController: """ 混合架构控制器 (Chunked Static + Dynamic Editing) ================================================ - 默认: 每个 chunk 使用 flatten 后的静态表示 - 编辑: 被选中对象所属 chunk 切换为动态表示,直接改 NodePath 变换 - 提交: 离开 chunk 时仅重建该 chunk 的静态表示 """ 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 = [] self.position_offsets = {} self.vertex_index = {} self.original_positions = {} self.local_to_global_id = {} self.local_transform_state = {} self.local_transform_base_positions = {} self.pick_vertex_index = {} self.virtual_tree = None self.virtual_tree_meta = None self.model = None self.pick_model = None self.lightweight_flat_mode = False self.supports_gpu_picking = True 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 self.id_to_geom_index = {} # global_id -> owner GeomNode geom index # 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).""" if not self.model: return LMatrix4f.ident_mat() try: if self.model.isEmpty(): return LMatrix4f.ident_mat() except Exception: try: if self.model.is_empty(): return LMatrix4f.ident_mat() except Exception: pass try: return LMatrix4f(self.model.getNetTransform().getMat()) except Exception: try: # snake_case fallback in newer Panda3D bindings return LMatrix4f(self.model.get_net_transform().get_mat()) except Exception: pass try: top = self.model.getTop() if top and not top.isEmpty(): return LMatrix4f(self.model.getMat(top)) except Exception: pass try: return LMatrix4f(self.model.getMat()) except Exception: return LMatrix4f.ident_mat() def get_model_world_mat(self): """Public accessor for current model net transform matrix.""" return self._get_model_world_mat() def _local_point_to_world(self, local_pos): """Convert a local-space point to world-space based on model net transform.""" mat = self._get_model_world_mat() p = Point3(float(local_pos.x), float(local_pos.y), float(local_pos.z)) wp = mat.xformPoint(p) return Vec3(wp.x, wp.y, wp.z) def _world_vec_to_local(self, world_vec): """Convert a world-space vector to model-local space.""" mat = self._get_model_world_mat() inv = LMatrix4f(mat) try: inv.invertInPlace() except Exception: try: inv.invert_in_place() except Exception: return Vec3(world_vec) v = Vec3(world_vec) lv = inv.xformVec(v) return Vec3(lv.x, lv.y, lv.z) def world_vector_to_model_local(self, world_vec): """Public converter from world delta vector to model-local delta vector.""" return self._world_vec_to_local(world_vec) def get_model_world_quat(self): """Return current model world quaternion.""" if not self.model: return Quat.identQuat() try: if self.model.isEmpty(): return Quat.identQuat() except Exception: pass try: top = self.model.getTop() if top and not top.isEmpty(): return Quat(self.model.getQuat(top)) except Exception: pass try: return Quat(self.model.getQuat()) except Exception: return Quat.identQuat() def world_quat_delta_to_model_local(self, delta_quat_world): """ Convert world-space delta quaternion to model-local delta quaternion. local = inv(model_world_rot) * world_delta * model_world_rot """ if delta_quat_world is None: return Quat.identQuat() model_q = self.get_model_world_quat() inv_model_q = Quat(model_q) inv_model_q.invertInPlace() local_q = inv_model_q * Quat(delta_quat_world) * model_q local_q.normalize() return local_q 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 _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 redundant wrapper nodes like ROOT so the UI shows the imported model hierarchy instead of source-format packaging nodes. """ node = self.tree_nodes.get(key) if not node: return False name = (node["name"] or "").strip().lower() if name == "root": return len(node["children"]) > 0 # Hide scene-package/runtime wrappers in virtual tree: # - scene.bam container # - chunk_* dynamic/static batching nodes # - modelCollision_* helper nodes if name.endswith(".bam") and len(node["children"]) > 0: return True if name.startswith("chunk_"): return True if name.startswith("modelcollision_"): return True return False def get_runtime_structure_stats(self): """Summarize hybrid runtime structure to diagnose idle-state regressions.""" stats = { "chunks_total": 0, "chunks_dynamic_enabled": 0, "chunk_static_nodes": 0, "chunk_dynamic_nodes": 0, "chunk_static_visible": 0, "chunk_static_stashed": 0, "chunk_dynamic_visible": 0, "chunk_dynamic_stashed": 0, "dynamic_object_nodes": 0, "pick_nodes": 0, "model_descendants": 0, "pick_descendants": 0, "model_geom_nodes": 0, "pick_geom_nodes": 0, } chunks = getattr(self, "chunks", {}) or {} stats["chunks_total"] = len(chunks) for chunk in chunks.values(): if not isinstance(chunk, dict): continue if chunk.get("dynamic_enabled"): stats["chunks_dynamic_enabled"] += 1 dynamic_np = chunk.get("dynamic_np") static_np = chunk.get("static_np") try: if dynamic_np and not dynamic_np.is_empty(): stats["chunk_dynamic_nodes"] += 1 try: if dynamic_np.is_stashed(): stats["chunk_dynamic_stashed"] += 1 elif not dynamic_np.is_hidden(): stats["chunk_dynamic_visible"] += 1 except Exception: pass stats["dynamic_object_nodes"] += len(list(dynamic_np.get_children())) except Exception: pass try: if static_np and not static_np.is_empty(): stats["chunk_static_nodes"] += 1 try: if static_np.is_stashed(): stats["chunk_static_stashed"] += 1 elif not static_np.is_hidden(): stats["chunk_static_visible"] += 1 except Exception: pass except Exception: pass model = getattr(self, "model", None) if model: try: if not model.is_empty(): stats["model_descendants"] = model.find_all_matches("**").get_num_paths() stats["model_geom_nodes"] = model.find_all_matches("**/+GeomNode").get_num_paths() except Exception: pass pick_model = getattr(self, "pick_model", None) if pick_model: try: if not pick_model.is_empty(): stats["pick_descendants"] = pick_model.find_all_matches("**").get_num_paths() stats["pick_geom_nodes"] = pick_model.find_all_matches("**/+GeomNode").get_num_paths() stats["pick_nodes"] = len(list(pick_model.get_children())) except Exception: pass return stats def get_preferred_selection_ids(self, key): """Return the IDs that should be selected when a tree node is clicked. Prefer the node's own local geometry so parent transforms remain directly selectable. Only fall back to the aggregated subtree when the node itself has no local renderable geometry. """ node = self.tree_nodes.get(key) if node: local_ids = list(node.get("local_ids", []) or []) if local_ids: return local_ids return list(self.name_to_ids.get(key, []) or []) 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() 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 _copy_node_tags(self, src_np, dst_np): """Copy all string tags from source node to rebuilt runtime node.""" if not src_np or not dst_np: return try: for tag_name in src_np.get_tag_keys(): try: dst_np.set_tag(tag_name, src_np.get_tag(tag_name)) except Exception: continue return except Exception: pass try: for tag_name in src_np.getTagKeys(): try: dst_np.setTag(tag_name, src_np.getTag(tag_name)) except Exception: continue except Exception: pass def bake_ids_and_collect(self, model, lightweight=False): """ 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.pick_vertex_index = {} 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() # Build source hierarchy metadata first so flat mode keeps the same # selection/tree semantics as hybrid mode. self._build_scene_tree(model) if lightweight: self.lightweight_flat_mode = True self.supports_gpu_picking = False self.model = model self.chunk_node = model chunk_key = model.get_name() or "default" self.chunks[chunk_key] = {'node': model, 'base_id': 0} self.key_to_node[self.tree_root_key] = model try: # 对于超大模型(节点数 > 8000),使用较温和的 flatten_medium, # 避免 flatten_strong 造成长时间卡顿或内存激增。 node_count = model.find_all_matches("**").get_num_paths() if node_count > 8000: print(f"[控制器] 超大场景({node_count} 节点),使用 flatten_medium 代替 flatten_strong") model.flatten_medium() else: model.flatten_strong() except Exception as e: print(f"[控制器] Flatten 失败: {e}") pass self._aggregate_tree_ids(self.tree_root_key) self.node_list = [] self._build_tree_preorder(self.tree_root_key, self.node_list) t1 = time.time() print(f"[控制器] Flatten took {(t1-t0)*1000:.0f}ms") print(f"[控制器] Lightweight flat tree built: {len(self.tree_nodes)} nodes") return len(geom_nodes) 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 = {} original_owner_keys = {} for np in geom_nodes: original_keys[id(np)] = self._build_original_hierarchy_key(np, model) original_owner_keys[id(np)] = self._path_to_tree_key.get(str(np), self.tree_root_key) # 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 = 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 owner_key = original_owner_keys.get(id(np), self.tree_root_key) if owner_key not in self.tree_nodes: owner_key = self.tree_root_key if owner_key not in self.key_to_node: self.key_to_node[owner_key] = np # 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] = owner_key self.tree_nodes[owner_key]["local_ids"].append(global_id_counter) 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._aggregate_tree_ids(self.tree_root_key) self.node_list = [] self._build_tree_preorder(self.tree_root_key, self.node_list) # Keep ID colors only in picking clone to avoid affecting visible shading. self.pick_model = model.copy_to(NodePath("ssbo_pick_root")) self._build_pick_vertex_index(self.pick_model) self._set_uniform_vertex_color(model, 1.0, 1.0, 1.0, 1.0) t2 = time.time() print(f"[控制器] Vertex index built in {(t2-t1)*1000:.0f}ms, " f"{len(self.vertex_index)} unique IDs indexed") print(f"[控制器] Flat tree built: {len(self.tree_nodes)} nodes") self.model = model return global_id_counter def _set_uniform_vertex_color(self, root_np, r, g, b, a): """ Force vertex color to a uniform value on visible model to avoid ID-encoding colors tinting the final render output. """ for gn_np in root_np.find_all_matches("**/+GeomNode"): gnode = gn_np.node() for gi in range(gnode.get_num_geoms()): geom = gnode.modify_geom(gi) vdata = geom.modify_vertex_data() if not vdata.has_column("color"): continue writer = GeomVertexWriter(vdata, InternalName.make("color")) for row in range(vdata.get_num_rows()): 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 redundant wrapper nodes like ROOT so the UI shows the imported model hierarchy instead of source-format packaging nodes. """ node = self.tree_nodes.get(key) if not node: return False name = (node["name"] or "").strip().lower() if name != "root": 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() # Editor idle performance depends on the static chunk being actually batched. # The merged material/state preservation changes above keep per-geom render # state intact, so we can restore the pre-merge flattening behavior here. 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 = { "name": "", "path": "", "children": {}, "leaf_key": None, "display_name": "", "group_key": None, "aggregate_ids": [], } 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] # 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)) 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, "group_key": None, "aggregate_ids": [], } 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 # 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 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 _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 _resolve_chunk_and_local_idx(self, global_id): """ Compatibility helper for merged branches: - legacy: id_to_chunk[gid] -> (chunk_id, local_idx) - current: id_to_chunk[gid] -> chunk_id (local_idx defaults to gid) """ mapping = self.id_to_chunk.get(global_id) if mapping is None: return None, None if isinstance(mapping, (tuple, list)): if not mapping: return None, None chunk_id = mapping[0] local_idx = mapping[1] if len(mapping) > 1 else global_id return chunk_id, local_idx return mapping, global_id def set_active_ids(self, active_ids): """切换编辑激活集合,仅保留 active_ids 对应 chunk 为动态模式。""" target_chunks = set() for obj_id in active_ids: chunk_id, _ = self._resolve_chunk_and_local_idx(obj_id) if chunk_id is not None: target_chunks.add(chunk_id) # 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 # Always rebuild static chunk when leaving dynamic edit mode. # Material/texture edits may not set `dirty`, but still need to # propagate from dynamic objects to static representation. 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_hybrid(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)) # Preserve the inherited render state, not just the local node state. # Scene/package reload often stores material textures/effects on parent # nodes; using only local state drops those bindings and makes rebuilt # chunk_* runtime objects render black after reopening a project. try: node_state = np.get_net_state() except Exception: try: node_state = np.getNetState() except Exception: try: node_state = np.get_state() except Exception: try: node_state = np.getState() except Exception: node_state = None 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}") geom_state = gnode.get_geom_state(gi) try: merged_state = node_state.compose(geom_state) if node_state is not None else geom_state except Exception: merged_state = geom_state render_gnode.add_geom(render_geom, merged_state) # 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, merged_state) 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) self._copy_node_tags(np, obj_np) 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.id_to_geom_index[global_id] = gi 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. This keeps GPU-picking geometry writable in sync with visible geometry edits. """ import numpy as np self.pick_vertex_index = {} if not pick_root: return for gn_np in pick_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 fmt = vdata.get_format() 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() color_array_format = fmt.get_array(color_array_idx) color_stride = color_array_format.get_stride() 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) num_components = color_col.get_num_components() component_bytes = color_col.get_component_bytes() if component_bytes == 4: 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: 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: continue local_ids = r_vals + (g_vals << 8) sort_idx = np.argsort(local_ids) sorted_ids = local_ids[sort_idx] boundaries = np.where(np.diff(sorted_ids) != 0)[0] + 1 id_groups = np.split(sort_idx, 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] if uid not in self.pick_vertex_index: self.pick_vertex_index[uid] = [] self.pick_vertex_index[uid].append((gn_np, gi, rows)) def _apply_vertices_to_pick(self, local_idx, entry_idx, new_pos): """Mirror one transformed vertex group to pick-model geometry.""" pick_entries = self.pick_vertex_index.get(local_idx) if not pick_entries or entry_idx >= len(pick_entries): return pick_gn_np, pick_gi, pick_rows = pick_entries[entry_idx] gnode = pick_gn_np.node() geom = gnode.modify_geom(pick_gi) vdata = geom.modify_vertex_data() writer = GeomVertexWriter(vdata, "vertex") max_rows = min(len(pick_rows), len(new_pos)) for j in range(max_rows): writer.set_row(int(pick_rows[j])) writer.set_data3f(float(new_pos[j, 0]), float(new_pos[j, 1]), float(new_pos[j, 2])) 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: _, local_idx = self._resolve_chunk_and_local_idx(global_id) if local_idx is None: continue 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 (model-local 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) center_local = acc / float(valid) return self._local_point_to_world(center_local) 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])) self._apply_vertices_to_pick(local_idx, i, new_pos) 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 in hybrid mode.""" 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 chunk_id, local_idx = self._resolve_chunk_and_local_idx(global_id) if local_idx is None: return # Hybrid chunk mode (current) may move NodePaths directly without # vertex_index/original_positions populated. if local_idx not in self.vertex_index or local_idx not in self.original_positions: obj_np = self.id_to_object_np.get(global_id) if not obj_np or obj_np.is_empty(): return next_pos = obj_np.get_pos() + delta if hasattr(obj_np, "set_fluid_pos"): obj_np.set_fluid_pos(next_pos) else: obj_np.set_pos(next_pos) pick_np = self.id_to_pick_np.get(global_id) if pick_np and not pick_np.is_empty(): pick_np.set_mat(self.model, obj_np.get_mat(self.model)) if chunk_id is not None and chunk_id in self.chunks: self.chunks[chunk_id]["dirty"] = True self.position_offsets[local_idx] = self.position_offsets.get(local_idx, Vec3(0)) + delta 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])) self._apply_vertices_to_pick(local_idx, i, new_pos) def get_world_pos(self, global_id): if not self.model: return Vec3(0, 0, 0) obj_np = self.id_to_object_np.get(global_id) if obj_np and not obj_np.is_empty(): p = obj_np.get_pos(self.model) return self._local_point_to_world(Vec3(p)) _, local_idx = self._resolve_chunk_and_local_idx(global_id) if local_idx is None: return Vec3(0, 0, 0) original_mat = self.global_transforms[global_id] original_pos = original_mat.get_row3(3) offset = self.position_offsets.get(local_idx, Vec3(0)) local_pos = Vec3(original_pos) + offset return self._local_point_to_world(local_pos) def get_object_center(self, global_id): 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): 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