diff --git a/.codex/environments/environment-2.toml b/.codex/environments/environment-2.toml new file mode 100644 index 00000000..2ee9ffae --- /dev/null +++ b/.codex/environments/environment-2.toml @@ -0,0 +1,11 @@ +# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY +version = 1 +name = "EG" + +[setup] +script = "" + +[[actions]] +name = "go" +icon = "tool" +command = "python ./main.py" diff --git a/RenderPipelineFile/effects/simple_transparent.yaml b/RenderPipelineFile/effects/simple_transparent.yaml index 3ce65adc..1e76fb4f 100644 --- a/RenderPipelineFile/effects/simple_transparent.yaml +++ b/RenderPipelineFile/effects/simple_transparent.yaml @@ -10,17 +10,21 @@ vertex_shader: | texcoord = p3d_MultiTexCoord0; } -fragment_shader: | - #version 330 - uniform sampler2D p3d_Texture0; - uniform float material_opacity = 1.0; - in vec2 texcoord; - out vec4 o_color; - void main() { - vec4 c = texture(p3d_Texture0, texcoord); - o_color = vec4(c.rgb, c.a * material_opacity); - } - -render_states: - TransparencyAttrib: M_alpha - DepthWriteAttrib: 0 \ No newline at end of file +fragment_shader: | + #version 330 + uniform sampler2D p3d_Texture0; + uniform vec4 material_base_color = vec4(1.0, 1.0, 1.0, 1.0); + uniform float material_opacity = 1.0; + in vec2 texcoord; + out vec4 o_color; + void main() { + vec4 c = texture(p3d_Texture0, texcoord); + o_color = vec4( + material_base_color.rgb * c.rgb, + material_base_color.a * c.a * material_opacity + ); + } + +render_states: + TransparencyAttrib: M_alpha + DepthWriteAttrib: 0 diff --git a/RenderPipelineFile/rpcore/shader/templates/forward.frag.glsl b/RenderPipelineFile/rpcore/shader/templates/forward.frag.glsl index 10036654..1cbf789b 100644 --- a/RenderPipelineFile/rpcore/shader/templates/forward.frag.glsl +++ b/RenderPipelineFile/rpcore/shader/templates/forward.frag.glsl @@ -45,6 +45,7 @@ layout(location = 0) in VertexOutput vOutput; uniform Panda3DMaterial p3d_Material; +uniform vec4 p3d_ColorScale; #pragma include "includes/normal_mapping.inc.glsl" #pragma include "includes/forward_shading.inc.glsl" @@ -141,9 +142,9 @@ void main() { m.shading_model = mInput.shading_model; #if DONT_FETCH_DEFAULT_TEXTURES - m.basecolor = mInput.color; + m.basecolor = mInput.color * p3d_ColorScale.xyz; #else - m.basecolor = mInput.color * sampled_diffuse.xyz; + m.basecolor = mInput.color * sampled_diffuse.xyz * p3d_ColorScale.xyz; #endif m.normal = material_nrm; m.metallic = mInput.metallic; @@ -160,7 +161,7 @@ void main() { vec3 view_dir = normalize(m_out.position - MainSceneData.camera_pos); vec3 color = vec3(0); - float alpha = m_out.shading_model_param0; + float alpha = m_out.shading_model_param0 * p3d_ColorScale.w; AmbientResult ambient = get_full_forward_ambient(m_out, view_dir); color += ambient.diffuse; diff --git a/Resources/models/box1.glb b/Resources/materials/box1.glb similarity index 100% rename from Resources/models/box1.glb rename to Resources/materials/box1.glb diff --git a/TransformGizmo/transform_gizmo.py b/TransformGizmo/transform_gizmo.py index c3132e52..24eb1955 100644 --- a/TransformGizmo/transform_gizmo.py +++ b/TransformGizmo/transform_gizmo.py @@ -469,6 +469,129 @@ class TransformGizmo(DirectObject): # New user action invalidates redo chain. self._redo_history.clear() + def _coerce_vec3(self, value, fallback) -> p3d.Vec3: + if value is None: + return p3d.Vec3(fallback) + try: + return p3d.Vec3(value) + except Exception: + pass + if isinstance(value, (tuple, list)) and len(value) >= 3: + return p3d.Vec3(value[0], value[1], value[2]) + return p3d.Vec3(fallback) + + def _make_transform_mat(self, pos, hpr, scale) -> p3d.LMatrix4f: + try: + state = p3d.TransformState.make_pos_hpr_scale(pos, hpr, scale) + return p3d.LMatrix4f(state.get_mat()) + except Exception: + pass + try: + state = p3d.TransformState.makePosHprScale(pos, hpr, scale) + return p3d.LMatrix4f(state.getMat()) + except Exception: + temp = NodePath("tg_temp_transform") + temp.setPos(pos) + temp.setHpr(hpr) + temp.setScale(scale) + return p3d.LMatrix4f(temp.getMat()) + + def _invert_matrix(self, mat) -> Optional[p3d.LMatrix4f]: + inv = p3d.LMatrix4f(mat) + try: + inv.invertInPlace() + return inv + except Exception: + pass + try: + inv.invert_in_place() + return inv + except Exception: + return None + + def _build_ssbo_group_snapshot_command(self, action: Dict[str, Any]): + node: NodePath = action.get("node") + if node is None or node.isEmpty() or (not node.hasTag("is_ssbo_proxy")): + return None + + ssbo_editor = getattr(self.world, "ssbo_editor", None) + controller = getattr(ssbo_editor, "controller", None) if ssbo_editor else None + if not controller: + return None + + selection_key = node.getTag("ssbo_selection_key") if node.hasTag("ssbo_selection_key") else None + selected_ids = list(controller.name_to_ids.get(selection_key, [])) if selection_key else [] + if not selected_ids: + selected_ids = list(getattr(ssbo_editor, "selected_ids", []) or []) + + targets = [] + for gid in selected_ids: + obj_np = controller.id_to_object_np.get(gid) + if obj_np and not obj_np.is_empty(): + targets.append(obj_np) + if not targets: + return None + + current_pos = node.getPos(self.world.render) + current_hpr = node.getHpr(self.world.render) + current_scale = node.getScale(self.world.render) + + old_pos = self._coerce_vec3(action.get("old_pos"), current_pos) + new_pos = self._coerce_vec3(action.get("new_pos"), current_pos) + old_hpr = self._coerce_vec3(action.get("old_hpr"), current_hpr) + new_hpr = self._coerce_vec3(action.get("new_hpr"), current_hpr) + old_scale = self._coerce_vec3(action.get("old_scale"), current_scale) + new_scale = self._coerce_vec3(action.get("new_scale"), current_scale) + + old_proxy_mat = self._make_transform_mat(old_pos, old_hpr, old_scale) + new_proxy_mat = self._make_transform_mat(new_pos, new_hpr, new_scale) + new_proxy_inv = self._invert_matrix(new_proxy_mat) + if new_proxy_inv is None: + return None + + before_state = [] + after_state = [] + for target in targets: + try: + current_world_mat = p3d.LMatrix4f(target.get_mat(self.world.render)) + except Exception: + try: + current_world_mat = p3d.LMatrix4f(target.getMat(self.world.render)) + except Exception: + continue + + old_world_mat = p3d.LMatrix4f(current_world_mat * new_proxy_inv * old_proxy_mat) + before_state.append({"node": target, "mat": old_world_mat}) + after_state.append({"node": target, "mat": current_world_mat}) + + if not before_state: + return None + + def apply_state(state): + synced_nodes = [] + for item in state: + target = item.get("node") + mat = item.get("mat") + if target is None or target.isEmpty() or mat is None: + continue + try: + target.set_mat(self.world.render, mat) + except Exception: + try: + target.setMat(self.world.render, mat) + except Exception: + continue + synced_nodes.append(target) + + if ssbo_editor and hasattr(ssbo_editor, "sync_scene_nodes_to_pick"): + try: + ssbo_editor.sync_scene_nodes_to_pick(synced_nodes) + except Exception: + pass + + from core.Command_System import SnapshotStateCommand + return SnapshotStateCommand(apply_state, before_state, after_state) + def _record_action_with_command_manager(self, action: Dict[str, Any]) -> bool: """Prefer routing transform actions into the global command manager.""" command_manager = getattr(self.world, "command_manager", None) @@ -476,6 +599,11 @@ class TransformGizmo(DirectObject): return False try: + group_command = self._build_ssbo_group_snapshot_command(action) + if group_command is not None: + command_manager.execute_command(group_command) + return True + from core.Command_System import MoveNodeCommand, RotateNodeCommand, ScaleNodeCommand kind = action.get("kind") @@ -490,6 +618,7 @@ class TransformGizmo(DirectObject): action.get("old_pos"), action.get("new_pos"), reference_node=self.world.render, + world=self.world, ) elif kind == "rotate": command = RotateNodeCommand( @@ -497,12 +626,14 @@ class TransformGizmo(DirectObject): action.get("old_hpr"), action.get("new_hpr"), reference_node=self.world.render, + world=self.world, ) elif kind == "scale": command = ScaleNodeCommand( node, action.get("old_scale"), action.get("new_scale"), + world=self.world, ) if command is None: @@ -513,6 +644,7 @@ class TransformGizmo(DirectObject): except Exception: return False + def _sync_light_position_if_needed(self, node: Optional[NodePath]) -> None: """When target node wraps an RP light, keep RP light position in sync.""" try: diff --git a/core/Command_System.py b/core/Command_System.py index 9c0c5ede..ddc31d99 100644 --- a/core/Command_System.py +++ b/core/Command_System.py @@ -1,178 +1,209 @@ from abc import ABC, abstractmethod from collections import deque -from typing import List -from panda3d.core import NodePath, Point3 - - -def _is_valid_node(node) -> bool: - return bool(node) and hasattr(node, "isEmpty") and (not node.isEmpty()) - - -def _is_light_node(node: NodePath) -> bool: - return bool(node) and hasattr(node, "hasTag") and node.hasTag("light_type") - - -def _is_terrain_node(node: NodePath) -> bool: - return bool(node) and hasattr(node, "hasTag") and node.hasTag("tree_item_type") and node.getTag("tree_item_type") == "TERRAIN_NODE" - - -def _set_light_registration(world, node: NodePath, registered: bool): - if not world or not _is_valid_node(node) or not _is_light_node(node): - return - - scene_manager = getattr(world, "scene_manager", None) - light_type = node.getTag("light_type") - light_lists = [] - if scene_manager: - if light_type == "spot_light" and hasattr(scene_manager, "Spotlight"): - light_lists.append(scene_manager.Spotlight) - elif light_type == "point_light" and hasattr(scene_manager, "Pointlight"): - light_lists.append(scene_manager.Pointlight) - - rp_light = node.getPythonTag("rp_light_object") if hasattr(node, "hasPythonTag") and node.hasPythonTag("rp_light_object") else None - current_registered = bool(node.getPythonTag("engine_light_registered")) if hasattr(node, "hasPythonTag") and node.hasPythonTag("engine_light_registered") else False - - if registered: - for light_list in light_lists: - if node not in light_list: - light_list.append(node) - if not current_registered: - try: - if rp_light is not None and getattr(world, "render_pipeline", None): - world.render_pipeline.add_light(rp_light) - elif hasattr(world, "render") and world.render: - world.render.setLight(node) - except Exception: - pass - try: - node.setPythonTag("engine_light_registered", True) - except Exception: - pass - return - - for light_list in light_lists: - try: - while node in light_list: - light_list.remove(node) - except Exception: - pass - if current_registered: - try: - if rp_light is not None and getattr(world, "render_pipeline", None): - world.render_pipeline.remove_light(rp_light) - elif hasattr(world, "render") and world.render: - world.render.clearLight(node) - except Exception: - pass - try: - node.setPythonTag("engine_light_registered", False) - except Exception: - pass - - -def _set_terrain_registration(world, node: NodePath, registered: bool): - if not world or not _is_valid_node(node) or not _is_terrain_node(node): - return - - terrain_manager = getattr(world, "terrain_manager", None) - if not terrain_manager or not hasattr(terrain_manager, "terrains"): - return - - terrain_info = None - if hasattr(node, "hasPythonTag") and node.hasPythonTag("terrain_info"): - terrain_info = node.getPythonTag("terrain_info") - else: - for info in getattr(terrain_manager, "terrains", []): - if info.get("node") == node: - terrain_info = info - break - if terrain_info is not None: - try: - node.setPythonTag("terrain_info", terrain_info) - except Exception: - pass - - if registered: - if terrain_info is not None: - terrain_info["node"] = node - if all(info.get("node") != node for info in terrain_manager.terrains): - terrain_manager.terrains.append(terrain_info) - return - - try: - terrain_manager.terrains = [info for info in terrain_manager.terrains if info.get("node") != node] - except Exception: - pass - - -def _register_scene_node(world, node: NodePath): - if not world or not _is_valid_node(node): - return - scene_manager = getattr(world, "scene_manager", None) - _set_light_registration(world, node, True) - _set_terrain_registration(world, node, True) - if scene_manager and hasattr(scene_manager, "models") and node not in scene_manager.models: - scene_manager.models.append(node) - try: - if hasattr(world, "updateSceneTree"): - world.updateSceneTree() - except Exception: - pass - - -def _unregister_scene_node(world, node: NodePath): - if not world or not node: - return - scene_manager = getattr(world, "scene_manager", None) - _set_light_registration(world, node, False) - _set_terrain_registration(world, node, False) - if scene_manager and hasattr(scene_manager, "models"): - try: - while node in scene_manager.models: - scene_manager.models.remove(node) - except Exception: - pass - try: - if hasattr(world, "updateSceneTree"): - world.updateSceneTree() - except Exception: - pass - - -def _refresh_scene_tree(world): - if not world: - return - try: - if hasattr(world, "updateSceneTree"): - world.updateSceneTree() - except Exception: - pass - - -def _apply_vec3_method(node, method_name: str, value, reference_node=None): - method = getattr(node, method_name) - if reference_node is not None: - try: - method(reference_node, value) - return - except Exception: - pass - if isinstance(value, (tuple, list)) and len(value) >= 3: - method(reference_node, value[0], value[1], value[2]) - return - else: - try: - method(value) - return - except Exception: - pass - if isinstance(value, (tuple, list)) and len(value) >= 3: - method(value[0], value[1], value[2]) - return - method(value) - - -class Command(ABC): +from typing import List +from panda3d.core import NodePath, Point3 + + +def _is_valid_node(node) -> bool: + return bool(node) and hasattr(node, "isEmpty") and (not node.isEmpty()) + + +def _is_light_node(node: NodePath) -> bool: + return bool(node) and hasattr(node, "hasTag") and node.hasTag("light_type") + + +def _is_terrain_node(node: NodePath) -> bool: + return bool(node) and hasattr(node, "hasTag") and node.hasTag("tree_item_type") and node.getTag("tree_item_type") == "TERRAIN_NODE" + + +def _set_light_registration(world, node: NodePath, registered: bool): + if not world or not _is_valid_node(node) or not _is_light_node(node): + return + + scene_manager = getattr(world, "scene_manager", None) + light_type = node.getTag("light_type") + light_lists = [] + if scene_manager: + if light_type == "spot_light" and hasattr(scene_manager, "Spotlight"): + light_lists.append(scene_manager.Spotlight) + elif light_type == "point_light" and hasattr(scene_manager, "Pointlight"): + light_lists.append(scene_manager.Pointlight) + + rp_light = node.getPythonTag("rp_light_object") if hasattr(node, "hasPythonTag") and node.hasPythonTag("rp_light_object") else None + current_registered = bool(node.getPythonTag("engine_light_registered")) if hasattr(node, "hasPythonTag") and node.hasPythonTag("engine_light_registered") else False + + if registered: + for light_list in light_lists: + if node not in light_list: + light_list.append(node) + if not current_registered: + try: + if rp_light is not None and getattr(world, "render_pipeline", None): + world.render_pipeline.add_light(rp_light) + elif hasattr(world, "render") and world.render: + world.render.setLight(node) + except Exception: + pass + try: + node.setPythonTag("engine_light_registered", True) + except Exception: + pass + return + + for light_list in light_lists: + try: + while node in light_list: + light_list.remove(node) + except Exception: + pass + if current_registered: + try: + if rp_light is not None and getattr(world, "render_pipeline", None): + world.render_pipeline.remove_light(rp_light) + elif hasattr(world, "render") and world.render: + world.render.clearLight(node) + except Exception: + pass + try: + node.setPythonTag("engine_light_registered", False) + except Exception: + pass + + +def _set_terrain_registration(world, node: NodePath, registered: bool): + if not world or not _is_valid_node(node) or not _is_terrain_node(node): + return + + terrain_manager = getattr(world, "terrain_manager", None) + if not terrain_manager or not hasattr(terrain_manager, "terrains"): + return + + terrain_info = None + if hasattr(node, "hasPythonTag") and node.hasPythonTag("terrain_info"): + terrain_info = node.getPythonTag("terrain_info") + else: + for info in getattr(terrain_manager, "terrains", []): + if info.get("node") == node: + terrain_info = info + break + if terrain_info is not None: + try: + node.setPythonTag("terrain_info", terrain_info) + except Exception: + pass + + if registered: + if terrain_info is not None: + terrain_info["node"] = node + if all(info.get("node") != node for info in terrain_manager.terrains): + terrain_manager.terrains.append(terrain_info) + return + + try: + terrain_manager.terrains = [info for info in terrain_manager.terrains if info.get("node") != node] + except Exception: + pass + + +def _register_scene_node(world, node: NodePath): + if not world or not _is_valid_node(node): + return + scene_manager = getattr(world, "scene_manager", None) + _set_light_registration(world, node, True) + _set_terrain_registration(world, node, True) + if scene_manager and hasattr(scene_manager, "models") and node not in scene_manager.models: + scene_manager.models.append(node) + try: + if hasattr(world, "updateSceneTree"): + world.updateSceneTree() + except Exception: + pass + + +def _unregister_scene_node(world, node: NodePath): + if not world or not node: + return + scene_manager = getattr(world, "scene_manager", None) + _set_light_registration(world, node, False) + _set_terrain_registration(world, node, False) + if scene_manager and hasattr(scene_manager, "models"): + try: + while node in scene_manager.models: + scene_manager.models.remove(node) + except Exception: + pass + try: + if hasattr(world, "updateSceneTree"): + world.updateSceneTree() + except Exception: + pass + + +def _refresh_scene_tree(world): + if not world: + return + try: + if hasattr(world, "updateSceneTree"): + world.updateSceneTree() + except Exception: + pass + + +def _resolve_world(world=None): + if world: + return world + try: + from direct.showbase.ShowBaseGlobal import base + return base + except Exception: + return None + + +def _sync_scene_node_side_effects(world, nodes): + world = _resolve_world(world) + if not world: + return + + ssbo_editor = getattr(world, "ssbo_editor", None) + if ssbo_editor and hasattr(ssbo_editor, "sync_scene_nodes_to_pick"): + try: + ssbo_editor.sync_scene_nodes_to_pick(nodes or []) + except Exception: + pass + + +def _sync_transform_side_effects(world, nodes): + _sync_scene_node_side_effects(world, nodes) + + +def _apply_vec3_method(node, method_name: str, value, reference_node=None): + if not _is_valid_node(node): + return + if reference_node is not None and not _is_valid_node(reference_node): + reference_node = None + method = getattr(node, method_name) + if reference_node is not None: + try: + method(reference_node, value) + return + except Exception: + pass + if isinstance(value, (tuple, list)) and len(value) >= 3: + method(reference_node, value[0], value[1], value[2]) + return + else: + try: + method(value) + return + except Exception: + pass + if isinstance(value, (tuple, list)) and len(value) >= 3: + method(value[0], value[1], value[2]) + return + method(value) + + +class Command(ABC): """ 抽象命令类,所有具体命令都需要继承此类 """ @@ -212,27 +243,27 @@ class CommandManager: # 最大历史记录数 self._max_history = max_history - def execute_command(self, command: Command): - """ - 执行命令,并将其添加到撤销栈中 - """ - try: - command.execute() - self.record_command(command) - except Exception as e: - print(f"执行命令时出错: {e}") - raise - - def record_command(self, command: Command): - """记录一个已经执行完成的命令。""" - self._undo_stack.append(command) - self._redo_stack.clear() - - def pop_last_command(self): - """弹出最后一个已执行命令,供复合操作合并历史使用。""" - if not self._undo_stack: - return None - return self._undo_stack.pop() + def execute_command(self, command: Command): + """ + 执行命令,并将其添加到撤销栈中 + """ + try: + command.execute() + self.record_command(command) + except Exception as e: + print(f"执行命令时出错: {e}") + raise + + def record_command(self, command: Command): + """记录一个已经执行完成的命令。""" + self._undo_stack.append(command) + self._redo_stack.clear() + + def pop_last_command(self): + """弹出最后一个已执行命令,供复合操作合并历史使用。""" + if not self._undo_stack: + return None + return self._undo_stack.pop() def undo(self) -> bool: """ @@ -305,34 +336,39 @@ class CommandManager: # 示例命令实现 -class MoveNodeCommand(Command): +class MoveNodeCommand(Command): """ - 移动节点命令示例 + Move node command. """ - def __init__(self, node: NodePath, old_pos, new_pos, reference_node=None): - self.node = node - self.old_pos = old_pos - self.new_pos = new_pos - self.reference_node = reference_node + def __init__(self, node: NodePath, old_pos, new_pos, reference_node=None, world=None): + self.node = node + self.old_pos = old_pos + self.new_pos = new_pos + self.reference_node = reference_node + self.world = world - def execute(self): - """ - 执行移动操作 - """ - _apply_vec3_method(self.node, "setPos", self.new_pos, self.reference_node) + def _apply(self, value): + _apply_vec3_method(self.node, "setPos", value, self.reference_node) + _sync_transform_side_effects(self.world, [self.node]) + + def execute(self): + """ + Execute move operation. + """ + self._apply(self.new_pos) def undo(self): """ - 撤销移动操作 + Undo move operation. """ - _apply_vec3_method(self.node, "setPos", self.old_pos, self.reference_node) + self._apply(self.old_pos) def redo(self): """ - 重做移动操作 + Redo move operation. """ - _apply_vec3_method(self.node, "setPos", self.new_pos, self.reference_node) + self._apply(self.new_pos) class DeleteNodeCommand(Command): @@ -432,11 +468,11 @@ class DeleteNodeCommand(Command): if node.hasTag("tileset_url"): self.extra_data["tileset_url"] = node.getTag("tileset_url") - def execute(self): - """ - 执行删除操作 - """ - # 从world的相应列表中移除节点引用 + def execute(self): + """ + 执行删除操作 + """ + # 从world的相应列表中移除节点引用 if self.world and hasattr(self.world, 'scene_manager'): scene_manager = self.world.scene_manager if self.node_type == "LIGHT_NODE": @@ -454,30 +490,33 @@ class DeleteNodeCommand(Command): if self.node_type.startswith("GUI_") and hasattr(self.world, 'gui_elements') and self.node in self.world.gui_elements: self.world.gui_elements.remove(self.node) - elif self.node_type == "CESIUM_TILESET_NODE": - # 从tilesets列表中移除 - if hasattr(scene_manager, 'tilesets'): - tilesets_to_remove = [] - for i, tileset_info in enumerate(scene_manager.tilesets): + elif self.node_type == "CESIUM_TILESET_NODE": + # 从tilesets列表中移除 + if hasattr(scene_manager, 'tilesets'): + tilesets_to_remove = [] + for i, tileset_info in enumerate(scene_manager.tilesets): if tileset_info.get('node') == self.node: tilesets_to_remove.append(i) - for i in reversed(tilesets_to_remove): - del scene_manager.tilesets[i] - - _unregister_scene_node(self.world, self.node) - - # 从场景图中移除节点,使用 detachNode 而不是 removeNode 以便可以撤销 - if self.node and not self.node.isEmpty(): - self.node.detachNode() + for i in reversed(tilesets_to_remove): + del scene_manager.tilesets[i] + + _unregister_scene_node(self.world, self.node) + + # 从场景图中移除节点,使用 detachNode 而不是 removeNode 以便可以撤销 + if _is_valid_node(self.node): + self.node.detachNode() def undo(self): """ 撤销删除操作(恢复旧节点) """ try: - if self.node and not self.node.isEmpty(): + if _is_valid_node(self.node): # 直接将节点挂载回原父节点 - self.node.reparentTo(self.parent_node) + if _is_valid_node(self.parent_node): + self.node.reparentTo(self.parent_node) + elif self.world and _is_valid_node(getattr(self.world, "render", None)): + self.node.reparentTo(self.world.render) # 恢复到相应的管理器列表中 if self.world and hasattr(self.world, 'scene_manager'): @@ -495,16 +534,16 @@ class DeleteNodeCommand(Command): if self.node_type.startswith("GUI_") and hasattr(self.world, 'gui_elements') and self.node not in self.world.gui_elements: self.world.gui_elements.append(self.node) - elif self.node_type == "CESIUM_TILESET_NODE": - # 简单恢复到 tilesets - if hasattr(scene_manager, 'tilesets'): - scene_manager.tilesets.append({'node': self.node, 'url': self.extra_data.get('tileset_url', '')}) - - _register_scene_node(self.world, self.node) - - print(f"✅ 成功撤销删除操作,节点 {self.node_name} 已恢复") - else: - print("❌ 无法撤销删除操作,节点引用已丢失") + elif self.node_type == "CESIUM_TILESET_NODE": + # 简单恢复到 tilesets + if hasattr(scene_manager, 'tilesets'): + scene_manager.tilesets.append({'node': self.node, 'url': self.extra_data.get('tileset_url', '')}) + + _register_scene_node(self.world, self.node) + + print(f"✅ 成功撤销删除操作,节点 {self.node_name} 已恢复") + else: + print("❌ 无法撤销删除操作,节点引用已丢失") except Exception as e: print(f"❌ 撤销删除操作时出错: {e}") @@ -518,216 +557,234 @@ class DeleteNodeCommand(Command): self.execute() -class RotateNodeCommand(Command): +class RotateNodeCommand(Command): """ - 旋转节点命令 + Rotate node command. """ - def __init__(self, node: NodePath, old_hpr, new_hpr, reference_node=None): - self.node = node - self.old_hpr = old_hpr - self.new_hpr = new_hpr - self.reference_node = reference_node + def __init__(self, node: NodePath, old_hpr, new_hpr, reference_node=None, world=None): + self.node = node + self.old_hpr = old_hpr + self.new_hpr = new_hpr + self.reference_node = reference_node + self.world = world - def execute(self): - """ - 执行旋转操作 - """ - _apply_vec3_method(self.node, "setHpr", self.new_hpr, self.reference_node) + def _apply(self, value): + _apply_vec3_method(self.node, "setHpr", value, self.reference_node) + _sync_transform_side_effects(self.world, [self.node]) + + def execute(self): + """ + Execute move operation. + """ + self._apply(self.new_hpr) def undo(self): """ - 撤销旋转操作 + Undo move operation. """ - _apply_vec3_method(self.node, "setHpr", self.old_hpr, self.reference_node) + self._apply(self.old_hpr) def redo(self): """ - 重做旋转操作 + Redo move operation. """ - _apply_vec3_method(self.node, "setHpr", self.new_hpr, self.reference_node) + self._apply(self.new_hpr) -class ScaleNodeCommand(Command): +class ScaleNodeCommand(Command): """ - 缩放节点命令 + Scale node command. """ - def __init__(self, node: NodePath, old_scale, new_scale): + def __init__(self, node: NodePath, old_scale, new_scale, world=None): self.node = node self.old_scale = old_scale self.new_scale = new_scale + self.world = world - def execute(self): - """ - 执行缩放操作 - """ - _apply_vec3_method(self.node, "setScale", self.new_scale) + def _apply(self, value): + _apply_vec3_method(self.node, "setScale", value) + _sync_transform_side_effects(self.world, [self.node]) + + def execute(self): + """ + Execute rotate operation. + """ + self._apply(self.new_scale) def undo(self): """ - 撤销缩放操作 + Undo rotate operation. """ - _apply_vec3_method(self.node, "setScale", self.old_scale) + self._apply(self.old_scale) - def redo(self): - """ - 重做缩放操作 - """ - _apply_vec3_method(self.node, "setScale", self.new_scale) - - -class RenameNodeCommand(Command): - """Rename a node and refresh scene tree bindings.""" - - def __init__(self, node: NodePath, old_name: str, new_name: str, world=None): - self.node = node - self.old_name = old_name - self.new_name = new_name - self.world = world - - def execute(self): - if self.node and not self.node.isEmpty(): - self.node.setName(self.new_name) - _refresh_scene_tree(self.world) - - def undo(self): - if self.node and not self.node.isEmpty(): - self.node.setName(self.old_name) - _refresh_scene_tree(self.world) - - def redo(self): - self.execute() - - -class VisibilityNodeCommand(Command): - """Toggle editor visibility state for a node.""" - - def __init__(self, node: NodePath, old_visible: bool, new_visible: bool): - self.node = node - self.old_visible = bool(old_visible) - self.new_visible = bool(new_visible) - - def _apply(self, visible: bool): - if not self.node or self.node.isEmpty(): - return - self.node.setPythonTag("user_visible", bool(visible)) - if visible: - self.node.show() - else: - self.node.hide() - - def execute(self): - self._apply(self.new_visible) - - def undo(self): - self._apply(self.old_visible) - - def redo(self): - self.execute() - - -class MaterialStateCommand(Command): - """Replay a captured material snapshot for undo/redo.""" - - def __init__(self, apply_state_callback, before_state, after_state): - self.apply_state_callback = apply_state_callback - self.before_state = before_state - self.after_state = after_state - - def execute(self): - if self.apply_state_callback: - self.apply_state_callback(self.after_state) - - def undo(self): - if self.apply_state_callback: - self.apply_state_callback(self.before_state) - - def redo(self): - self.execute() - - -class SnapshotStateCommand(MaterialStateCommand): - """Generic callback-based snapshot command.""" - - -class CreateNodeCommand(Command): - """ - 创建节点命令 - """ + def redo(self): + """ + Redo rotate operation. + """ + self._apply(self.new_scale) - def __init__(self, node_creator_func, parent_node, *args, world=None, **kwargs): - self.node_creator_func = node_creator_func - self.parent_node = parent_node - self.args = args - self.kwargs = kwargs - self.world = world - self.created_node = None + +class RenameNodeCommand(Command): + """Rename a node and refresh scene tree bindings.""" + + def __init__(self, node: NodePath, old_name: str, new_name: str, world=None): + self.node = node + self.old_name = old_name + self.new_name = new_name + self.world = world + + def execute(self): + if _is_valid_node(self.node): + self.node.setName(self.new_name) + _refresh_scene_tree(self.world) + + def undo(self): + if _is_valid_node(self.node): + self.node.setName(self.old_name) + _refresh_scene_tree(self.world) + + def redo(self): + self.execute() + + +class VisibilityNodeCommand(Command): + """Toggle editor visibility state for a node.""" + + def __init__(self, node: NodePath, old_visible: bool, new_visible: bool, world=None): + self.node = node + self.old_visible = bool(old_visible) + self.new_visible = bool(new_visible) + self.world = world + + def _apply(self, visible: bool): + if not _is_valid_node(self.node): + return + self.node.setPythonTag("user_visible", bool(visible)) + if visible: + self.node.show() + else: + self.node.hide() + _sync_scene_node_side_effects(self.world, [self.node]) + + def execute(self): + self._apply(self.new_visible) + + def undo(self): + self._apply(self.old_visible) + + def redo(self): + self.execute() + + +class MaterialStateCommand(Command): + """Replay a captured material snapshot for undo/redo.""" + + def __init__(self, apply_state_callback, before_state, after_state): + self.apply_state_callback = apply_state_callback + self.before_state = before_state + self.after_state = after_state + + def execute(self): + if self.apply_state_callback: + self.apply_state_callback(self.after_state) + + def undo(self): + if self.apply_state_callback: + self.apply_state_callback(self.before_state) + + def redo(self): + self.execute() + + +class SnapshotStateCommand(MaterialStateCommand): + """Generic callback-based snapshot command.""" + + +class CreateNodeCommand(Command): + """ + 创建节点命令 + """ + + def __init__(self, node_creator_func, parent_node, *args, world=None, **kwargs): + self.node_creator_func = node_creator_func + self.parent_node = parent_node + self.args = args + self.kwargs = kwargs + self.world = world + self.created_node = None def execute(self): """ 执行创建节点操作 """ - if self.created_node and not self.created_node.isEmpty(): - target_parent = self.parent_node - if (not target_parent or target_parent.isEmpty()) and self.world: - target_parent = getattr(self.world, "render", None) - if target_parent and not target_parent.isEmpty(): - self.created_node.wrtReparentTo(target_parent) - _register_scene_node(self.world, self.created_node) - return self.created_node - - self.created_node = self.node_creator_func(self.parent_node, *self.args, **self.kwargs) - _register_scene_node(self.world, self.created_node) - return self.created_node + if _is_valid_node(self.created_node): + target_parent = self.parent_node + if (not _is_valid_node(target_parent)) and self.world: + target_parent = getattr(self.world, "render", None) + if _is_valid_node(target_parent): + self.created_node.wrtReparentTo(target_parent) + _register_scene_node(self.world, self.created_node) + _sync_scene_node_side_effects(self.world, [self.created_node]) + return self.created_node + + self.created_node = self.node_creator_func(self.parent_node, *self.args, **self.kwargs) + _register_scene_node(self.world, self.created_node) + _sync_scene_node_side_effects(self.world, [self.created_node]) + return self.created_node def undo(self): - """ - 撤销创建节点操作 - """ - if self.created_node: - _unregister_scene_node(self.world, self.created_node) - self.created_node.detachNode() + """ + 撤销创建节点操作 + """ + if _is_valid_node(self.created_node): + _unregister_scene_node(self.world, self.created_node) + self.created_node.detachNode() + _sync_scene_node_side_effects(self.world, [self.created_node]) def redo(self): - """ - 重做创建节点操作 - """ - if self.created_node and not self.created_node.isEmpty(): - target_parent = self.parent_node - if (not target_parent or target_parent.isEmpty()) and self.world: - target_parent = getattr(self.world, "render", None) - if target_parent and not target_parent.isEmpty(): - self.created_node.wrtReparentTo(target_parent) - _register_scene_node(self.world, self.created_node) - return - self.execute() - - -class AttachNodeCommand(Command): - """Attach an existing detached node into a parent and make it undoable.""" - - def __init__(self, node: NodePath, parent_node: NodePath, world=None): - self.node = node - self.parent_node = parent_node - self.world = world - - def execute(self): - target_parent = self.parent_node - if (not target_parent or target_parent.isEmpty()) and self.world: - target_parent = getattr(self.world, "render", None) - if self.node and not self.node.isEmpty() and target_parent and not target_parent.isEmpty(): - self.node.wrtReparentTo(target_parent) - _register_scene_node(self.world, self.node) - return self.node - - def undo(self): - if self.node and not self.node.isEmpty(): - _unregister_scene_node(self.world, self.node) - self.node.detachNode() - - def redo(self): - self.execute() + """ + 重做创建节点操作 + """ + if _is_valid_node(self.created_node): + target_parent = self.parent_node + if (not _is_valid_node(target_parent)) and self.world: + target_parent = getattr(self.world, "render", None) + if _is_valid_node(target_parent): + self.created_node.wrtReparentTo(target_parent) + _register_scene_node(self.world, self.created_node) + _sync_scene_node_side_effects(self.world, [self.created_node]) + return + self.execute() + + +class AttachNodeCommand(Command): + """Attach an existing detached node into a parent and make it undoable.""" + + def __init__(self, node: NodePath, parent_node: NodePath, world=None): + self.node = node + self.parent_node = parent_node + self.world = world + + def execute(self): + target_parent = self.parent_node + if (not target_parent or target_parent.isEmpty()) and self.world: + target_parent = getattr(self.world, "render", None) + if _is_valid_node(self.node) and _is_valid_node(target_parent): + self.node.wrtReparentTo(target_parent) + _register_scene_node(self.world, self.node) + _sync_scene_node_side_effects(self.world, [self.node]) + return self.node + + def undo(self): + if _is_valid_node(self.node): + _unregister_scene_node(self.world, self.node) + self.node.detachNode() + _sync_scene_node_side_effects(self.world, [self.node]) + + def redo(self): + self.execute() class ReparentNodeCommand(Command): @@ -778,10 +835,12 @@ class ReparentNodeCommand(Command): """ 执行重新父化操作 """ + if not _is_valid_node(self.node): + return # 更新Panda3D节点父子关系 if self.is_2d_gui and self.world: # 2D GUI元素需要特殊处理 - if self.new_parent and not self.new_parent.isEmpty(): + if _is_valid_node(self.new_parent): if hasattr(self.new_parent, 'getTag') and self.new_parent.getTag("is_gui_element") == "1": # 目标是GUI元素,直接重新父化 self.node.wrtReparentTo(self.new_parent) @@ -795,7 +854,7 @@ class ReparentNodeCommand(Command): print(f"2D GUI元素重新父化到aspect2d") else: # 普通3D节点的处理 - if self.new_parent and not self.new_parent.isEmpty(): + if _is_valid_node(self.new_parent): self.node.wrtReparentTo(self.new_parent) else: # 如果新父节点为空,将其父化到render节点 @@ -810,13 +869,15 @@ class ReparentNodeCommand(Command): """ 撤销重新父化操作 """ + if not _is_valid_node(self.node): + return # 在改变父节点前保存当前的缩放值 current_scale = self.node.getScale() # 恢复Panda3D节点父子关系 if self.is_2d_gui and self.world: # 2D GUI元素需要特殊处理 - if self.old_parent and not self.old_parent.isEmpty(): + if _is_valid_node(self.old_parent): if hasattr(self.old_parent, 'getTag') and self.old_parent.getTag("is_gui_element") == "1": # 原父节点是GUI元素,直接重新父化 self.node.wrtReparentTo(self.old_parent) @@ -830,7 +891,7 @@ class ReparentNodeCommand(Command): print(f"2D GUI元素恢复到aspect2d") else: # 普通3D节点的处理 - if self.old_parent and not self.old_parent.isEmpty(): + if _is_valid_node(self.old_parent): self.node.wrtReparentTo(self.old_parent) else: # 如果原父节点为空,将其父化到render节点 @@ -852,13 +913,15 @@ class ReparentNodeCommand(Command): """ 重做重新父化操作 """ + if not _is_valid_node(self.node): + return # 在改变父节点前保存当前的缩放值 current_scale = self.node.getScale() # 重新执行Panda3D节点父子关系更新 if self.is_2d_gui and self.world: # 2D GUI元素需要特殊处理 - if self.new_parent and not self.new_parent.isEmpty(): + if _is_valid_node(self.new_parent): if hasattr(self.new_parent, 'getTag') and self.new_parent.getTag("is_gui_element") == "1": # 目标是GUI元素,直接重新父化 self.node.wrtReparentTo(self.new_parent) @@ -872,7 +935,7 @@ class ReparentNodeCommand(Command): print(f"2D GUI元素重新父化到aspect2d") else: # 普通3D节点的处理 - if self.new_parent and not self.new_parent.isEmpty(): + if _is_valid_node(self.new_parent): self.node.wrtReparentTo(self.new_parent) else: # 如果新父节点为空,将其父化到render节点 @@ -919,40 +982,40 @@ class CompositeCommand(Command): for command in self.commands: command.redo() -class MoveLightCommand(Command): - def __init__(self, node, old_pos, new_pos, light_object=None): - self.node = node - self.old_pos = Point3(old_pos) - self.new_pos = Point3(new_pos) - self.light_object = light_object - - def _apply_light_position(self, pos): - if not self.light_object: - return - try: - self.light_object.setPos(pos) - return - except Exception: - pass - try: - self.light_object.setPos(pos.x, pos.y, pos.z) - return - except Exception: - pass - try: - self.light_object.pos = Point3(pos) - except Exception: - pass - - def execute(self): # 将原来的 do() 改为 execute() - self._apply_light_position(self.new_pos) - if self.node: - self.node.setPos(self.new_pos) - - def undo(self): - self._apply_light_position(self.old_pos) - if self.node: - self.node.setPos(self.old_pos) +class MoveLightCommand(Command): + def __init__(self, node, old_pos, new_pos, light_object=None): + self.node = node + self.old_pos = Point3(old_pos) + self.new_pos = Point3(new_pos) + self.light_object = light_object + + def _apply_light_position(self, pos): + if not self.light_object: + return + try: + self.light_object.setPos(pos) + return + except Exception: + pass + try: + self.light_object.setPos(pos.x, pos.y, pos.z) + return + except Exception: + pass + try: + self.light_object.pos = Point3(pos) + except Exception: + pass + + def execute(self): # 将原来的 do() 改为 execute() + self._apply_light_position(self.new_pos) + if _is_valid_node(self.node): + self.node.setPos(self.new_pos) + + def undo(self): + self._apply_light_position(self.old_pos) + if _is_valid_node(self.node): + self.node.setPos(self.old_pos) def redo(self): self.execute() # 调用 execute() 而不是 do() diff --git a/core/imgui_style_manager.py b/core/imgui_style_manager.py index a7ecf181..4bbad1e4 100644 --- a/core/imgui_style_manager.py +++ b/core/imgui_style_manager.py @@ -24,6 +24,8 @@ class ImGuiStyleManager: self.world = world self.io = imgui_backend.io self.style = None # 延迟初始化,在apply_style中设置 + self._icon_cache = {} + self._missing_icons = set() # 颜色定义 - 与Qt UI保持一致 self.colors = { @@ -403,24 +405,40 @@ class ImGuiStyleManager: def load_icon(self, icon_name): """加载图标纹理为ImGui可用的格式""" + if icon_name in self._icon_cache: + return self._icon_cache[icon_name] + if icon_name in self._missing_icons: + return None + try: # 构建图标路径 project_root = Path(__file__).resolve().parent.parent icon_path = project_root / "icons" / f"{icon_name}.png" - - if icon_path.exists(): - # 使用base.imgui.loadTexture方法 - if hasattr(base, 'imgui') and hasattr(base.imgui, 'loadTexture'): - # 转换路径为Panda3D兼容格式 (Windows下: D:\... -> /d/...) - # 注意: p3dimgui.loadTexture 仅支持 str 或 Texture,不支持 Filename 对象 - fn = Filename.fromOsSpecific(str(icon_path)) - return base.imgui.loadTexture(fn.getFullpath()) - else: - print(f"⚠ ImGui后端未初始化") - return None - else: + + if not icon_path.exists(): print(f"⚠ 图标文件不存在: {icon_path}") + self._missing_icons.add(icon_name) return None + + base_app = getattr(self.world, "base", None) if self.world else None + if base_app is None: + try: + from direct.showbase.ShowBaseGlobal import base as base_app + except Exception: + base_app = None + + imgui_runtime = getattr(base_app, "imgui", None) if base_app else None + texture_loader = getattr(imgui_runtime, "loadTexture", None) + if not callable(texture_loader): + print("⚠ ImGui后端未初始化") + return None + + # 转换路径为Panda3D兼容格式 (Windows下: D:\... -> /d/...) + # 注意: p3dimgui.loadTexture 仅支持 str 或 Texture,不支持 Filename 对象 + fn = Filename.fromOsSpecific(str(icon_path)) + texture = texture_loader(fn.getFullpath()) + self._icon_cache[icon_name] = texture + return texture except Exception as e: print(f"⚠ 加载图标失败: {e}") return None @@ -428,6 +446,72 @@ class ImGuiStyleManager: def image_button(self, texture_id, size=(32, 32), bg_col=(0, 0, 0, 0), tint_col=(1, 1, 1, 1)): """绘制图像按钮""" return imgui.image_button(texture_id, size, bg_col, tint_col) + + def draw_stat_chip(self, label, tint=None, text_color=None): + """绘制不可交互的状态胶囊。""" + if tint is None: + tint = self.colors['button_bg'] + if text_color is None: + text_color = self.colors['text'] + + text_size = imgui.calc_text_size(label) + horizontal_padding = 8.0 + vertical_padding = 4.0 + chip_width = float(text_size.x) + horizontal_padding * 2.0 + chip_height = float(text_size.y) + vertical_padding * 2.0 + + cursor_pos = imgui.get_cursor_screen_pos() + draw_list = imgui.get_window_draw_list() + bg_color = imgui.color_convert_float4_to_u32(tint) + fg_color = imgui.color_convert_float4_to_u32(text_color) + + draw_list.add_rect_filled( + cursor_pos, + (cursor_pos.x + chip_width, cursor_pos.y + chip_height), + bg_color, + 12.0, + ) + draw_list.add_text( + (cursor_pos.x + horizontal_padding, cursor_pos.y + vertical_padding), + fg_color, + label, + ) + imgui.dummy((chip_width, chip_height)) + + def draw_toolbar_button(self, label, active=False, size=(56, 28), tooltip=None, enabled=True): + """绘制统一风格的工具栏按钮。""" + if active: + button_color = self.colors['primary'] + hovered_color = self.colors['primary_dark'] + text_color = (1.0, 1.0, 1.0, 1.0) + border_color = self.colors['primary_dark'] + else: + button_color = self.colors['button_bg'] + hovered_color = self.colors['panel_bg'] + text_color = self.colors['text'] + border_color = self.colors['border_secondary'] + + imgui.push_style_color(imgui.Col_.button, button_color) + imgui.push_style_color(imgui.Col_.button_hovered, hovered_color) + imgui.push_style_color(imgui.Col_.button_active, hovered_color) + imgui.push_style_color(imgui.Col_.text, text_color) + imgui.push_style_color(imgui.Col_.border, border_color) + imgui.push_style_var(imgui.StyleVar_.frame_rounding, 8.0) + imgui.push_style_var(imgui.StyleVar_.frame_border_size, 1.0) + imgui.push_style_var(imgui.StyleVar_.frame_padding, (10.0, 6.0)) + + if not enabled: + imgui.begin_disabled() + clicked = imgui.button(label, size) + if not enabled: + imgui.end_disabled() + + if tooltip and imgui.is_item_hovered(): + imgui.set_tooltip(tooltip) + + imgui.pop_style_var(3) + imgui.pop_style_color(5) + return clicked def get_icon_text_button(self, icon_texture, text, size=(0, 0)): """绘制带图标的文本按钮""" diff --git a/core/selection.py b/core/selection.py index 6b40d081..3587b3bd 100644 --- a/core/selection.py +++ b/core/selection.py @@ -93,6 +93,33 @@ class SelectionSystem: print("✓ 选择和变换系统初始化完成") + def _is_valid_node(self, node, require_attached=False): + if node is None: + return False + try: + if node.isEmpty(): + return False + except Exception: + return False + if require_attached: + try: + return bool(node.hasParent()) + except Exception: + return False + return True + + def _same_valid_node(self, left, right): + if left is None and right is None: + return True + if (left is None) != (right is None): + return False + if not self._is_valid_node(left) or not self._is_valid_node(right): + return False + try: + return left == right + except Exception: + return False + def _get_tree_widget(self): """统一获取场景树控件。""" return self._editor_context.get_tree_widget() @@ -175,28 +202,18 @@ class SelectionSystem: try: if ssbo_editor.has_active_selection(): node = ssbo_editor.get_selection_scene_node() - if node and not node.isEmpty(): + if self._is_valid_node(node, require_attached=True): return node return None except Exception: pass - resolver = getattr(self.world, "_get_selection_node", None) - if callable(resolver): - try: - node = resolver() - if node and not node.isEmpty(): - return node - except Exception: - pass - node = self.selectedNode - if not node: + if node is None: return None - try: - return None if node.isEmpty() else node - except Exception: + if not self._is_valid_node(node, require_attached=True): return None + return node def _sync_rp_light_position(self, light_node, light_object=None): """同步灯光包装节点与 RenderPipeline 灯光对象位置。""" @@ -2139,7 +2156,7 @@ class SelectionSystem: def updateSelection(self, nodePath): try: - if self.selectedNode == nodePath: + if self._same_valid_node(self.selectedNode, nodePath): return #print(f"\n=== 更新选择状态 ===") @@ -2150,7 +2167,7 @@ class SelectionSystem: return node_name = "None" - if nodePath and not nodePath.isEmpty(): + if self._is_valid_node(nodePath, require_attached=True): node_name = nodePath.getName() #print(f"新选择的节点: {node_name}") @@ -2165,7 +2182,7 @@ class SelectionSystem: # 添加兼容性属性 self.selectedObject = nodePath - if nodePath and not nodePath.isEmpty(): + if self._is_valid_node(nodePath, require_attached=True): node_name = nodePath.getName() #print(f"开始为节点 {node_name} 创建选择框和坐标轴...") @@ -2315,13 +2332,15 @@ class SelectionSystem: return self._get_effective_selected_node() is not None def checkAndClearIfTargetDeleted(self): - if (self.gizmoTarget and self.gizmoTarget.isEmpty()): + if self.gizmoTarget is not None and (not self._is_valid_node(self.gizmoTarget, require_attached=True)): self.clearGizmo() - if (self.selectionBoxTarget and self.selectionBoxTarget.isEmpty()): + if self.selectionBoxTarget is not None and (not self._is_valid_node(self.selectionBoxTarget, require_attached=True)): self.clearSelectionBox() - if self.selectedNode and self.selectedNode.isEmpty(): + if self.selectedNode is not None and (not self._is_valid_node(self.selectedNode, require_attached=True)): + self.selectedNode = None + self.selectedObject = None self._updateSelectionOutline(None) def setupGizmoCollision(self): diff --git a/imgui.ini b/imgui.ini index 4deebaaf..5760f2f1 100644 --- a/imgui.ini +++ b/imgui.ini @@ -150,8 +150,8 @@ Size=101,226 Collapsed=0 [Window][LUI编辑器] -Pos=1193,20 -Size=855,748 +Pos=1690,20 +Size=358,748 Collapsed=0 DockId=0x00000002,2 @@ -206,6 +206,11 @@ Size=610,748 Collapsed=0 DockId=0x00000002,2 +[Window][项目另存为] +Pos=794,432 +Size=460,240 +Collapsed=0 + [Docking][Data] DockSpace ID=0x08BD597D Window=0x1BBC0F80 Pos=0,20 Size=2048,1084 Split=Y DockNode ID=0x00000005 Parent=0x08BD597D SizeRef=2560,995 Split=X @@ -213,7 +218,7 @@ DockSpace ID=0x08BD597D Window=0x1BBC0F80 Pos=0,20 Size=2048,1084 Split= DockNode ID=0x00000008 Parent=0x00000005 SizeRef=2206,1084 Split=X DockNode ID=0x00000001 Parent=0x00000008 SizeRef=1846,989 Split=Y DockNode ID=0x0000000D Parent=0x00000001 SizeRef=1318,32 HiddenTabBar=1 Selected=0x43A39006 - DockNode ID=0x0000000E Parent=0x00000001 SizeRef=1318,714 CentralNode=1 + DockNode ID=0x0000000E Parent=0x00000001 SizeRef=1318,961 CentralNode=1 DockNode ID=0x00000002 Parent=0x00000008 SizeRef=358,989 Selected=0x5DB6FF37 DockNode ID=0x00000006 Parent=0x08BD597D SizeRef=2560,334 Selected=0x3A2E05C3 diff --git a/ssbo_component/ssbo_editor.py b/ssbo_component/ssbo_editor.py index a0e35eba..288b396a 100644 --- a/ssbo_component/ssbo_editor.py +++ b/ssbo_component/ssbo_editor.py @@ -312,6 +312,18 @@ class SSBOEditor: except Exception: pass + def _get_node_name(self, node, default_name=None): + if not node: + return default_name + try: + return node.get_name() + except Exception: + pass + try: + return node.getName() + except Exception: + return default_name + def _iter_children(self, node): if not node: return [] @@ -472,6 +484,75 @@ class SSBOEditor: self.source_model_root = None self._sync_pick_scene_binding() + def _get_source_root_children(self): + root = self.source_model_root + if not root: + return [] + return [child for child in self._iter_children(root) if self._node_is_valid(child)] + + def _rebuild_or_clear_runtime_from_current_source(self, highlight_root_name=None): + if self._get_source_root_children(): + self.source_model = self.source_model_root + self._rebuild_runtime_from_source_root(highlight_root_name=highlight_root_name) + return self.model + + self.last_import_tree_key = None + self.last_import_root_name = None + self.source_model = self.source_model_root + self._sync_pick_scene_binding() + return None + + def find_source_child_by_name(self, child_name): + if not child_name: + return None + for child in self._get_source_root_children(): + if self._get_node_name(child) == child_name: + return child + return None + + def detach_source_child(self, child_name=None, child_np=None): + target = child_np if self._node_is_valid(child_np) else self.find_source_child_by_name(child_name) + if not self._node_is_valid(target): + return None + + if self.controller and self.model: + self._snapshot_top_level_transforms_to_source_root() + self._clear_runtime_state(preserve_source_models=True) + + try: + target.detach_node() + except Exception: + try: + target.detachNode() + except Exception: + return None + + self._rebuild_or_clear_runtime_from_current_source() + return target + + def attach_source_child(self, child_np, highlight_root_name=None): + if not self._node_is_valid(child_np): + return None + + if self.controller and self.model: + self._snapshot_top_level_transforms_to_source_root() + self._clear_runtime_state(preserve_source_models=True) + + source_root = self._ensure_source_model_root() + target_name = highlight_root_name or self._get_node_name(child_np, "imported_model") + self._set_node_name(child_np, target_name) + + try: + child_np.reparent_to(source_root) + except Exception: + try: + child_np.reparentTo(source_root) + except Exception: + return None + + self._rebuild_or_clear_runtime_from_current_source(highlight_root_name=target_name) + return self.model + def _rebuild_runtime_from_source_root(self, highlight_root_name=None): root = self._ensure_source_model_root() working_holder = NodePath("ssbo_source_scene_work") @@ -535,6 +616,7 @@ class SSBOEditor: self.source_model = source_root self._rebuild_runtime_from_source_root(highlight_root_name=unique_root_name) + return imported_root def _build_filename_candidates(self, path_text): """Build Filename candidates with wide-char first for Windows CJK paths.""" @@ -1417,6 +1499,105 @@ class SSBOEditor: if chunk_id is not None and chunk_id in self.controller.chunks: self.controller.chunks[chunk_id]["dirty"] = True + def sync_scene_nodes_to_pick(self, nodes): + """Sync transformed scene nodes to both pick data and visible static chunks.""" + if not self.controller: + return + + self._sync_pick_scene_binding() + self._sync_pick_root_transform() + + valid_nodes = [] + for node in nodes or []: + if not self._node_is_valid(node): + continue + duplicate = False + for existing in valid_nodes: + try: + if existing == node: + duplicate = True + break + except Exception: + pass + if not duplicate: + valid_nodes.append(node) + + if not valid_nodes: + self._reset_pick_sync_cache() + return + + affected_chunks = set() + + for gid, obj_np in self.controller.id_to_object_np.items(): + if not self._node_is_valid(obj_np): + continue + + matched = False + for target in valid_nodes: + try: + if obj_np == target: + matched = True + break + except Exception: + pass + if not matched: + continue + + is_attached = False + try: + is_attached = bool(obj_np.has_parent()) + except Exception: + try: + is_attached = bool(obj_np.hasParent()) + except Exception: + is_attached = False + + is_visible = False + if is_attached: + try: + is_visible = not obj_np.is_hidden() + except Exception: + try: + is_visible = not obj_np.isHidden() + except Exception: + is_visible = True + + pick_np = self.controller.id_to_pick_np.get(gid) + if pick_np and not pick_np.is_empty(): + if is_visible: + try: + pick_np.show() + except Exception: + pass + try: + pick_np.set_mat(self.base.render, obj_np.get_mat(self.base.render)) + except Exception: + try: + pick_np.setMat(self.base.render, obj_np.getMat(self.base.render)) + except Exception: + pass + else: + try: + pick_np.hide() + except Exception: + pass + + chunk_id = self.controller.id_to_chunk.get(gid) + if chunk_id is not None and chunk_id in self.controller.chunks: + self.controller.chunks[chunk_id]["dirty"] = True + affected_chunks.add(chunk_id) + + for chunk_id in affected_chunks: + chunk = self.controller.chunks.get(chunk_id) + if not chunk or chunk.get("dynamic_enabled"): + continue + try: + self.controller._rebuild_static_chunk(chunk_id) + except Exception: + pass + + self._reset_pick_sync_cache() + def _update_outline_for_selection(self): if not self._outline_manager: return diff --git a/ui/panels/animation_tools.py b/ui/panels/animation_tools.py index 764826e1..dda9678b 100644 --- a/ui/panels/animation_tools.py +++ b/ui/panels/animation_tools.py @@ -1571,6 +1571,7 @@ class AnimationTools: owner_node = self._resolve_animation_owner_model(node) node.setPythonTag("cached_anim_info", None) node.setPythonTag("cached_processed_names", None) + node.setPythonTag("cached_has_animation_nodes", None) node.setPythonTag("animation", None) # 同时清除动画检测结果 # 如果Actor在缓存中,也需要清理 diff --git a/ui/panels/app_actions.py b/ui/panels/app_actions.py index 3ddb658c..0fecf763 100644 --- a/ui/panels/app_actions.py +++ b/ui/panels/app_actions.py @@ -516,9 +516,91 @@ class AppActions: def add_info_message(self, text): """添加信息消息""" self.add_message(f"ℹ {text}", (0.157, 0.620, 1.0, 1.0)) - - # ==================== 编辑菜单功能实现 ==================== - + + def _is_history_node_valid(self, node, require_attached=False): + if node is None: + return False + try: + if node.isEmpty(): + return False + except Exception: + return False + if require_attached: + try: + return bool(node.hasParent()) + except Exception: + return False + return True + + def _has_active_ssbo_history_selection(self): + ssbo_editor = getattr(self, "ssbo_editor", None) + if not ssbo_editor or not hasattr(ssbo_editor, "has_active_selection"): + return False + try: + return bool(ssbo_editor.has_active_selection()) + except Exception: + return False + + def _sync_editor_state_after_history_change(self): + ssbo_editor = getattr(self, "ssbo_editor", None) + ssbo_active = self._has_active_ssbo_history_selection() + transform = getattr(self, "newTransform", None) + selection = getattr(self, "selection", None) + + if ssbo_editor: + try: + if hasattr(ssbo_editor, "_sync_pick_scene_binding"): + ssbo_editor._sync_pick_scene_binding() + except Exception: + pass + try: + if hasattr(ssbo_editor, "sync_scene_nodes_to_pick"): + ssbo_editor.sync_scene_nodes_to_pick([]) + except Exception: + pass + + if selection: + if ssbo_active and ssbo_editor: + try: + selected_key = getattr(ssbo_editor, "selected_name", None) + if selected_key and hasattr(ssbo_editor, "select_node"): + ssbo_editor.select_node(selected_key, sync_world_selection=False) + if hasattr(ssbo_editor, "_sync_editor_selection_reference"): + ssbo_editor._sync_editor_selection_reference(ssbo_editor.get_selection_scene_node()) + except Exception: + pass + else: + selected_node = None + try: + selected_node = selection.getSelectedNode() if hasattr(selection, "getSelectedNode") else getattr(selection, "selectedNode", None) + except Exception: + selected_node = None + if not self._is_history_node_valid(selected_node, require_attached=True): + try: + selection.clearSelection() + except Exception: + try: + selection.selectedNode = None + selection.selectedObject = None + except Exception: + pass + + monitored_node = getattr(self, "_monitored_node", None) + target_node = getattr(transform, "target_node", None) if transform else None + + if not self._is_history_node_valid(monitored_node, require_attached=True): + if not (ssbo_active and self._is_history_node_valid(target_node, require_attached=True)): + try: + self.stop_transform_monitoring() + except Exception: + pass + + if transform and (not self._is_history_node_valid(target_node, require_attached=True)): + if not ssbo_active: + try: + transform.detach() + except Exception: + pass def _on_undo(self): """处理撤销操作""" @@ -527,6 +609,7 @@ class AppActions: if hasattr(self, 'command_manager') and self.command_manager and self.command_manager.can_undo(): success = self.command_manager.undo() if success: + self._sync_editor_state_after_history_change() self.add_success_message("撤销操作成功") return self.add_error_message("撤销操作失败") @@ -535,6 +618,7 @@ class AppActions: # 2) 回退到 TransformGizmo 历史(拖拽位移/旋转/缩放) tg = getattr(self, 'newTransform', None) if tg and hasattr(tg, 'undo_last') and tg.undo_last(): + self._sync_editor_state_after_history_change() self.add_success_message("撤销操作成功") return @@ -550,6 +634,7 @@ class AppActions: if hasattr(self, 'command_manager') and self.command_manager and self.command_manager.can_redo(): success = self.command_manager.redo() if success: + self._sync_editor_state_after_history_change() self.add_success_message("重做操作成功") return self.add_error_message("重做操作失败") @@ -558,6 +643,7 @@ class AppActions: # 2) 回退到 TransformGizmo 重做栈 tg = getattr(self, 'newTransform', None) if tg and hasattr(tg, 'redo_last') and tg.redo_last(): + self._sync_editor_state_after_history_change() self.add_success_message("重做操作成功") return @@ -1162,6 +1248,138 @@ class AppActions: # ==================== 路径浏览器辅助方法 ==================== + def _refresh_ssbo_runtime_import_bindings(self, file_path=None, scene_package_import=False): + ssbo_editor = getattr(self, 'ssbo_editor', None) + model_np = getattr(ssbo_editor, 'model', None) if ssbo_editor else None + scene_manager = getattr(self, 'scene_manager', None) + + if scene_manager and hasattr(scene_manager, 'models'): + scene_manager.models = [model_np] if model_np else [] + + if not model_np: + return None + + normalized_model_path = file_path + if file_path and not scene_package_import: + try: + from scene import util as scene_util + normalized_model_path = scene_util.normalize_model_path(file_path) + except Exception: + normalized_model_path = file_path + + if normalized_model_path: + model_np.setTag("model_path", normalized_model_path) + model_np.setTag("saved_model_path", normalized_model_path) + if file_path: + model_np.setTag("original_path", file_path) + model_np.setTag("file", os.path.basename(file_path)) + + model_np.setTag("is_model_root", "1") + model_np.setTag("is_scene_element", "1") + + ssbo_source_root = getattr(ssbo_editor, "source_model_root", None) + source_children = [] + if ssbo_source_root is not None: + try: + source_children = [child for child in ssbo_source_root.getChildren() if not child.isEmpty()] + except Exception: + try: + source_children = [child for child in ssbo_source_root.get_children() if not child.is_empty()] + except Exception: + source_children = [] + + if len(source_children) > 1 and not scene_package_import: + model_np.setName("\u5bfc\u5165\u6a21\u578b") + elif file_path: + model_np.setName(os.path.basename(file_path)) + elif len(source_children) == 1: + try: + model_np.setName(source_children[0].getName()) + except Exception: + pass + + if scene_manager: + try: + scene_manager.setupCollision(model_np) + if scene_package_import: + model_np.setTag("has_animations", "false") + model_np.setTag("has_animations_checked", "true") + model_np.setTag("scene_import_source", "project_scene_bam") + elif file_path: + scene_manager._processModelAnimations(model_np) + except Exception as e: + print(f"[SSBO] setup components failed: {e}") + + return model_np + + def _execute_ssbo_import_command(self, file_path): + if not getattr(self, 'ssbo_editor', None): + return None + + from core.Command_System import SnapshotStateCommand + + import_state = { + 'source_child': None, + 'root_name': None, + } + + def apply_state(state): + mode = state.get('mode') if isinstance(state, dict) else state + if mode == 'after': + source_child = import_state['source_child'] + source_child_valid = False + if source_child: + try: + source_child_valid = not source_child.isEmpty() + except Exception: + try: + source_child_valid = not source_child.is_empty() + except Exception: + source_child_valid = False + + if source_child_valid: + self.ssbo_editor.attach_source_child(source_child, highlight_root_name=import_state['root_name']) + else: + imported_root = self.ssbo_editor.load_model( + file_path, + keep_source_model=False, + append=True, + ) + import_state['source_child'] = imported_root + import_state['root_name'] = getattr(self.ssbo_editor, 'last_import_root_name', None) + return self._refresh_ssbo_runtime_import_bindings(file_path=file_path) + + source_child = import_state['source_child'] + if source_child: + self.ssbo_editor.detach_source_child(child_np=source_child) + return self._refresh_ssbo_runtime_import_bindings() + + command = SnapshotStateCommand( + apply_state, + {'mode': 'before'}, + {'mode': 'after'}, + ) + self.command_manager.execute_command(command) + return getattr(self.ssbo_editor, 'model', None) + + def _execute_import_command(self, file_path, scene_package_import=False): + command_manager = getattr(self, 'command_manager', None) + if scene_package_import or not command_manager: + return self._import_model_for_runtime(file_path, scene_package_import=scene_package_import) + + if self.use_ssbo_mouse_picking and getattr(self, 'ssbo_editor', None): + return self._execute_ssbo_import_command(file_path) + + from core.Command_System import CreateNodeCommand + + command = CreateNodeCommand( + lambda _parent: self._import_model_for_runtime(file_path, scene_package_import=False), + getattr(self, 'render', None), + world=self, + ) + command_manager.execute_command(command) + return command.created_node + def _import_model_for_runtime(self, file_path, prefer_scene_manager=False, scene_package_import=False): """Import model through the active runtime path. SSBO mode: load via SSBOEditor always (regardless of prefer_scene_manager). @@ -1184,53 +1402,10 @@ class AppActions: keep_source_model=scene_package_import, append=not scene_package_import, ) - model_np = getattr(self.ssbo_editor, 'model', None) - # Keep legacy ray-pick fallback usable by adding a collision body. - if model_np: - normalized_model_path = file_path - if not scene_package_import: - try: - from scene import util as scene_util - normalized_model_path = scene_util.normalize_model_path(file_path) - except Exception: - normalized_model_path = file_path - # Apply vital tags manually since SSBO overrides SceneManager loader - model_np.setTag("model_path", normalized_model_path) - model_np.setTag("original_path", file_path) - model_np.setTag("saved_model_path", normalized_model_path) - model_np.setTag("is_model_root", "1") - model_np.setTag("is_scene_element", "1") - model_np.setTag("file", os.path.basename(file_path)) - ssbo_source_root = getattr(self.ssbo_editor, "source_model_root", None) - source_children = [] - if ssbo_source_root is not None: - try: - source_children = list(ssbo_source_root.getChildren()) - except Exception: - try: - source_children = list(ssbo_source_root.get_children()) - except Exception: - source_children = [] - if len(source_children) > 1 and not scene_package_import: - model_np.setName("导入模型") - else: - model_np.setName(os.path.basename(file_path)) - - if hasattr(self, 'scene_manager') and self.scene_manager: - try: - self.scene_manager.setupCollision(model_np) - if scene_package_import: - model_np.setTag("has_animations", "false") - model_np.setTag("has_animations_checked", "true") - model_np.setTag("scene_import_source", "project_scene_bam") - else: - self.scene_manager._processModelAnimations(model_np) - except Exception as e: - print(f"[SSBO] setup components failed: {e}") - - if hasattr(self, 'scene_manager') and self.scene_manager and hasattr(self.scene_manager, 'models'): - self.scene_manager.models = [model_np] - return model_np + return self._refresh_ssbo_runtime_import_bindings( + file_path=file_path, + scene_package_import=scene_package_import, + ) except Exception as e: print(f"[SSBO] load_model failed: {e}") return None @@ -1272,7 +1447,7 @@ class AppActions: if show_info_message: self.add_info_message(f"正在导入模型: {file_name}") - model_node = self._import_model_for_runtime(normalized_path) + model_node = self._execute_import_command(normalized_path) if not model_node: self.add_error_message("模型导入失败") return None @@ -1308,7 +1483,7 @@ class AppActions: if hasattr(self.scene_manager, 'models'): if getattr(self, "use_ssbo_mouse_picking", False): self.scene_manager.models = [model_node] - else: + elif model_node not in self.scene_manager.models: self.scene_manager.models.append(model_node) if select_model: diff --git a/ui/panels/editor_panels_left.py b/ui/panels/editor_panels_left.py index b88069d1..a0e5ace0 100644 --- a/ui/panels/editor_panels_left.py +++ b/ui/panels/editor_panels_left.py @@ -4,6 +4,13 @@ from pathlib import Path class EditorPanelsLeftMixin: """Auto-split mixin from editor_panels.py.""" + @staticmethod + def _truncate_panel_text(text, limit=28): + text = text or "" + if len(text) <= limit: + return text + return text[: max(0, limit - 1)] + "…" + def _draw_scene_tree(self): """绘制场景树面板""" # 使用更少的限制性标志,允许docking @@ -36,16 +43,28 @@ class EditorPanelsLeftMixin: if selected_node and not selected_node.isEmpty(): selected_name = selected_node.getName() or "未命名对象" - imgui.text_disabled(f"模型 {model_count}") + self.app.style_manager.draw_stat_chip(f"模型 {model_count}", tint=(0.14, 0.17, 0.22, 1.0)) imgui.same_line() - imgui.text_disabled(f"当前: {selected_name}") + self.app.style_manager.draw_stat_chip( + f"当前 {self._truncate_panel_text(selected_name, 20)}", + tint=(0.16, 0.20, 0.27, 1.0), + ) + filter_text = self._get_scene_tree_filter() + if filter_text: + imgui.same_line() + self.app.style_manager.draw_stat_chip( + f"筛选 {self._truncate_panel_text(filter_text, 16)}", + tint=(0.19, 0.24, 0.33, 1.0), + ) + imgui.spacing() + imgui.text_disabled("筛选场景") imgui.set_next_item_width(-64) changed, search_text = imgui.input_text("##scene_tree_search", self.app._scene_tree_search_text, 256) if changed: self.app._scene_tree_search_text = search_text imgui.same_line() - if imgui.button("清空##scene_tree_search"): + if self.app.style_manager.draw_toolbar_button("清空", size=(52, 26), enabled=bool(self.app._scene_tree_search_text)): self.app._scene_tree_search_text = "" def _get_scene_tree_filter(self): @@ -578,12 +597,24 @@ class EditorPanelsLeftMixin: except ValueError: location = str(rm.current_path) - imgui.text_disabled(location or ".") + self.app.style_manager.draw_stat_chip( + self._truncate_panel_text(location or ".", 30), + tint=(0.14, 0.17, 0.22, 1.0), + ) imgui.same_line() - imgui.text_disabled(f"{total_items} 项") + self.app.style_manager.draw_stat_chip(f"{total_items} 项", tint=(0.16, 0.19, 0.24, 1.0)) if rm.selected_files: imgui.same_line() - imgui.text_colored((0.5, 0.8, 1.0, 1.0), f"已选 {len(rm.selected_files)}") + self.app.style_manager.draw_stat_chip( + f"已选 {len(rm.selected_files)}", + tint=(0.18, 0.24, 0.32, 1.0), + ) + if rm.search_filter: + imgui.same_line() + self.app.style_manager.draw_stat_chip( + f"搜索 {self._truncate_panel_text(rm.search_filter, 18)}", + tint=(0.19, 0.24, 0.33, 1.0), + ) def _sync_resource_path_input(self, rm): current_path_text = str(rm.current_path) @@ -606,22 +637,21 @@ class EditorPanelsLeftMixin: """绘制资源管理器顶部工具条与筛选输入。""" self._sync_resource_path_input(rm) - if imgui.button("◀"): + if self.app.style_manager.draw_toolbar_button("←", size=(36, 26), tooltip="后退"): rm.navigate_back() imgui.same_line() - if imgui.button("▶"): + if self.app.style_manager.draw_toolbar_button("→", size=(36, 26), tooltip="前进"): rm.navigate_forward() imgui.same_line() - if imgui.button("▲"): + if self.app.style_manager.draw_toolbar_button("上级", size=(48, 26), tooltip="返回上级目录"): rm.navigate_up() imgui.same_line() - if imgui.button("主页"): + if self.app.style_manager.draw_toolbar_button("资源", size=(48, 26), tooltip="回到 Resources 根目录"): rm.navigate_to(rm.project_root / "Resources") imgui.same_line() - if imgui.button("刷新"): + if self.app.style_manager.draw_toolbar_button("刷新", size=(48, 26), tooltip="刷新当前目录"): rm.force_refresh() - # 自动刷新开关 imgui.same_line() changed, rm.auto_refresh_enabled = imgui.checkbox("自动刷新", rm.auto_refresh_enabled) if changed: @@ -633,14 +663,14 @@ class EditorPanelsLeftMixin: if changed: self.app._resource_path_input = new_path imgui.same_line() - if imgui.button("前往##resource_go"): + if self.app.style_manager.draw_toolbar_button("前往", size=(52, 26), tooltip="跳转到输入路径"): self._navigate_resource_path_from_input(rm) imgui.text_disabled("搜索") imgui.set_next_item_width(-72) changed, rm.search_filter = imgui.input_text("##resource_search", rm.search_filter, 256) imgui.same_line() - if imgui.button("清除##resource_search"): + if self.app.style_manager.draw_toolbar_button("清空", size=(52, 26), tooltip="清空搜索条件", enabled=bool(rm.search_filter)): rm.search_filter = "" def _load_resource_icon(self, icon_name: str): @@ -672,8 +702,11 @@ class EditorPanelsLeftMixin: def _handle_resource_file_double_click(self, rm, file_path: Path): """处理文件双击:模型导入,其他文件打开。""" if self._is_model_file(file_path): - self.app.add_info_message(f"正在导入模型: {file_path.name}") - self.app._import_model_for_runtime(str(file_path)) + self.app._import_model_with_menu_logic( + str(file_path), + show_info_message=True, + show_success_message=True, + ) else: rm.open_file(file_path) @@ -697,8 +730,11 @@ class EditorPanelsLeftMixin: imgui.separator() if imgui.menu_item("导入到场景")[1]: if self._is_model_file(rm.context_menu_file): - self.app.add_info_message(f"正在导入模型: {rm.context_menu_file.name}") - self.app._import_model_for_runtime(str(rm.context_menu_file)) + self.app._import_model_with_menu_logic( + str(rm.context_menu_file), + show_info_message=True, + show_success_message=True, + ) if imgui.menu_item("重命名")[1]: print(f"重命名文件: {rm.context_menu_file.name}") if imgui.menu_item("删除")[1]: @@ -726,6 +762,8 @@ class EditorPanelsLeftMixin: imgui.end_popup() + + @staticmethod def _is_model_file(path: Path) -> bool: return path.suffix.lower() in ['.gltf', '.glb', '.fbx', '.bam', '.egg', '.obj'] diff --git a/ui/panels/editor_panels_right.py b/ui/panels/editor_panels_right.py index 75397757..5db184ba 100644 --- a/ui/panels/editor_panels_right.py +++ b/ui/panels/editor_panels_right.py @@ -1,4 +1,7 @@ +from pathlib import Path + from imgui_bundle import imgui, imgui_ctx +from panda3d.core import TransparencyAttrib from ui.panels.editor_panels_right_collision import EditorPanelsRightCollisionMixin from ui.panels.editor_panels_right_material import EditorPanelsRightMaterialMixin @@ -12,6 +15,65 @@ class EditorPanelsRightMixin( ): """Right panel aggregator mixin.""" + def _apply_gui_alpha(self, gui_element, alpha_value): + alpha_value = max(0.0, min(1.0, float(alpha_value))) + gui_element.alpha = alpha_value + + if hasattr(gui_element, "setTransparency"): + gui_element.setTransparency( + TransparencyAttrib.MAlpha if alpha_value < 0.999 else TransparencyAttrib.MNone + ) + + color_scale = None + if hasattr(gui_element, "getColorScale"): + try: + color_scale = gui_element.getColorScale() + except Exception: + color_scale = None + + if color_scale is not None and hasattr(gui_element, "setColorScale"): + try: + gui_element.setColorScale( + float(color_scale[0]), + float(color_scale[1]), + float(color_scale[2]), + alpha_value, + ) + return + except Exception: + pass + + if hasattr(gui_element, "setAlphaScale"): + try: + gui_element.setAlphaScale(alpha_value) + return + except Exception: + pass + + if hasattr(gui_element, "setColorScale"): + try: + gui_element.setColorScale(1.0, 1.0, 1.0, alpha_value) + except Exception: + pass + + def _get_cached_animation_structure_state(self, anim_node, force_refresh=False): + if not force_refresh: + cached_state = anim_node.getPythonTag("cached_has_animation_nodes") + if cached_state is not None: + return bool(cached_state) + + has_animation_nodes = False + try: + has_animation_nodes = ( + anim_node.findAllMatches("**/+Character").getNumPaths() > 0 or + anim_node.findAllMatches("**/+AnimBundleNode").getNumPaths() > 0 + ) + except Exception: + has_animation_nodes = False + + anim_node.setPythonTag("cached_has_animation_nodes", bool(has_animation_nodes)) + return has_animation_nodes + def _record_visibility_change(self, node, old_visible, new_visible): if not hasattr(self.app, "command_manager") or not self.app.command_manager: node.setPythonTag("user_visible", bool(new_visible)) @@ -22,7 +84,7 @@ class EditorPanelsRightMixin( return from core.Command_System import VisibilityNodeCommand self.app.command_manager.execute_command( - VisibilityNodeCommand(node, old_visible, new_visible) + VisibilityNodeCommand(node, old_visible, new_visible, world=self.app) ) def _record_name_change(self, node, old_name, new_name): @@ -212,6 +274,41 @@ class EditorPanelsRightMixin( imgui.text_disabled(f"{node_type} · 父级: {parent_name}") self._draw_status_badges(node, node_type) + def _node_has_collision_badge(self, node): + child_count = 0 + try: + child_count = int(node.getNumChildren()) + except Exception: + child_count = 0 + + cached = None + try: + cached = node.getPythonTag("cached_collision_badge_state") + except Exception: + cached = None + + if isinstance(cached, dict) and cached.get("child_count") == child_count: + return bool(cached.get("has_collision", False)) + + has_collision = False + try: + for child in node.getChildren(): + child_name = child.getName() + if child_name and "Collision" in child_name: + has_collision = True + break + except Exception: + has_collision = False + + try: + node.setPythonTag( + "cached_collision_badge_state", + {"child_count": child_count, "has_collision": bool(has_collision)}, + ) + except Exception: + pass + return has_collision + def _draw_status_badges(self, node, node_type=None): """绘制精简后的对象状态徽章行。""" if node_type is None: @@ -222,9 +319,7 @@ class EditorPanelsRightMixin( if node.is_hidden(): badges.append(("已隐藏", (0.65, 0.65, 0.65, 1.0))) - has_collision = hasattr(node, "getChild") and any( - "Collision" in child.getName() for child in node.getChildren() if child.getName() - ) + has_collision = hasattr(node, "getChild") and self._node_has_collision_badge(node) if has_collision: badges.append(("碰撞", (0.35, 0.65, 1.0, 1.0))) @@ -401,11 +496,7 @@ class EditorPanelsRightMixin( current_alpha = getattr(gui_element, 'alpha', 1.0) changed, new_alpha = imgui.slider_float("Alpha", current_alpha, 0.0, 1.0) if changed: - gui_element.alpha = new_alpha - if hasattr(gui_element, 'setTransparency'): - # 将0.0-1.0范围转换为Panda3D的透明度格式 - panda_transparency = int((1.0 - new_alpha) * 255) - gui_element.setTransparency(panda_transparency) + self._apply_gui_alpha(gui_element, new_alpha) # 渲染顺序 imgui.text("渲染顺序") @@ -538,22 +629,30 @@ class EditorPanelsRightMixin( except Exception: pass - # 先刷新一次模型动画标签,避免“导入后未初始化”导致误判 + has_animation_checked = ( + anim_node.hasTag("has_animations_checked") and + anim_node.getTag("has_animations_checked").lower() == "true" + ) + + # 只在模型还没做过动画探测时刷新标签,避免属性面板每帧重复扫描。 try: - if hasattr(self, "scene_manager") and self.scene_manager and hasattr(self.scene_manager, "_processModelAnimations"): + if ( + not has_animation_checked and + hasattr(self, "scene_manager") and + self.scene_manager and + hasattr(self.scene_manager, "_processModelAnimations") + ): self.scene_manager._processModelAnimations(anim_node) except Exception: pass has_animation_tag = anim_node.hasTag("has_animations") and anim_node.getTag("has_animations").lower() == "true" - has_animation_nodes = False - try: - has_animation_nodes = ( - anim_node.findAllMatches("**/+Character").getNumPaths() > 0 or - anim_node.findAllMatches("**/+AnimBundleNode").getNumPaths() > 0 - ) - except Exception: - pass + has_cached_animation = anim_node.getPythonTag("animation") is True + has_animation_nodes = ( + has_animation_tag or + has_cached_animation or + self._get_cached_animation_structure_state(anim_node) + ) # 检查是否已经缓存了动画信息 cached_anim_info = anim_node.getPythonTag("cached_anim_info") @@ -581,13 +680,17 @@ class EditorPanelsRightMixin( anim_node.setPythonTag("animation", None) should_force_probe = False - has_cached_animation = anim_node.getPythonTag("animation") is True if not (has_animation_tag or has_animation_nodes or has_cached_animation): imgui.text_colored((0.7, 0.7, 0.7, 1.0), "此模型未检测到动画结构") - if imgui.button("尝试强制检测##force_detect_animation"): + if self.app.style_manager.draw_toolbar_button( + "尝试强制检测", + size=(108, 26), + tooltip="重新扫描当前模型的动画结构", + ): should_force_probe = True anim_node.setPythonTag("cached_anim_info", None) anim_node.setPythonTag("cached_processed_names", None) + has_animation_nodes = self._get_cached_animation_structure_state(anim_node, force_refresh=True) else: return @@ -683,22 +786,22 @@ class EditorPanelsRightMixin( imgui.text("控制:") # 播放按钮 - if imgui.button("播放##play_animation"): + if self.app.style_manager.draw_toolbar_button("播放", size=(60, 26), tooltip="播放当前动画"): self._playAnimation(anim_node) imgui.same_line() # 暂停按钮 - if imgui.button("暂停##pause_animation"): + if self.app.style_manager.draw_toolbar_button("暂停", size=(60, 26), tooltip="暂停当前动画"): self._pauseAnimation(anim_node) imgui.same_line() # 停止按钮 - if imgui.button("停止##stop_animation"): + if self.app.style_manager.draw_toolbar_button("停止", size=(60, 26), tooltip="停止当前动画"): self._stopAnimation(anim_node) imgui.same_line() # 循环按钮 - if imgui.button("循环##loop_animation"): + if self.app.style_manager.draw_toolbar_button("循环", size=(60, 26), tooltip="循环播放当前动画"): self._loopAnimation(anim_node) imgui.spacing() @@ -725,7 +828,7 @@ class EditorPanelsRightMixin( def _draw_property_actions(self, node): """绘制属性操作按钮""" # 重置变换 - if imgui.button("重置变换"): + if self.app.style_manager.draw_toolbar_button("重置变换", size=(84, 28), tooltip="将位置、旋转、缩放恢复为默认值"): if hasattr(self.app, "command_manager") and self.app.command_manager: from core.Command_System import ( CompositeCommand, @@ -756,19 +859,23 @@ class EditorPanelsRightMixin( # 切换可见性 is_visible = not node.is_hidden() visibility_text = "隐藏" if is_visible else "显示" - if imgui.button(visibility_text): + if self.app.style_manager.draw_toolbar_button( + visibility_text, + size=(64, 28), + tooltip="切换当前对象的可见状态", + ): self._record_visibility_change(node, is_visible, not is_visible) imgui.same_line() # 聚焦到对象 - if imgui.button("聚焦"): + if self.app.style_manager.draw_toolbar_button("聚焦", size=(64, 28), tooltip="将相机聚焦到当前对象"): if hasattr(self, 'selection') and self.selection: self.selection.focusCameraOnSelectedNodeAdvanced() # 删除对象 imgui.same_line() - if imgui.button("删除"): + if self.app.style_manager.draw_toolbar_button("删除", size=(64, 28), tooltip="删除当前对象"): if hasattr(self, 'selection') and self.selection: self.selection.deleteSelectedNode() diff --git a/ui/panels/editor_panels_top.py b/ui/panels/editor_panels_top.py index aa3137f7..0e40fb59 100644 --- a/ui/panels/editor_panels_top.py +++ b/ui/panels/editor_panels_top.py @@ -3,6 +3,30 @@ from imgui_bundle import imgui, imgui_ctx class EditorPanelsTopMixin: """Auto-split mixin from editor_panels.py.""" + @staticmethod + def _truncate_toolbar_text(text, limit=20): + text = text or "" + if len(text) <= limit: + return text + return text[: max(0, limit - 1)] + "…" + + def _get_toolbar_project_name(self): + project_manager = getattr(self.app, "project_manager", None) + project_path = getattr(project_manager, "current_project_path", None) if project_manager else None + if not project_path: + return "未命名项目" + import os + return os.path.basename(project_path) or "未命名项目" + + def _get_toolbar_selection_name(self): + selected_node = self.app._get_selection_node() + if selected_node and not selected_node.isEmpty(): + return selected_node.getName() or "未命名对象" + ssbo_summary = self.app._get_ssbo_selection_summary() + if ssbo_summary: + return ssbo_summary.get("display_name") or "SSBO 选择" + return "未选择" + def draw_menu_bar(self): """绘制菜单栏""" with imgui_ctx.begin_main_menu_bar() as main_menu: @@ -265,86 +289,61 @@ class EditorPanelsTopMixin: return self.app.showToolbar = opened - - # 选择工具按钮 - select_active = self.app.tool_manager.isSelectionTool() - if self.app.icons.get('select'): - tint_col = (1.0, 1.0, 0.0, 1.0) if select_active else (1.0, 1.0, 1.0, 1.0) - if self.app.style_manager.image_button(self.app.icons['select'], (24, 24), tint_col=tint_col): - self.app.tool_manager.setCurrentTool("选择") - if imgui.is_item_hovered(): - imgui.set_tooltip("选择工具 (Q)") - imgui.same_line() - else: - if imgui.button("选择##select_tool"): - self.app.tool_manager.setCurrentTool("选择") - if select_active: - draw_list = imgui.get_window_draw_list() - button_min = imgui.get_item_rect_min() - button_max = imgui.get_item_rect_max() - draw_list.add_rect_filled(button_min, button_max, imgui.get_color_u32((0.3, 0.6, 1.0, 0.3))) - imgui.same_line() - - # 移动工具按钮 - move_active = self.app.tool_manager.isMoveTool() - if self.app.icons.get('move'): - # 使用不同颜色表示活动状态 - tint_col = (1.0, 1.0, 0.0, 1.0) if move_active else (1.0, 1.0, 1.0, 1.0) # 活动时显示黄色 - if self.app.style_manager.image_button(self.app.icons['move'], (24, 24), tint_col=tint_col): - self.app.tool_manager.setCurrentTool("移动") - if imgui.is_item_hovered(): - imgui.set_tooltip("移动工具 (W)") - imgui.same_line() - else: - if imgui.button("移动##move_tool"): - self.app.tool_manager.setCurrentTool("移动") - if move_active: - # 为活动按钮添加背景色 - draw_list = imgui.get_window_draw_list() - button_min = imgui.get_item_rect_min() - button_max = imgui.get_item_rect_max() - draw_list.add_rect_filled(button_min, button_max, imgui.get_color_u32((0.3, 0.6, 1.0, 0.3))) - imgui.same_line() - - # 旋转工具按钮 - rotate_active = self.app.tool_manager.isRotateTool() - if self.app.icons.get('rotate'): - tint_col = (1.0, 1.0, 0.0, 1.0) if rotate_active else (1.0, 1.0, 1.0, 1.0) - if self.app.style_manager.image_button(self.app.icons['rotate'], (24, 24), tint_col=tint_col): - self.app.tool_manager.setCurrentTool("旋转") - if imgui.is_item_hovered(): - imgui.set_tooltip("旋转工具 (E)") - imgui.same_line() - else: - if imgui.button("旋转##rotate_tool"): - self.app.tool_manager.setCurrentTool("旋转") - if rotate_active: - draw_list = imgui.get_window_draw_list() - button_min = imgui.get_item_rect_min() - button_max = imgui.get_item_rect_max() - draw_list.add_rect_filled(button_min, button_max, imgui.get_color_u32((0.3, 0.6, 1.0, 0.3))) - imgui.same_line() - - # 缩放工具按钮 - scale_active = self.app.tool_manager.isScaleTool() - if self.app.icons.get('scale'): - tint_col = (1.0, 1.0, 0.0, 1.0) if scale_active else (1.0, 1.0, 1.0, 1.0) - if self.app.style_manager.image_button(self.app.icons['scale'], (24, 24), tint_col=tint_col): - self.app.tool_manager.setCurrentTool("缩放") - if imgui.is_item_hovered(): - imgui.set_tooltip("缩放工具 (R)") - else: - if imgui.button("缩放##scale_tool"): - self.app.tool_manager.setCurrentTool("缩放") - if scale_active: - draw_list = imgui.get_window_draw_list() - button_min = imgui.get_item_rect_min() - button_max = imgui.get_item_rect_max() - draw_list.add_rect_filled(button_min, button_max, imgui.get_color_u32((0.3, 0.6, 1.0, 0.3))) - + style_manager = self.app.style_manager + command_manager = getattr(self.app, "command_manager", None) + can_undo = command_manager.can_undo() if command_manager else False + can_redo = command_manager.can_redo() if command_manager else False + selection_name = self._truncate_toolbar_text(self._get_toolbar_selection_name(), 18) + project_name = self._truncate_toolbar_text(self._get_toolbar_project_name(), 18) + tool_name = getattr(self.app.tool_manager, "getCurrentTool", lambda: "选择")() + + imgui.push_style_var(imgui.StyleVar_.item_spacing, (8.0, 6.0)) + imgui.push_style_var(imgui.StyleVar_.frame_padding, (8.0, 5.0)) + + tool_buttons = [ + ("Q 选择", "选择", self.app.tool_manager.isSelectionTool(), "选择工具 (Q)"), + ("W 移动", "移动", self.app.tool_manager.isMoveTool(), "移动工具 (W)"), + ("E 旋转", "旋转", self.app.tool_manager.isRotateTool(), "旋转工具 (E)"), + ("R 缩放", "缩放", self.app.tool_manager.isScaleTool(), "缩放工具 (R)"), + ] + + for index, (label, tool_id, is_active, tooltip) in enumerate(tool_buttons): + if style_manager.draw_toolbar_button(label, active=is_active, size=(74, 28), tooltip=tooltip): + self.app.tool_manager.setCurrentTool(tool_id) + if index != len(tool_buttons) - 1: + imgui.same_line() + imgui.same_line() imgui.separator() imgui.same_line() - - # 工具按钮已移除(导入、保存、播放) + + if style_manager.draw_toolbar_button("撤销", size=(56, 28), tooltip="撤销上一步 (Ctrl+Z)", enabled=can_undo): + self.app._on_undo() + imgui.same_line() + if style_manager.draw_toolbar_button("重做", size=(56, 28), tooltip="重做上一步 (Ctrl+Y)", enabled=can_redo): + self.app._on_redo() + imgui.same_line() + if style_manager.draw_toolbar_button( + "聚焦", + size=(56, 28), + tooltip="聚焦当前选择 (F)", + enabled=selection_name != "未选择", + ): + self.app.onFocusKeyPressed() + + imgui.same_line() + imgui.separator() + imgui.same_line() + style_manager.draw_stat_chip(f"项目 {project_name}", tint=(0.12, 0.14, 0.18, 1.0)) + imgui.same_line() + style_manager.draw_stat_chip(f"工具 {tool_name}", tint=(0.16, 0.19, 0.24, 1.0)) + imgui.same_line() + style_manager.draw_stat_chip(f"选择 {selection_name}", tint=(0.18, 0.24, 0.32, 1.0)) + imgui.same_line() + style_manager.draw_stat_chip( + f"历史 {command_manager.get_undo_count() if command_manager else 0}/{command_manager.get_redo_count() if command_manager else 0}", + tint=(0.16, 0.18, 0.2, 1.0), + ) + + imgui.pop_style_var(2) diff --git a/ui/panels/panel_delegates.py b/ui/panels/panel_delegates.py index 8fa86aeb..36929428 100644 --- a/ui/panels/panel_delegates.py +++ b/ui/panels/panel_delegates.py @@ -2,30 +2,38 @@ class PanelDelegates: - def _node_is_valid(self, node): - if not node: + def _node_is_valid(self, node, require_attached=False): + if node is None: return False try: - return not node.is_empty() + if node.is_empty(): + return False except Exception: try: - return not node.isEmpty() + if node.isEmpty(): + return False except Exception: return False + if require_attached: + try: + return bool(node.hasParent()) + except Exception: + return False + return True def _get_selection_node(self): """Return the current editor selection, preferring the active SSBO state.""" ssbo_editor = getattr(self, "ssbo_editor", None) if ssbo_editor and hasattr(ssbo_editor, "has_active_selection") and ssbo_editor.has_active_selection(): ssbo_node = ssbo_editor.get_selection_scene_node() - return ssbo_node if self._node_is_valid(ssbo_node) else None + return ssbo_node if self._node_is_valid(ssbo_node, require_attached=True) else None selection = getattr(self, "selection", None) if selection and hasattr(selection, "getSelectedNode"): node = selection.getSelectedNode() else: node = getattr(selection, "selectedNode", None) if selection else None - return node if self._node_is_valid(node) else None + return node if self._node_is_valid(node, require_attached=True) else None def _get_ssbo_selection_summary(self): ssbo_editor = getattr(self, "ssbo_editor", None) diff --git a/ui/panels/property_helpers.py b/ui/panels/property_helpers.py index 870d4e8e..680e7608 100644 --- a/ui/panels/property_helpers.py +++ b/ui/panels/property_helpers.py @@ -1014,6 +1014,13 @@ class PropertyHelpers: material = entry.get("material") if material is not None: self._refresh_pipeline_material_mode(node, material) + + ssbo_editor = getattr(self.app, "ssbo_editor", None) + if ssbo_editor and hasattr(ssbo_editor, "sync_scene_nodes_to_pick"): + try: + ssbo_editor.sync_scene_nodes_to_pick([node]) + except Exception: + pass except Exception as e: print(f"应用材质快照失败: {e}") @@ -1025,17 +1032,27 @@ class PropertyHelpers: if not node or node.isEmpty() or material is None: return - # For imported multi-material models we edit the existing material - # instances in place. Rebroadcasting one material to every GeomNode - # would collapse the whole model to a single material. - try: - if not node.hasMaterial(): + target_geom_paths = self._get_geom_paths_for_material(node, material) + if not target_geom_paths: + try: + if node.hasMaterial(): + node.setMaterial(material, 1) + except Exception: + pass + try: + target_geom_paths = [geom_path for geom_path in node.find_all_matches("**/+GeomNode")] + except Exception: + target_geom_paths = [] + if not target_geom_paths: self._invalidate_material_render_cache() return - except Exception: - pass - for geom_path in node.find_all_matches("**/+GeomNode"): + for geom_path in target_geom_paths: + try: + geom_path.setMaterial(material, 1) + except Exception: + pass + try: geom_path.setState(geom_path.getState().setAttrib(MaterialAttrib.make(material))) except Exception: @@ -1071,13 +1088,65 @@ class PropertyHelpers: unique_materials = [] seen_keys = set() for material in materials: - key = getattr(material, "this", None) or id(material) + key = self._get_material_identity_key(material) if key in seen_keys: continue seen_keys.add(key) unique_materials.append(material) return unique_materials + def _get_material_identity_key(self, material): + try: + return getattr(material, "this", None) or id(material) + except Exception: + return id(material) + + def _geom_uses_material(self, geom_path, material): + try: + from panda3d.core import MaterialAttrib + + target_key = self._get_material_identity_key(material) + + if geom_path.hasMaterial(): + geom_material = geom_path.getMaterial() + if self._get_material_identity_key(geom_material) == target_key: + return True + + geom_node = geom_path.node() + for geom_index in range(geom_node.getNumGeoms()): + geom_state = geom_node.getGeomState(geom_index) + if not geom_state.hasAttrib(MaterialAttrib): + continue + material_attrib = geom_state.getAttrib(MaterialAttrib) + geom_material = material_attrib.getMaterial() if material_attrib else None + if geom_material is not None and self._get_material_identity_key(geom_material) == target_key: + return True + + try: + net_state = geom_path.getNetState() + if net_state.hasAttrib(MaterialAttrib): + material_attrib = net_state.getAttrib(MaterialAttrib) + geom_material = material_attrib.getMaterial() if material_attrib else None + if geom_material is not None and self._get_material_identity_key(geom_material) == target_key: + return True + except Exception: + pass + except Exception: + pass + return False + + def _get_geom_paths_for_material(self, node, material): + try: + if not node or node.isEmpty() or material is None: + return [] + return [ + geom_path + for geom_path in node.find_all_matches("**/+GeomNode") + if self._geom_uses_material(geom_path, material) + ] + except Exception: + return [] + def _invalidate_material_render_cache(self): """Force Panda/RenderPipeline to pick up runtime material edits immediately.""" try: @@ -1141,10 +1210,9 @@ class PropertyHelpers: if previous_surface_type == 3: opacity = self._get_material_opacity(material) else: - # Entering transparent mode should keep the object fully - # visible by default. Reusing historical base-color alpha - # makes some assets appear to "disappear" immediately. - opacity = 0.92 + # Entering transparent mode should never unexpectedly + # inherit stale low alpha from imported material data. + opacity = 1.0 material.set_emission(Vec4(float(surface_type), float(emission.y), float(emission.z), float(emission.w))) self._set_material_opacity(node, material, opacity) else: @@ -1469,22 +1537,27 @@ class PropertyHelpers: use_metallic_effect = source_node.hasTag("material_effect_metallic_enabled") enable_parallax = source_node.hasTag("material_effect_parallax_enabled") - effect_path = "effects/pbr_with_metallic.yaml" if use_metallic_effect else "effects/default.yaml" - options = { - "normal_mapping": True, - "render_gbuffer": not use_forward, - "render_forward": use_forward, - "alpha_testing": False, - "parallax_mapping": enable_parallax, - "render_shadow": True, - "render_envmap": True, - } - sort = 60 if use_metallic_effect else 55 + if use_forward: + effect_path = "effects/simple_transparent.yaml" + options = {} + sort = 100 + else: + effect_path = "effects/pbr_with_metallic.yaml" if use_metallic_effect else "effects/default.yaml" + options = { + "normal_mapping": True, + "render_gbuffer": True, + "render_forward": False, + "alpha_testing": False, + "parallax_mapping": enable_parallax, + "render_shadow": True, + "render_envmap": True, + } + sort = 60 if use_metallic_effect else 55 effect_signature = "|".join(( effect_path, - "1" if options["render_forward"] else "0", - "1" if options["render_gbuffer"] else "0", - "1" if options["parallax_mapping"] else "0", + "1" if options.get("render_forward", False) else "0", + "1" if options.get("render_gbuffer", False) else "0", + "1" if options.get("parallax_mapping", False) else "0", "1" if use_metallic_effect else "0", )) current_signature = node.getTag("material_render_effect_signature") if node.hasTag("material_render_effect_signature") else "" @@ -1509,12 +1582,6 @@ class PropertyHelpers: surface_type = self._get_material_surface_type(material) if surface_type != 3: surface_type = 3 - else: - # The RP forward transparent path in this editor build becomes - # visually unusable at exact opacity 1.0. Treat transparent - # mode as a slightly blended surface; users can switch back to - # "不透明" when they want a fully opaque result. - opacity_value = min(opacity_value, 0.92) material.set_emission(Vec4(float(surface_type), float(emission.y), opacity_value, float(emission.w))) base_color = list(self._get_material_base_color(material)) @@ -1525,30 +1592,76 @@ class PropertyHelpers: except Exception as e: print(f"设置材质透明度失败: {e}") + def _get_material_transparent_base_color(self, material): + try: + base_color = list(self._get_material_base_color(material)) + if len(base_color) < 4: + base_color = list(base_color[:3]) + [1.0] + return tuple(float(v) for v in base_color[:4]) + except Exception: + return (1.0, 1.0, 1.0, 1.0) + def _apply_material_surface_state(self, node, material): """Sync Panda node transparency mode with the material surface type.""" try: from panda3d.core import ColorBlendAttrib, TransparencyAttrib - for target_node in self._iter_material_state_nodes(node): - target_material = self._get_renderable_node_material(target_node) or material + is_transparent = self._get_material_surface_type(material) == 3 + opacity = self._get_material_opacity(material) if is_transparent else 1.0 + base_color = self._get_material_transparent_base_color(material) + + target_nodes = self._get_geom_paths_for_material(node, material) + if not target_nodes: + target_nodes = self._iter_material_state_nodes(node) + + for target_node in target_nodes: + target_material = material if target_material is not None: try: target_node.setMaterial(target_material, 1) except Exception: pass - # Let RenderPipeline handle transparent materials via its - # forward pass. Leaving Panda-side alpha blending enabled here - # causes the object to be blended twice and makes live editing - # hard to reason about. - target_node.setTransparency(TransparencyAttrib.M_none) - target_node.setAlphaScale(1.0) - target_node.setDepthWrite(True) - try: - target_node.clearBin() - except Exception: - pass + if is_transparent: + target_node.setTransparency(TransparencyAttrib.M_alpha) + target_node.setAlphaScale(opacity) + try: + target_node.setColorScale(1.0, 1.0, 1.0, opacity) + except Exception: + pass + try: + target_node.setBin("transparent", 0) + except Exception: + pass + try: + target_node.setShaderInput("material_base_color", base_color) + except Exception: + pass + try: + target_node.setShaderInput("material_opacity", float(opacity)) + except Exception: + pass + target_node.setDepthWrite(False) + else: + target_node.setTransparency(TransparencyAttrib.M_none) + target_node.setAlphaScale(1.0) + target_node.setDepthWrite(True) + try: + target_node.clearBin() + except Exception: + pass + try: + target_node.clearColorScale() + except Exception: + pass + try: + target_node.clearShaderInput("material_base_color") + except Exception: + pass + try: + target_node.clearShaderInput("material_opacity") + except Exception: + pass try: target_node.clearAttrib(ColorBlendAttrib.getClassType()) except Exception: @@ -2239,7 +2352,7 @@ class PropertyHelpers: def start_transform_monitoring(self, node): """开始变换监控""" - if node and not node.isEmpty(): + if node is not None and (not node.isEmpty()) and node.hasParent(): self._monitored_node = node self._transform_monitoring = True self._transform_update_timer = 0 @@ -2257,7 +2370,7 @@ class PropertyHelpers: def _update_last_transform_values(self): """更新最后记录的变换值""" - if self._monitored_node and not self._monitored_node.isEmpty(): + if self._monitored_node is not None and (not self._monitored_node.isEmpty()) and self._monitored_node.hasParent(): try: pos = self._monitored_node.getPos() hpr = self._monitored_node.getHpr() @@ -2274,7 +2387,10 @@ class PropertyHelpers: def _check_transform_changes(self): """检查变换变化""" - if not self._transform_monitoring or not self._monitored_node: + if (not self._transform_monitoring) or self._monitored_node is None: + return + if self._monitored_node.isEmpty() or (not self._monitored_node.hasParent()): + self.stop_transform_monitoring() return try: diff --git a/新项目_副本/project.json b/新项目_副本/project.json new file mode 100644 index 00000000..d053d21e --- /dev/null +++ b/新项目_副本/project.json @@ -0,0 +1,8 @@ +{ + "name": "新项目_副本", + "path": "D:\\IMGUI\\EG\\新项目_副本", + "last_modified": "2026-03-13 16:55:57", + "scene_file": "scenes\\scene.bam", + "created_at": "2026-03-13 16:55:57", + "version": "1.0" +} \ No newline at end of file diff --git a/新项目_副本/scenes/scene.bam b/新项目_副本/scenes/scene.bam new file mode 100644 index 00000000..a8cccf47 Binary files /dev/null and b/新项目_副本/scenes/scene.bam differ diff --git a/新项目_副本/新项目_副本.png b/新项目_副本/新项目_副本.png new file mode 100644 index 00000000..ba2db267 Binary files /dev/null and b/新项目_副本/新项目_副本.png differ