From 6e76b54ddba90d7e70ce98e9efb88db4ba7ee264 Mon Sep 17 00:00:00 2001 From: ayuan9957 <107920784+ayuan9957@users.noreply.github.com> Date: Fri, 13 Mar 2026 12:33:23 +0800 Subject: [PATCH] =?UTF-8?q?=E6=92=A4=E9=94=80=E5=92=8C=E9=87=8D=E5=81=9A?= =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- TransformGizmo/transform_gizmo.py | 47 + core/Command_System.py | 350 ++++-- imgui.ini | 24 +- main.py | 66 +- ssbo_component/ssbo_editor.py | 5 + ui/LUI/lui_function_properties.py | 1139 +++++++++++++++----- ui/LUI/lui_manager_editor.py | 1087 ++++++++++++++++++- ui/LUI/lui_manager_interaction.py | 13 + ui/panels/app_actions.py | 181 +++- ui/panels/create_actions.py | 102 +- ui/panels/dialog_panels.py | 104 ++ ui/panels/editor_panels_right.py | 110 +- ui/panels/editor_panels_right_collision.py | 113 +- ui/panels/editor_panels_right_material.py | 81 +- ui/panels/editor_panels_right_transform.py | 40 + ui/panels/editor_panels_top.py | 6 +- ui/panels/panel_delegates.py | 48 + ui/panels/property_helpers.py | 416 ++++++- 18 files changed, 3362 insertions(+), 570 deletions(-) diff --git a/TransformGizmo/transform_gizmo.py b/TransformGizmo/transform_gizmo.py index 82d77574..c3132e52 100644 --- a/TransformGizmo/transform_gizmo.py +++ b/TransformGizmo/transform_gizmo.py @@ -462,10 +462,57 @@ class TransformGizmo(DirectObject): Callback used by MoveGizmo / RotateGizmo / ScaleGizmo to report that a transform action (move/rotate/scale) has been committed. """ + if self._record_action_with_command_manager(action): + return + self._history.append(action) # New user action invalidates redo chain. self._redo_history.clear() + 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) + if not command_manager: + return False + + try: + from core.Command_System import MoveNodeCommand, RotateNodeCommand, ScaleNodeCommand + + kind = action.get("kind") + node: NodePath = action.get("node") + if node is None or node.isEmpty(): + return False + + command = None + if kind == "move": + command = MoveNodeCommand( + node, + action.get("old_pos"), + action.get("new_pos"), + reference_node=self.world.render, + ) + elif kind == "rotate": + command = RotateNodeCommand( + node, + action.get("old_hpr"), + action.get("new_hpr"), + reference_node=self.world.render, + ) + elif kind == "scale": + command = ScaleNodeCommand( + node, + action.get("old_scale"), + action.get("new_scale"), + ) + + if command is None: + return False + + command_manager.execute_command(command) + return True + 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 d3fbb356..5e301764 100644 --- a/core/Command_System.py +++ b/core/Command_System.py @@ -1,10 +1,77 @@ from abc import ABC, abstractmethod from collections import deque -from typing import List -from panda3d.core import NodePath, Point3 - - -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 _register_scene_node(world, node: NodePath): + if not world or not _is_valid_node(node): + return + scene_manager = getattr(world, "scene_manager", None) + 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) + 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): """ 抽象命令类,所有具体命令都需要继承此类 """ @@ -44,18 +111,27 @@ class CommandManager: # 最大历史记录数 self._max_history = max_history - def execute_command(self, command: Command): - """ - 执行命令,并将其添加到撤销栈中 - """ - try: - command.execute() - self._undo_stack.append(command) - # 清空重做栈,因为执行新命令后就无法重做之前的命令了 - self._redo_stack.clear() - except Exception as e: - print(f"执行命令时出错: {e}") - raise + 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: """ @@ -128,33 +204,34 @@ class CommandManager: # 示例命令实现 -class MoveNodeCommand(Command): +class MoveNodeCommand(Command): """ 移动节点命令示例 """ - def __init__(self, node: NodePath, old_pos, new_pos): - self.node = node - self.old_pos = old_pos - self.new_pos = new_pos + 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 execute(self): - """ - 执行移动操作 - """ - self.node.setPos(self.new_pos) + def execute(self): + """ + 执行移动操作 + """ + _apply_vec3_method(self.node, "setPos", self.new_pos, self.reference_node) def undo(self): """ 撤销移动操作 """ - self.node.setPos(self.old_pos) + _apply_vec3_method(self.node, "setPos", self.old_pos, self.reference_node) def redo(self): """ 重做移动操作 """ - self.node.setPos(self.new_pos) + _apply_vec3_method(self.node, "setPos", self.new_pos, self.reference_node) class DeleteNodeCommand(Command): @@ -336,36 +413,37 @@ class DeleteNodeCommand(Command): self.execute() -class RotateNodeCommand(Command): +class RotateNodeCommand(Command): """ 旋转节点命令 """ - def __init__(self, node: NodePath, old_hpr, new_hpr): - self.node = node - self.old_hpr = old_hpr - self.new_hpr = new_hpr + 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 execute(self): - """ - 执行旋转操作 - """ - self.node.setHpr(self.new_hpr) + def execute(self): + """ + 执行旋转操作 + """ + _apply_vec3_method(self.node, "setHpr", self.new_hpr, self.reference_node) def undo(self): """ 撤销旋转操作 """ - self.node.setHpr(self.old_hpr) + _apply_vec3_method(self.node, "setHpr", self.old_hpr, self.reference_node) def redo(self): """ 重做旋转操作 """ - self.node.setHpr(self.new_hpr) + _apply_vec3_method(self.node, "setHpr", self.new_hpr, self.reference_node) -class ScaleNodeCommand(Command): +class ScaleNodeCommand(Command): """ 缩放节点命令 """ @@ -375,56 +453,176 @@ class ScaleNodeCommand(Command): self.old_scale = old_scale self.new_scale = new_scale - def execute(self): - """ - 执行缩放操作 - """ - self.node.setScale(self.new_scale) + def execute(self): + """ + 执行缩放操作 + """ + _apply_vec3_method(self.node, "setScale", self.new_scale) def undo(self): """ 撤销缩放操作 """ - self.node.setScale(self.old_scale) + _apply_vec3_method(self.node, "setScale", self.old_scale) - def redo(self): - """ - 重做缩放操作 - """ - self.node.setScale(self.new_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): + """ + 创建节点命令 + """ - -class CreateNodeCommand(Command): - """ - 创建节点命令 - """ - - def __init__(self, node_creator_func,parent_node, *args, **kwargs): - self.node_creator_func = node_creator_func - self.parent_node = parent_node - self.args = args - self.kwargs = kwargs - self.created_node = None + 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): """ 执行创建节点操作 """ - self.created_node = self.node_creator_func(self.parent_node,*self.args, **self.kwargs) - return self.created_node + 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 def undo(self): - """ - 撤销创建节点操作 - """ - if self.created_node: - self.created_node.detachNode() + """ + 撤销创建节点操作 + """ + if self.created_node: + _unregister_scene_node(self.world, self.created_node) + self.created_node.detachNode() def redo(self): - """ - 重做创建节点操作 - """ - self.execute() + """ + 重做创建节点操作 + """ + 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() class ReparentNodeCommand(Command): diff --git a/imgui.ini b/imgui.ini index 292225a9..e089d092 100644 --- a/imgui.ini +++ b/imgui.ini @@ -25,25 +25,25 @@ Collapsed=0 [Window][工具栏] Pos=354,20 -Size=1334,32 +Size=1846,32 Collapsed=0 DockId=0x0000000D,0 [Window][场景树] Pos=0,20 -Size=352,748 +Size=352,995 Collapsed=0 DockId=0x00000007,0 [Window][属性面板] -Pos=1690,20 -Size=358,748 +Pos=2202,20 +Size=358,995 Collapsed=0 DockId=0x00000002,0 [Window][控制台] -Pos=1690,20 -Size=358,748 +Pos=2202,20 +Size=358,995 Collapsed=0 DockId=0x00000002,1 @@ -60,7 +60,7 @@ Collapsed=0 [Window][WindowOverViewport_11111111] Pos=0,20 -Size=2048,1084 +Size=2560,1331 Collapsed=0 [Window][测试窗口1] @@ -79,7 +79,7 @@ Size=93,65 Collapsed=0 [Window][新建项目] -Pos=824,401 +Pos=824,402 Size=400,300 Collapsed=0 @@ -99,8 +99,8 @@ Size=600,500 Collapsed=0 [Window][资源管理器] -Pos=0,770 -Size=2048,334 +Pos=0,1017 +Size=2560,334 Collapsed=0 DockId=0x00000006,0 @@ -207,13 +207,13 @@ Collapsed=0 DockId=0x00000002,2 [Docking][Data] -DockSpace ID=0x08BD597D Window=0x1BBC0F80 Pos=0,20 Size=2048,1084 Split=Y +DockSpace ID=0x08BD597D Window=0x1BBC0F80 Pos=0,20 Size=2560,1331 Split=Y DockNode ID=0x00000005 Parent=0x08BD597D SizeRef=2560,995 Split=X DockNode ID=0x00000007 Parent=0x00000005 SizeRef=352,1084 Selected=0xE0015051 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=0x00000002 Parent=0x00000008 SizeRef=358,989 Selected=0x5428E753 + DockNode ID=0x00000002 Parent=0x00000008 SizeRef=358,989 Selected=0x5DB6FF37 DockNode ID=0x00000006 Parent=0x08BD597D SizeRef=2560,334 Selected=0x3A2E05C3 diff --git a/main.py b/main.py index 77f717ae..8eef26e7 100644 --- a/main.py +++ b/main.py @@ -135,7 +135,7 @@ class MyWorld(PanelDelegates, CoreWorld): self.win.request_properties(props) self._last_forced_window_sync_size = None self._remaining_window_sync_attempts = 6 - self.taskMgr.doMethodLater(0.05, self._sync_window_metrics_task, "initial-window-metrics-sync") + self.taskMgr.doMethodLater(0.05, self._sync_window_metrics_task, "window-metrics-sync") print(f"✓ 窗口初始化完成: {self.win.getXSize()} x {self.win.getYSize()}") # Legacy compatibility fields used by scene/project modules. @@ -389,6 +389,9 @@ class MyWorld(PanelDelegates, CoreWorld): self.show_new_project_dialog = False self.show_open_project_dialog = False self.show_save_as_dialog = False + self.show_about_dialog = False + self.save_as_project_name = "" + self.save_as_project_path = "" # 路径选择对话框状态 self.show_path_browser = False @@ -630,11 +633,61 @@ class MyWorld(PanelDelegates, CoreWorld): if not target_window: return self.windowEvent(target_window) + self._sync_window_dependent_state(target_window) if not target_window.getProperties().getOpen(): self.userExit() except Exception as e: print(f"处理窗口事件失败: {e}") + def _sync_window_dependent_state(self, window): + """Keep camera and picking lenses aligned with the current client size.""" + try: + width = int(window.getXSize()) + height = int(window.getYSize()) + if width <= 0 or height <= 0: + return + + render_pipeline = getattr(self, "render_pipeline", None) + if render_pipeline: + try: + pipeline_window_dims = getattr(render_pipeline, "_last_window_dims", None) + pipeline_needs_resize = ( + pipeline_window_dims is None + or int(pipeline_window_dims.x) != width + or int(pipeline_window_dims.y) != height + ) + if pipeline_needs_resize and hasattr(render_pipeline, "_handle_window_event"): + render_pipeline._handle_window_event(window) + except Exception as e: + print(f"同步 RenderPipeline 窗口尺寸失败: {e}") + + aspect_ratio = float(width) / float(height) + + lens = getattr(self, "camLens", None) + if lens: + try: + lens.setAspectRatio(aspect_ratio) + except Exception: + pass + + cam = getattr(self, "cam", None) + if cam: + try: + cam_lens = cam.node().getLens() + if cam_lens: + cam_lens.setAspectRatio(aspect_ratio) + except Exception: + pass + + ssbo_editor = getattr(self, "ssbo_editor", None) + if ssbo_editor and getattr(ssbo_editor, "pick_lens", None): + try: + ssbo_editor.pick_lens.setAspectRatio(aspect_ratio) + except Exception: + pass + except Exception as e: + print(f"同步窗口依赖状态失败: {e}") + def _sync_window_metrics_task(self, task): """Force early window-size synchronization so RP/ImGui match the real client area.""" if self._shutdown_in_progress: @@ -652,14 +705,17 @@ class MyWorld(PanelDelegates, CoreWorld): self._last_forced_window_sync_size = current_size messenger.send("window-event", [self.win]) print(f"✓ 同步窗口分辨率: {width} x {height}") + else: + self._sync_window_dependent_state(self.win) except Exception as e: print(f"同步窗口分辨率失败: {e}") - self._remaining_window_sync_attempts -= 1 if self._remaining_window_sync_attempts > 0: + self._remaining_window_sync_attempts -= 1 task.delayTime = 0.15 - return task.again - return task.done + else: + task.delayTime = 0.05 + return task.again def userExit(self): """统一退出流程,避免关闭窗口后进程残留。""" @@ -952,6 +1008,7 @@ class MyWorld(PanelDelegates, CoreWorld): # 绘制对话框 self._draw_new_project_dialog() self._draw_open_project_dialog() + self._draw_save_as_project_dialog() self._draw_path_browser() self._draw_import_dialog() @@ -970,6 +1027,7 @@ class MyWorld(PanelDelegates, CoreWorld): # 绘制纹理选择对话框 self._draw_texture_file_dialog() self._draw_script_browser() + self._draw_about_dialog() # 绘制右键菜单 self._draw_context_menus() diff --git a/ssbo_component/ssbo_editor.py b/ssbo_component/ssbo_editor.py index 4835c6e7..a0e35eba 100644 --- a/ssbo_component/ssbo_editor.py +++ b/ssbo_component/ssbo_editor.py @@ -1248,6 +1248,11 @@ class SSBOEditor: not self.controller or not self.model): return False self._sync_pick_model_transform() + + try: + self.pick_lens.setAspectRatio(self.base.camLens.getAspectRatio()) + except Exception: + pass self.pick_lens.set_fov(0.1) self.pick_lens.set_film_offset(0, 0) diff --git a/ui/LUI/lui_function_properties.py b/ui/LUI/lui_function_properties.py index 97c88882..d4c12d95 100644 --- a/ui/LUI/lui_function_properties.py +++ b/ui/LUI/lui_function_properties.py @@ -5,6 +5,7 @@ from pathlib import Path import panda3d.core as p3d from imgui_bundle import imgui, imgui_ctx +from ui.LUI.lui_shared import LUISprite class LUIFunctionPropertiesMixin: @@ -13,6 +14,10 @@ class LUIFunctionPropertiesMixin: if index < 0 or index >= len(manager.components): return + before_snapshot = None + if hasattr(manager, "_capture_lui_structure_snapshot") and not getattr(manager, "_restoring_lui_structure", False): + before_snapshot = manager._capture_lui_structure_snapshot() + # 1. Identify all components to delete (recursive) to_delete = {index} queue = list(manager.components[index].get('children_indices', [])) @@ -107,6 +112,12 @@ class LUIFunctionPropertiesMixin: new_outlines[old_idx - shift] = borders manager.debug_outlines = new_outlines + if before_snapshot is not None and hasattr(manager, "_record_lui_structure_snapshot_command"): + manager._record_lui_structure_snapshot_command( + before_snapshot, + manager._capture_lui_structure_snapshot(), + ) + def _load_ui_texture(manager, file_path, name_hint="", max_size=1024): """Load image as a standalone Panda3D Texture (avoid LUI atlas).""" try: @@ -289,7 +300,7 @@ class LUIFunctionPropertiesMixin: # Text content # Text content manager.luiFunction._draw_text_and_button_properties( - manager, comp_data, comp_obj, comp_type + manager, index, comp_data, comp_obj, comp_type ) manager.luiFunction._draw_layout_transform_properties( @@ -303,22 +314,22 @@ class LUIFunctionPropertiesMixin: manager, index, comp_data, comp_obj ) - def _draw_text_and_button_properties(manager, comp_data, comp_obj, comp_type): + def _draw_text_and_button_properties(manager, index, comp_data, comp_obj, comp_type): if comp_type not in ['Button', 'Text', 'Label']: return # 编排入口:文本编辑/字号/颜色与按钮贴图拆分,降低单函数复杂度。 - manager.luiFunction._draw_text_content_editor(comp_data, comp_obj) + manager.luiFunction._draw_text_content_editor(manager, index, comp_data, comp_obj) if comp_type in ['Text', 'Label']: - manager.luiFunction._draw_text_font_size_editor(comp_data, comp_obj) + manager.luiFunction._draw_text_font_size_editor(manager, index, comp_data, comp_obj) - draw_color = manager.luiFunction._draw_text_color_editor(comp_data) + draw_color = manager.luiFunction._draw_text_color_editor(manager, index, comp_data) manager.luiFunction._sync_text_or_button_color(comp_obj, comp_type, draw_color) if comp_type == 'Button': - manager.luiFunction._draw_button_texture_controls(manager, comp_data, comp_obj) + manager.luiFunction._draw_button_texture_controls(manager, index, comp_data, comp_obj) - def _draw_text_content_editor(manager, comp_data, comp_obj): + def _draw_text_content_editor(manager, index, comp_data, comp_obj): imgui.text("Text Content") imgui.separator() if 'text' not in comp_data: @@ -327,17 +338,23 @@ class LUIFunctionPropertiesMixin: imgui.push_item_width(-1) changed, new_text = imgui.input_text("##text_input", comp_data['text'], 256) imgui.pop_item_width() + if imgui.is_item_activated(): + manager._begin_component_snapshot_session(index, "lui_text") if changed: comp_data['text'] = new_text comp_obj.text = new_text print(f"Text updated: {new_text}") + manager._finish_component_snapshot_session(index, "lui_text") - def _draw_text_font_size_editor(manager, comp_data, comp_obj): + def _draw_text_font_size_editor(manager, index, comp_data, comp_obj): imgui.spacing() imgui.text("Font Size") font_size = int(comp_data.get('font_size', 14)) changed, new_size = imgui.slider_int("##font_size", font_size, 8, 96) + if imgui.is_item_activated(): + manager._begin_component_snapshot_session(index, "lui_font_size") if not changed: + manager._finish_component_snapshot_session(index, "lui_font_size") return comp_data['font_size'] = int(new_size) @@ -350,16 +367,20 @@ class LUIFunctionPropertiesMixin: comp_obj.text_handle.font_size = int(new_size) except Exception as e: print(f"Font size update failed: {e}") + manager._finish_component_snapshot_session(index, "lui_font_size") - def _draw_text_color_editor(manager, comp_data): + def _draw_text_color_editor(manager, index, comp_data): imgui.spacing() imgui.text("Color") color = comp_data.get('color', (1.0, 1.0, 1.0, 1.0)) color = list(color) if isinstance(color, tuple) else color changed, new_color = imgui.color_edit4("##color_edit", color) + if imgui.is_item_activated(): + manager._begin_component_snapshot_session(index, "lui_text_color") draw_color = tuple(new_color) if changed: comp_data['color'] = draw_color + manager._finish_component_snapshot_session(index, "lui_text_color") imgui.separator() return draw_color @@ -391,27 +412,33 @@ class LUIFunctionPropertiesMixin: if hasattr(comp_obj, '_text'): comp_obj._text.color = color_value - def _draw_button_texture_controls(manager, comp_data, comp_obj): + def _draw_button_texture_controls(manager, index, comp_data, comp_obj): imgui.text("按钮图片") manager.luiFunction._draw_button_texture_pick_button( - manager, comp_data, comp_obj, "更改默认图##button_img_norm", "normal" + manager, index, comp_data, comp_obj, "更改默认图##button_img_norm", "normal" ) manager.luiFunction._draw_button_texture_pick_button( - manager, comp_data, comp_obj, "更改悬停图##button_img_hover", "hover" + manager, index, comp_data, comp_obj, "更改悬停图##button_img_hover", "hover" ) manager.luiFunction._draw_button_texture_pick_button( - manager, comp_data, comp_obj, "更改按下图##button_img_pressed", "pressed" + manager, index, comp_data, comp_obj, "更改按下图##button_img_pressed", "pressed" ) if imgui.button("使用默认图尺寸##button_fit", (160, 20)): - manager.luiFunction._fit_button_to_default_texture_size(comp_data, comp_obj) + manager._apply_component_change_with_history( + index, + lambda: manager.luiFunction._fit_button_to_default_texture_size(manager, comp_data, comp_obj), + ) if imgui.button("恢复默认按钮##button_default", (160, 20)): - manager.luiFunction._clear_button_custom_textures(comp_data, comp_obj) + manager._apply_component_change_with_history( + index, + lambda: manager.luiFunction._clear_button_custom_textures(manager, comp_data, comp_obj), + ) manager.luiFunction._draw_button_texture_path_labels(comp_data) - def _draw_button_texture_pick_button(manager, comp_data, comp_obj, button_label, variant): + def _draw_button_texture_pick_button(manager, index, comp_data, comp_obj, button_label, variant): if not imgui.button(button_label, (160, 20)): return @@ -428,8 +455,11 @@ class LUIFunctionPropertiesMixin: return try: - manager.luiFunction._apply_button_texture_variant( - manager, comp_data, comp_obj, selected_path, variant + manager._apply_component_change_with_history( + index, + lambda: manager.luiFunction._apply_button_texture_variant( + manager, comp_data, comp_obj, selected_path, variant + ), ) except Exception as e: print(f"Button {variant} texture set failed: {e}") @@ -439,7 +469,7 @@ class LUIFunctionPropertiesMixin: if not atlas_result: return - tex, u0, v0, u1, v1, w, h = manager.luiFunction._unpack_button_atlas_result(atlas_result) + tex, u0, v0, u1, v1, w, h = manager.luiFunction._unpack_button_atlas_result(manager, atlas_result) if variant == "normal": comp_data['button_texture_path'] = selected_path comp_data['button_texture_path_normal'] = selected_path @@ -461,7 +491,7 @@ class LUIFunctionPropertiesMixin: comp_data['button_texture_ref_pressed'] = tex comp_data['button_image_size_pressed'] = (int(w), int(h)) - manager.luiFunction._apply_button_custom_textures(comp_data, comp_obj) + manager.luiFunction._apply_button_custom_textures(manager, comp_data, comp_obj) def _unpack_button_atlas_result(manager, atlas_result): if len(atlas_result) >= 7: @@ -530,11 +560,422 @@ class LUIFunctionPropertiesMixin: if comp_data.get('button_texture_path_pressed'): imgui.text(f"按下图: {comp_data['button_texture_path_pressed']}") + def _capture_input_field_default_runtime(manager, comp_data, comp_obj): + if comp_data.get("_input_default_runtime"): + return + + layout = getattr(comp_obj, "_layout", None) + if layout is None: + return + + default_runtime = {} + for attr in ("_sprite_left", "_sprite_mid", "_sprite_right"): + spr = getattr(layout, attr, None) + if spr is None: + continue + tex = None + try: + if hasattr(spr, "get_texture"): + tex = spr.get_texture() + else: + tex = getattr(spr, "texture", None) + except Exception: + tex = None + default_runtime[attr] = {"texture": tex} + + if default_runtime: + comp_data["_input_default_runtime"] = default_runtime + + def _restore_input_field_default_texture(manager, comp_data, comp_obj): + layout = getattr(comp_obj, "_layout", None) + if layout is None: + return + + default_runtime = comp_data.get("_input_default_runtime", {}) or {} + for attr in ("_sprite_left", "_sprite_mid", "_sprite_right"): + spr = getattr(layout, attr, None) + if spr is None: + continue + default_texture = default_runtime.get(attr, {}).get("texture") + try: + if hasattr(spr, "set_texture"): + if default_texture is not None: + spr.set_texture(default_texture, resize=False) + else: + spr.set_texture(None) + elif hasattr(spr, "texture"): + spr.texture = default_texture + if hasattr(spr, "set_uv_range"): + spr.set_uv_range(0, 0, 1, 1) + except Exception: + pass + + comp_data["input_texture_path"] = None + comp_data["input_atlas_uv"] = None + comp_data["input_texture_ref"] = None + comp_data["input_image_size"] = None + + def _apply_input_field_texture_path(manager, comp_data, comp_obj, selected_path): + manager.luiFunction._capture_input_field_default_runtime(manager, comp_data, comp_obj) + atlas_result = manager.luiFunction._add_image_to_atlas(manager, selected_path, atlas_size=2048) + if not atlas_result: + return False + + if len(atlas_result) >= 7: + tex, u0, v0, u1, v1, w, h = atlas_result[:7] + else: + tex, u0, v0, u1, v1 = atlas_result + w = int(max(1, round((u1 - u0) * tex.getXSize()))) + h = int(max(1, round((v1 - v0) * tex.getYSize()))) + + comp_data["input_texture_path"] = selected_path + comp_data["input_atlas_uv"] = (u0, v0, u1, v1) + comp_data["input_texture_ref"] = tex + comp_data["input_image_size"] = (int(w), int(h)) + + layout = getattr(comp_obj, "_layout", None) + if layout is not None: + for attr in ("_sprite_left", "_sprite_mid", "_sprite_right"): + spr = getattr(layout, attr, None) + if spr is not None and hasattr(spr, "set_texture"): + spr.set_texture(tex, resize=False) + if hasattr(spr, "set_uv_range"): + spr.set_uv_range(u0, v0, u1, v1) + return True + + def _fit_input_field_to_texture(manager, comp_data, comp_obj): + w = h = None + if comp_data.get("input_image_size"): + try: + w, h = comp_data["input_image_size"] + except Exception: + w = h = None + elif comp_data.get("input_atlas_uv") and comp_data.get("input_texture_ref"): + try: + u0, v0, u1, v1 = comp_data["input_atlas_uv"] + tex = comp_data["input_texture_ref"] + tw = tex.getXSize() if hasattr(tex, "getXSize") else 0 + th = tex.getYSize() if hasattr(tex, "getYSize") else 0 + w = int(max(1, round((u1 - u0) * tw))) + h = int(max(1, round((v1 - v0) * th))) + except Exception: + w = h = None + + if w is None or h is None or w <= 0 or h <= 0: + return False + + comp_data["width"] = float(w) + comp_data["height"] = float(h) + comp_obj.width = float(w) + comp_obj.height = float(h) + if hasattr(comp_obj, "set_width"): + comp_obj.set_width(float(w)) + if hasattr(comp_obj, "set_height"): + comp_obj.set_height(float(h)) + layout = getattr(comp_obj, "_layout", None) + if layout is not None: + if hasattr(layout, "width"): + layout.width = "100%" + if hasattr(layout, "height"): + layout.height = "100%" + return True + + def _apply_blank_image_texture(manager, comp_data, comp_obj): + old_spr = comp_data.get("sprite") + if old_spr: + try: + old_spr.parent = None + if hasattr(old_spr, "set_texture"): + old_spr.set_texture(None) + if hasattr(old_spr, "destroy"): + old_spr.destroy() + except Exception: + pass + + new_spr = LUISprite(comp_obj, "blank", "skin") + new_spr.width = comp_data.get("width", 100) + new_spr.height = comp_data.get("height", 100) + new_spr.left = 0 + new_spr.top = 0 + new_spr.z_offset = comp_data.get("z_offset", 0) + new_spr.color = comp_data.get("color", (1, 1, 1, 1)) + comp_data["sprite"] = new_spr + comp_data["texture_path"] = "blank" + comp_data["current_texture"] = "blank" + comp_data["atlas_uv"] = None + comp_data["atlas_tex"] = None + comp_data["texture_ref"] = None + comp_data["image_size"] = None + + def _apply_image_texture_path(manager, comp_data, comp_obj, new_path): + new_path = str(new_path or "blank").strip() or "blank" + if new_path == "blank": + manager.luiFunction._apply_blank_image_texture(manager, comp_data, comp_obj) + return True + + atlas_result = manager.luiFunction._add_image_to_atlas(manager, new_path, atlas_size=2048) + if not atlas_result: + return False + + if len(atlas_result) >= 7: + tex, u0, v0, u1, v1, w, h = atlas_result[:7] + else: + tex, u0, v0, u1, v1 = atlas_result + w = int(max(1, round((u1 - u0) * tex.getXSize()))) + h = int(max(1, round((v1 - v0) * tex.getYSize()))) + + comp_data["texture_path"] = new_path + comp_data["current_texture"] = os.path.basename(new_path) + comp_data["atlas_uv"] = (u0, v0, u1, v1) + comp_data["atlas_tex"] = tex + comp_data["image_size"] = (int(w), int(h)) + + old_spr = comp_data.get("sprite") + if old_spr: + try: + old_spr.parent = None + if hasattr(old_spr, "set_texture"): + old_spr.set_texture(None) + if hasattr(old_spr, "destroy"): + old_spr.destroy() + except Exception: + pass + + new_spr = LUISprite(comp_obj, tex) + if hasattr(new_spr, "set_texture"): + new_spr.set_texture(tex, resize=False) + if hasattr(new_spr, "set_uv_range"): + new_spr.set_uv_range(u0, v0, u1, v1) + new_spr.width = comp_data.get("width", 100) + new_spr.height = comp_data.get("height", 100) + new_spr.left = 0 + new_spr.top = 0 + new_spr.z_offset = comp_data.get("z_offset", 0) + new_spr.color = comp_data.get("color", (1, 1, 1, 1)) + comp_data["sprite"] = new_spr + comp_data["texture_ref"] = tex + + if not hasattr(manager, "texture_refs"): + manager.texture_refs = [] + manager.texture_refs.append(tex) + + if hasattr(new_spr, "set_python_tag"): + new_spr.set_python_tag("texture_ref", tex) + if hasattr(comp_obj, "set_python_tag"): + comp_obj.set_python_tag("texture_ref", tex) + return True + + def _fit_image_to_texture(manager, comp_data, comp_obj): + tex = None + w = h = None + if comp_data.get("image_size"): + try: + w, h = comp_data["image_size"] + except Exception: + w = h = None + elif comp_data.get("atlas_uv") and comp_data.get("atlas_tex"): + try: + u0, v0, u1, v1 = comp_data["atlas_uv"] + atlas_tex = comp_data["atlas_tex"] + atlas_w = atlas_tex.getXSize() if hasattr(atlas_tex, "getXSize") else 0 + atlas_h = atlas_tex.getYSize() if hasattr(atlas_tex, "getYSize") else 0 + w = int(max(1, round((u1 - u0) * atlas_w))) + h = int(max(1, round((v1 - v0) * atlas_h))) + except Exception: + w = h = None + else: + tex = comp_data.get("texture_ref") or comp_data.get("texture") + if tex is not None: + if hasattr(tex, "getOrigFileXSize") and hasattr(tex, "getOrigFileYSize"): + ow = tex.getOrigFileXSize() + oh = tex.getOrigFileYSize() + if ow and oh: + w, h = ow, oh + if (w is None or h is None) and hasattr(tex, "getXSize") and hasattr(tex, "getYSize"): + w = tex.getXSize() + h = tex.getYSize() + + if w is None or h is None or w <= 0 or h <= 0: + return False + + comp_data["width"] = float(w) + comp_data["height"] = float(h) + comp_obj.width = float(w) + comp_obj.height = float(h) + spr = comp_data.get("sprite") + if spr: + if hasattr(spr, "set_size"): + spr.set_size(float(w), float(h)) + else: + spr.width = float(w) + spr.height = float(h) + return True + + def _clear_video_audio_runtime(manager, comp_data): + old_audio = comp_data.get("audio") + if old_audio and hasattr(old_audio, "stop"): + try: + old_audio.stop() + except Exception: + pass + old_texture = comp_data.get("texture") + if old_texture and hasattr(old_texture, "stop"): + try: + old_texture.stop() + except Exception: + pass + keep_alive = comp_data.get("keep_alive") + if keep_alive is not None: + try: + if hasattr(keep_alive, "destroy"): + keep_alive.destroy() + elif hasattr(keep_alive, "removeNode") and not keep_alive.isEmpty(): + keep_alive.removeNode() + except Exception: + pass + comp_data["keep_alive"] = None + comp_data["audio"] = None + comp_data["texture"] = None + if hasattr(comp_data.get("object"), "video_texture"): + try: + comp_data["object"].video_texture = None + except Exception: + pass + + def _apply_blank_video_texture(manager, comp_data, comp_obj): + old_spr = comp_data.get("sprite") + if old_spr: + try: + old_spr.parent = None + if hasattr(old_spr, "hide"): + old_spr.hide() + if hasattr(old_spr, "destroy"): + old_spr.destroy() + except Exception: + pass + + new_spr = LUISprite(comp_obj, "blank", "skin") + new_spr.width = comp_data.get("width", 320) + new_spr.height = comp_data.get("height", 240) + new_spr.left = 0 + new_spr.top = 0 + new_spr.z_offset = 0 + new_spr.color = (0.0, 0.0, 0.0, 1.0) + comp_data["sprite"] = new_spr + + def _reapply_video_resource_state(manager, comp_data, comp_obj): + manager.luiFunction._clear_video_audio_runtime(manager, comp_data) + desired_playing = bool(comp_data.get("is_playing", True)) + desired_playback_time = float(comp_data.get("playback_time", 0.0) or 0.0) + desired_loop = bool(comp_data.get("loop", True)) + video_path = str(comp_data.get("video_path", "") or "").strip() + if video_path: + try: + video_texture = manager.luiFunction._load_video_texture_from_source(manager, video_path) + if video_texture: + manager.luiFunction._apply_loaded_video_texture(manager, comp_data, comp_obj, video_texture, video_path) + comp_data["is_playing"] = desired_playing + comp_data["playback_time"] = desired_playback_time + except Exception as e: + print(f"视频资源回放失败: {e}") + else: + manager.luiFunction._apply_blank_video_texture(manager, comp_data, comp_obj) + comp_data["is_playing"] = desired_playing + comp_data["playback_time"] = desired_playback_time + + audio_path = str(comp_data.get("audio_path", "") or "").strip() + if audio_path and (not comp_data.get("audio_from_video") or audio_path != video_path): + manager.luiFunction._load_audio_for_component( + manager, comp_data, audio_path, from_video=False, stop_current=False + ) + + audio_sound = comp_data.get("audio") + if audio_sound and hasattr(audio_sound, "setVolume"): + try: + audio_sound.setVolume(comp_data.get("volume", 1.0)) + except Exception: + pass + if audio_sound and hasattr(audio_sound, "setLoop"): + try: + audio_sound.setLoop(desired_loop) + except Exception: + pass + + video_tex = comp_data.get("texture") + if video_tex and hasattr(video_tex, "setLoop"): + try: + video_tex.setLoop(desired_loop) + except Exception: + pass + + if video_tex and desired_playback_time > 0.0 and hasattr(video_tex, "setTime"): + try: + video_tex.setTime(desired_playback_time) + except Exception: + pass + if audio_sound and desired_playback_time > 0.0 and hasattr(audio_sound, "setTime"): + try: + audio_sound.setTime(desired_playback_time) + except Exception: + pass + + if desired_playing: + try: + if video_tex and hasattr(video_tex, "play"): + video_tex.play() + if audio_sound and hasattr(audio_sound, "play"): + audio_sound.play() + except Exception: + pass + else: + try: + if video_tex and hasattr(video_tex, "stop"): + video_tex.stop() + if audio_sound and hasattr(audio_sound, "stop"): + audio_sound.stop() + except Exception: + pass + + def _reapply_button_texture_state(manager, comp_data, comp_obj): + normal_path = comp_data.get("button_texture_path_normal") or comp_data.get("button_texture_path") + hover_path = comp_data.get("button_texture_path_hover") + pressed_path = comp_data.get("button_texture_path_pressed") + + if not normal_path and not hover_path and not pressed_path: + manager.luiFunction._clear_button_custom_textures(manager, comp_data, comp_obj) + return + + manager.luiFunction._clear_button_custom_textures(manager, comp_data, comp_obj) + if normal_path: + manager.luiFunction._apply_button_texture_variant(manager, comp_data, comp_obj, normal_path, "normal") + if hover_path: + manager.luiFunction._apply_button_texture_variant(manager, comp_data, comp_obj, hover_path, "hover") + if pressed_path: + manager.luiFunction._apply_button_texture_variant(manager, comp_data, comp_obj, pressed_path, "pressed") + + def _reapply_component_resource_state(manager, comp_data, comp_obj, comp_type): + if comp_type == "Button": + manager.luiFunction._reapply_button_texture_state(manager, comp_data, comp_obj) + return + if comp_type == "InputField": + input_texture_path = comp_data.get("input_texture_path") + if input_texture_path: + manager.luiFunction._apply_input_field_texture_path(manager, comp_data, comp_obj, input_texture_path) + else: + manager.luiFunction._restore_input_field_default_texture(manager, comp_data, comp_obj) + return + if comp_type == "Image": + manager.luiFunction._apply_image_texture_path(manager, comp_data, comp_obj, comp_data.get("texture_path", "blank")) + return + if comp_type == "Video": + manager.luiFunction._reapply_video_resource_state(manager, comp_data, comp_obj) + return + def _draw_layout_transform_properties(manager, index, comp_data, comp_obj, comp_type): # 编排入口:布局模式/坐标尺寸/布局组/Z-Offset 采用分步函数,降低维护成本。 is_fill = manager.luiFunction._draw_fill_layout_controls(manager, index, comp_data) imgui.separator() - manager.luiFunction._draw_position_controls(manager, comp_data, comp_obj, is_fill) + manager.luiFunction._draw_position_controls(manager, index, comp_data, comp_obj, is_fill) manager.luiFunction._draw_size_controls( manager, index, comp_data, comp_obj, comp_type, is_fill ) @@ -546,15 +987,10 @@ class LUIFunctionPropertiesMixin: is_fill = (layout_mode == 'fill') changed, new_fill = imgui.checkbox("填充父级", is_fill) if changed: - comp_data['layout_mode'] = 'fill' if new_fill else 'manual' - if new_fill: - comp_data['anchored_to_parent'] = False - comp_data.setdefault('fill_margin_left', 0.0) - comp_data.setdefault('fill_margin_right', 0.0) - comp_data.setdefault('fill_margin_top', 0.0) - comp_data.setdefault('fill_margin_bottom', 0.0) - if hasattr(manager, '_apply_fill_layout'): - manager._apply_fill_layout(index) + manager._apply_component_change_with_history( + index, + lambda: manager.luiFunction._set_fill_layout_mode(manager, index, comp_data, new_fill), + ) is_fill = (comp_data.get('layout_mode') == 'fill') if not is_fill: @@ -567,9 +1003,21 @@ class LUIFunctionPropertiesMixin: m_top = float(comp_data.get('fill_margin_top', 0.0)) m_bottom = float(comp_data.get('fill_margin_bottom', 0.0)) changed_l, new_l = imgui.input_float("左", m_left, 1.0, 10.0, "%.1f") + if imgui.is_item_activated(): + manager._begin_component_snapshot_session(index, "lui_fill_margin_left") + manager._finish_component_snapshot_session(index, "lui_fill_margin_left") changed_r, new_r = imgui.input_float("右", m_right, 1.0, 10.0, "%.1f") + if imgui.is_item_activated(): + manager._begin_component_snapshot_session(index, "lui_fill_margin_right") + manager._finish_component_snapshot_session(index, "lui_fill_margin_right") changed_t, new_t = imgui.input_float("上", m_top, 1.0, 10.0, "%.1f") + if imgui.is_item_activated(): + manager._begin_component_snapshot_session(index, "lui_fill_margin_top") + manager._finish_component_snapshot_session(index, "lui_fill_margin_top") changed_b, new_b = imgui.input_float("下", m_bottom, 1.0, 10.0, "%.1f") + if imgui.is_item_activated(): + manager._begin_component_snapshot_session(index, "lui_fill_margin_bottom") + manager._finish_component_snapshot_session(index, "lui_fill_margin_bottom") if changed_l or changed_r or changed_t or changed_b: comp_data['fill_margin_left'] = new_l comp_data['fill_margin_right'] = new_r @@ -579,7 +1027,18 @@ class LUIFunctionPropertiesMixin: manager._apply_fill_layout(index) return True - def _draw_position_controls(manager, comp_data, comp_obj, is_fill): + def _set_fill_layout_mode(manager, index, comp_data, new_fill): + comp_data['layout_mode'] = 'fill' if new_fill else 'manual' + if new_fill: + comp_data['anchored_to_parent'] = False + comp_data.setdefault('fill_margin_left', 0.0) + comp_data.setdefault('fill_margin_right', 0.0) + comp_data.setdefault('fill_margin_top', 0.0) + comp_data.setdefault('fill_margin_bottom', 0.0) + if hasattr(manager, '_apply_fill_layout'): + manager._apply_fill_layout(index) + + def _draw_position_controls(manager, index, comp_data, comp_obj, is_fill): imgui.text("位置") if is_fill: imgui.text(f"Left: {comp_data.get('left', 0.0):.1f}") @@ -588,15 +1047,21 @@ class LUIFunctionPropertiesMixin: if 'left' in comp_data: changed, new_left = imgui.input_float("Left", comp_data['left'], 1.0, 10.0, "%.1f") + if imgui.is_item_activated(): + manager._begin_component_snapshot_session(index, "lui_left") if changed: comp_data['left'] = new_left comp_obj.left = new_left + manager._finish_component_snapshot_session(index, "lui_left") if 'top' in comp_data: changed, new_top = imgui.input_float("Top", comp_data['top'], 1.0, 10.0, "%.1f") + if imgui.is_item_activated(): + manager._begin_component_snapshot_session(index, "lui_top") if changed: comp_data['top'] = new_top comp_obj.top = new_top + manager._finish_component_snapshot_session(index, "lui_top") def _draw_size_controls(manager, index, comp_data, comp_obj, comp_type, is_fill): imgui.spacing() @@ -607,20 +1072,23 @@ class LUIFunctionPropertiesMixin: return width_changed = manager.luiFunction._draw_width_control( - manager, comp_data, comp_obj, comp_type + manager, index, comp_data, comp_obj, comp_type ) height_changed = manager.luiFunction._draw_height_control( - manager, comp_data, comp_obj, comp_type + manager, index, comp_data, comp_obj, comp_type ) manager.luiFunction._post_size_control_sync( manager, index, comp_data, comp_type, width_changed, height_changed ) - def _draw_width_control(manager, comp_data, comp_obj, comp_type): + def _draw_width_control(manager, index, comp_data, comp_obj, comp_type): if 'width' not in comp_data: return False changed, new_width = imgui.input_float("Width", comp_data['width'], 1.0, 10.0, "%.1f") + if imgui.is_item_activated(): + manager._begin_component_snapshot_session(index, "lui_width") if not changed or new_width <= 0: + manager._finish_component_snapshot_session(index, "lui_width") return False comp_data['width'] = new_width @@ -638,13 +1106,17 @@ class LUIFunctionPropertiesMixin: comp_obj.set_width(new_width) if comp_type == 'InputField' and hasattr(comp_obj, 'set_width'): comp_obj.set_width(new_width) + manager._finish_component_snapshot_session(index, "lui_width") return True - def _draw_height_control(manager, comp_data, comp_obj, comp_type): + def _draw_height_control(manager, index, comp_data, comp_obj, comp_type): if 'height' not in comp_data: return False changed, new_height = imgui.input_float("Height", comp_data['height'], 1.0, 10.0, "%.1f") + if imgui.is_item_activated(): + manager._begin_component_snapshot_session(index, "lui_height") if not changed or new_height <= 0: + manager._finish_component_snapshot_session(index, "lui_height") return False comp_data['height'] = new_height @@ -658,6 +1130,7 @@ class LUIFunctionPropertiesMixin: comp_obj.set_width(float(comp_data.get('width', 200))) if comp_type == 'InputField' and hasattr(comp_obj, 'set_width'): comp_obj.set_width(float(comp_data.get('width', 200))) + manager._finish_component_snapshot_session(index, "lui_height") return True def _post_size_control_sync(manager, index, comp_data, comp_type, width_changed, height_changed): @@ -678,10 +1151,13 @@ class LUIFunctionPropertiesMixin: imgui.text("Layout Group") spacing = float(comp_data.get('layout_spacing', 0.0)) changed, new_spacing = imgui.input_float("Spacing", spacing, 1.0, 10.0, "%.1f") + if imgui.is_item_activated(): + manager._begin_component_snapshot_session(index, "lui_layout_spacing") if changed: comp_data['layout_spacing'] = new_spacing if hasattr(manager, '_update_layout_inner'): manager._update_layout_inner(index) + manager._finish_component_snapshot_session(index, "lui_layout_spacing") imgui.text("Padding") pad_left = float(comp_data.get('layout_padding_left', 0.0)) @@ -689,9 +1165,21 @@ class LUIFunctionPropertiesMixin: pad_top = float(comp_data.get('layout_padding_top', 0.0)) pad_bottom = float(comp_data.get('layout_padding_bottom', 0.0)) changed_l, new_l = imgui.input_float("Pad Left", pad_left, 1.0, 10.0, "%.1f") + if imgui.is_item_activated(): + manager._begin_component_snapshot_session(index, "lui_layout_pad_left") + manager._finish_component_snapshot_session(index, "lui_layout_pad_left") changed_r, new_r = imgui.input_float("Pad Right", pad_right, 1.0, 10.0, "%.1f") + if imgui.is_item_activated(): + manager._begin_component_snapshot_session(index, "lui_layout_pad_right") + manager._finish_component_snapshot_session(index, "lui_layout_pad_right") changed_t, new_t = imgui.input_float("Pad Top", pad_top, 1.0, 10.0, "%.1f") + if imgui.is_item_activated(): + manager._begin_component_snapshot_session(index, "lui_layout_pad_top") + manager._finish_component_snapshot_session(index, "lui_layout_pad_top") changed_b, new_b = imgui.input_float("Pad Bottom", pad_bottom, 1.0, 10.0, "%.1f") + if imgui.is_item_activated(): + manager._begin_component_snapshot_session(index, "lui_layout_pad_bottom") + manager._finish_component_snapshot_session(index, "lui_layout_pad_bottom") if changed_l or changed_r or changed_t or changed_b: comp_data['layout_padding_left'] = new_l comp_data['layout_padding_right'] = new_r @@ -703,27 +1191,42 @@ class LUIFunctionPropertiesMixin: wrap_enabled = bool(comp_data.get('layout_wrap', True)) changed_wrap, new_wrap = imgui.checkbox("Wrap", wrap_enabled) if changed_wrap: - comp_data['layout_wrap'] = new_wrap - if hasattr(manager, '_update_layout_inner'): - manager._update_layout_inner(index) + manager._apply_component_change_with_history( + index, + lambda: manager.luiFunction._set_layout_wrap(manager, index, comp_data, new_wrap), + ) line_spacing = float(comp_data.get('layout_line_spacing', 0.0)) changed_line, new_line_spacing = imgui.input_float("Line Spacing (Wrap)", line_spacing, 1.0, 10.0, "%.1f") + if imgui.is_item_activated(): + manager._begin_component_snapshot_session(index, "lui_layout_line_spacing") if changed_line: comp_data['layout_line_spacing'] = new_line_spacing if hasattr(manager, '_update_layout_inner'): manager._update_layout_inner(index) + manager._finish_component_snapshot_session(index, "lui_layout_line_spacing") align_options = ["start", "center", "end", "stretch"] current_align = comp_data.get('layout_align', 'start') if imgui.begin_combo("Align", current_align): for opt in align_options: if imgui.selectable(opt, current_align == opt)[0]: - comp_data['layout_align'] = opt - if hasattr(manager, '_update_layout_inner'): - manager._update_layout_inner(index) + manager._apply_component_change_with_history( + index, + lambda selected_opt=opt: manager.luiFunction._set_layout_align(manager, index, comp_data, selected_opt), + ) imgui.end_combo() + def _set_layout_wrap(manager, index, comp_data, new_wrap): + comp_data['layout_wrap'] = new_wrap + if hasattr(manager, '_update_layout_inner'): + manager._update_layout_inner(index) + + def _set_layout_align(manager, index, comp_data, align_value): + comp_data['layout_align'] = align_value + if hasattr(manager, '_update_layout_inner'): + manager._update_layout_inner(index) + def _draw_z_offset_controls(manager, index, comp_data, comp_obj): imgui.spacing() imgui.text("层级与显示顺序") @@ -736,13 +1239,18 @@ class LUIFunctionPropertiesMixin: layer_val = int(current_z) changed_layer, new_layer = imgui.input_int("渲染层级 (Layer)", layer_val) + if imgui.is_item_activated(): + manager._begin_component_snapshot_session(index, "lui_layer") if changed_layer: comp_data['z_offset'] = float(new_layer) if hasattr(comp_obj, 'set_z_offset'): comp_obj.set_z_offset(float(new_layer)) print(f"✓ 组件 #{index} 层级已映射到 Z-Offset: {new_layer}") + manager._finish_component_snapshot_session(index, "lui_layer") changed_z, new_z = imgui.input_float("深度微调 (Z-Offset)", comp_data['z_offset'], 0.1, 1.0, "%.2f") + if imgui.is_item_activated(): + manager._begin_component_snapshot_session(index, "lui_z_offset") if changed_z: comp_data['z_offset'] = new_z if hasattr(comp_obj, 'set_z_offset'): @@ -750,6 +1258,7 @@ class LUIFunctionPropertiesMixin: elif hasattr(comp_obj, 'z_offset'): comp_obj.z_offset = new_z print(f"✓ 组件 #{index} Z-Offset 已微调为: {new_z}") + manager._finish_component_snapshot_session(index, "lui_z_offset") imgui.text_disabled("(注: LUI 内部组件通过 Z-Offset 决定遮挡关系)") # 特定类型的属性 @@ -797,66 +1306,27 @@ class LUIFunctionPropertiesMixin: ) if selected_path: try: - atlas_result = manager.luiFunction._add_image_to_atlas(manager, selected_path, atlas_size=2048) - if atlas_result: - if len(atlas_result) >= 7: - tex, u0, v0, u1, v1, w, h = atlas_result[:7] - else: - tex, u0, v0, u1, v1 = atlas_result - w = int(max(1, round((u1 - u0) * tex.getXSize()))) - h = int(max(1, round((v1 - v0) * tex.getYSize()))) - comp_data['input_texture_path'] = selected_path - comp_data['input_atlas_uv'] = (u0, v0, u1, v1) - comp_data['input_texture_ref'] = tex - comp_data['input_image_size'] = (int(w), int(h)) - layout = getattr(comp_obj, '_layout', None) - if layout is not None: - for attr in ('_sprite_left', '_sprite_mid', '_sprite_right'): - spr = getattr(layout, attr, None) - if spr is not None and hasattr(spr, 'set_texture'): - spr.set_texture(tex, resize=False) - if hasattr(spr, 'set_uv_range'): - spr.set_uv_range(u0, v0, u1, v1) + manager._apply_component_change_with_history( + index, + lambda: manager.luiFunction._apply_input_field_texture_path( + manager, comp_data, comp_obj, selected_path + ), + ) except Exception as e: print(f"InputField texture set failed: {e}") if imgui.button("使用图片尺寸##input_fit", (120, 20)): - w = h = None - if 'input_image_size' in comp_data and comp_data['input_image_size']: - try: - w, h = comp_data['input_image_size'] - except Exception: - w = h = None - elif 'input_atlas_uv' in comp_data and 'input_texture_ref' in comp_data and comp_data['input_texture_ref']: - try: - u0, v0, u1, v1 = comp_data['input_atlas_uv'] - tex = comp_data['input_texture_ref'] - tw = tex.getXSize() if hasattr(tex, 'getXSize') else 0 - th = tex.getYSize() if hasattr(tex, 'getYSize') else 0 - w = int(max(1, round((u1 - u0) * tw))) - h = int(max(1, round((v1 - v0) * th))) - except Exception: - w = h = None - if w is not None and h is not None and w > 0 and h > 0: - comp_data['width'] = float(w) - comp_data['height'] = float(h) - comp_obj.width = float(w) - comp_obj.height = float(h) - if hasattr(comp_obj, 'set_width'): - comp_obj.set_width(float(w)) - if hasattr(comp_obj, 'set_height'): - comp_obj.set_height(float(h)) - layout = getattr(comp_obj, '_layout', None) - if layout is not None: - if hasattr(layout, 'width'): - layout.width = "100%" - if hasattr(layout, 'height'): - layout.height = "100%" + manager._apply_component_change_with_history( + index, + lambda: manager.luiFunction._fit_input_field_to_texture(manager, comp_data, comp_obj), + ) imgui.text("输入框颜色") in_color = comp_data.get('input_color', (1.0, 1.0, 1.0, 1.0)) in_color = list(in_color) if isinstance(in_color, tuple) else in_color changed, new_in_color = imgui.color_edit4("##input_color", in_color) + if imgui.is_item_activated(): + manager._begin_component_snapshot_session(index, "lui_input_color") if changed: comp_data['input_color'] = tuple(new_in_color) layout = getattr(comp_obj, '_layout', None) @@ -865,12 +1335,16 @@ class LUIFunctionPropertiesMixin: spr = getattr(layout, attr, None) if spr is not None: spr.color = tuple(new_in_color) + manager._finish_component_snapshot_session(index, "lui_input_color") if 'value' in comp_data: changed, new_value = imgui.input_text("当前值", comp_data['value'], 256) + if imgui.is_item_activated(): + manager._begin_component_snapshot_session(index, "lui_input_value") if changed: comp_data['value'] = new_value comp_obj.value = new_value + manager._finish_component_snapshot_session(index, "lui_input_value") def _draw_type_props_slider(manager, index, comp_data, comp_obj, comp_type): imgui.spacing() @@ -879,16 +1353,21 @@ class LUIFunctionPropertiesMixin: min_val = comp_data.get('min_value', 0.0) max_val = comp_data.get('max_value', 100.0) changed, new_value = imgui.slider_float("当前值", comp_data['value'], min_val, max_val, "%.1f") + if imgui.is_item_activated(): + manager._begin_component_snapshot_session(index, "lui_slider_value") if changed: comp_data['value'] = new_value if hasattr(comp_obj, 'set_value'): comp_obj.set_value(new_value) + manager._finish_component_snapshot_session(index, "lui_slider_value") def _draw_type_props_checkbox(manager, index, comp_data, comp_obj, comp_type): imgui.spacing() imgui.text("复选框属性") if 'text' in comp_data: changed, new_text = imgui.input_text("标签文本", comp_data['text'], 256) + if imgui.is_item_activated(): + manager._begin_component_snapshot_session(index, "lui_checkbox_text") if changed: comp_data['text'] = new_text # Checkbox uses a child label for text @@ -896,13 +1375,20 @@ class LUIFunctionPropertiesMixin: comp_obj.label.text = new_text elif hasattr(comp_obj, 'text'): comp_obj.text = new_text + manager._finish_component_snapshot_session(index, "lui_checkbox_text") if 'checked' in comp_data: changed, new_checked = imgui.checkbox("选中状态", comp_data['checked']) if changed: - comp_data['checked'] = new_checked - if hasattr(comp_obj, 'checked'): - comp_obj.checked = new_checked + manager._apply_component_change_with_history( + index, + lambda: manager.luiFunction._set_checkbox_checked(comp_data, comp_obj, new_checked), + ) + + def _set_checkbox_checked(manager, comp_data, comp_obj, new_checked): + comp_data['checked'] = new_checked + if hasattr(comp_obj, 'checked'): + comp_obj.checked = new_checked def _draw_type_props_plane_image(manager, index, comp_data, comp_obj, comp_type): imgui.spacing() @@ -913,75 +1399,23 @@ class LUIFunctionPropertiesMixin: color = comp_data.get('color', (1.0, 1.0, 1.0, 1.0)) color = list(color) if isinstance(color, tuple) else color changed, new_color = imgui.color_edit4("颜色", color) + if imgui.is_item_activated(): + manager._begin_component_snapshot_session(index, f"lui_{comp_type.lower()}_color") if changed: comp_data['color'] = tuple(new_color) # 更新sprite颜色 if 'sprite' in comp_data: comp_data['sprite'].color = tuple(new_color) + manager._finish_component_snapshot_session(index, f"lui_{comp_type.lower()}_color") # 如果是Image,显示纹理路径 if comp_type == 'Image' and 'texture_path' in comp_data: # Fit size to image texture if imgui.button("使用图片尺寸##fit_image"): - tex = None - # Prefer direct texture reference if available - if 'texture_ref' in comp_data and comp_data['texture_ref']: - tex = comp_data['texture_ref'] - elif 'texture' in comp_data and comp_data['texture']: - tex = comp_data['texture'] - else: - # Try to read from sprite python tag if present - try: - target = comp_data.get('sprite', comp_obj) - if target is not None and hasattr(target, 'get_python_tag'): - tex = target.get_python_tag('texture_ref') - except Exception: - tex = None - - w = h = None - if 'image_size' in comp_data and comp_data['image_size']: - try: - w, h = comp_data['image_size'] - except Exception: - w = h = None - elif 'atlas_uv' in comp_data and 'atlas_tex' in comp_data and comp_data['atlas_tex']: - try: - u0, v0, u1, v1 = comp_data['atlas_uv'] - atlas_tex = comp_data['atlas_tex'] - atlas_w = atlas_tex.getXSize() if hasattr(atlas_tex, 'getXSize') else 0 - atlas_h = atlas_tex.getYSize() if hasattr(atlas_tex, 'getYSize') else 0 - w = int(max(1, round((u1 - u0) * atlas_w))) - h = int(max(1, round((v1 - v0) * atlas_h))) - except Exception: - w = h = None - elif tex is not None: - if hasattr(tex, 'getOrigFileXSize') and hasattr(tex, 'getOrigFileYSize'): - ow = tex.getOrigFileXSize() - oh = tex.getOrigFileYSize() - if ow and oh: - w, h = ow, oh - if w is None or h is None: - if hasattr(tex, 'getXSize') and hasattr(tex, 'getYSize'): - w = tex.getXSize() - h = tex.getYSize() - - if w is not None and h is not None and w > 0 and h > 0: - comp_data['width'] = float(w) - comp_data['height'] = float(h) - comp_obj.width = float(w) - comp_obj.height = float(h) - # Sync sprite size (Image/Plane/Video) - if 'sprite' in comp_data and comp_data['sprite']: - spr = comp_data['sprite'] - if hasattr(spr, 'set_size'): - spr.set_size(float(w), float(h)) - else: - spr.width = float(w) - spr.height = float(h) - if hasattr(manager, '_hide_resize_handles'): - manager._hide_resize_handles() - else: - print("Image size not available: missing texture") + manager._apply_component_change_with_history( + index, + lambda: manager.luiFunction._fit_image_to_texture(manager, comp_data, comp_obj), + ) imgui.text(f"纹理路径: {comp_data['texture_path']}") if imgui.button("更改纹理", (100, 20)): @@ -996,61 +1430,13 @@ class LUIFunctionPropertiesMixin: ] ) if selected_path: - new_path = selected_path - comp_data['texture_path'] = new_path try: - atlas_result = manager.luiFunction._add_image_to_atlas(manager, new_path, atlas_size=2048) - if atlas_result: - if len(atlas_result) >= 7: - tex, u0, v0, u1, v1, w, h = atlas_result[:7] - else: - tex, u0, v0, u1, v1 = atlas_result - w = int(max(1, round((u1 - u0) * tex.getXSize()))) - h = int(max(1, round((v1 - v0) * tex.getYSize()))) - print(f"? Texture loaded: {tex.getName()}, Size: {tex.getXSize()}x{tex.getYSize()}") - comp_data["current_texture"] = os.path.basename(new_path) - comp_data["atlas_uv"] = (u0, v0, u1, v1) - comp_data["atlas_tex"] = tex - comp_data["image_size"] = (int(w), int(h)) - old_spr = comp_data.get("sprite") - if old_spr: - try: - old_spr.parent = None - if hasattr(old_spr, "set_texture"): old_spr.set_texture(None) - if hasattr(old_spr, "destroy"): old_spr.destroy() - except: - pass - try: - new_spr = LUISprite(comp_obj, tex) - if hasattr(new_spr, "set_texture"): - new_spr.set_texture(tex, resize=False) - if hasattr(new_spr, "set_uv_range"): - new_spr.set_uv_range(u0, v0, u1, v1) - new_spr.width = comp_data.get("width", 100) - new_spr.height = comp_data.get("height", 100) - new_spr.left = 0 - new_spr.top = 0 - new_spr.z_offset = comp_data.get("z_offset", 0) - new_spr.color = comp_data.get("color", (1,1,1,1)) - if hasattr(new_spr, "set_uv_range"): - new_spr.set_uv_range(u0, v0, u1, v1) - comp_data["sprite"] = new_spr - comp_data["texture_ref"] = tex - if not hasattr(manager, "texture_refs"): - manager.texture_refs = [] - manager.texture_refs.append(tex) - if hasattr(new_spr, "set_python_tag"): - new_spr.set_python_tag("texture_ref", tex) - if hasattr(comp_obj, "set_python_tag"): - comp_obj.set_python_tag("texture_ref", tex) - print(f"? Replaced sprite with atlas texture image: {tex.getName()}, Size: {tex.getXSize()}x{tex.getYSize()}") - except Exception as ex: - print(f"? Failed to create new sprite: {ex}") - target = comp_data.get("widget", comp_data.get("object")) - if hasattr(target, "set_texture"): - target.set_texture(tex) - else: - print(f"? Texture loading returned None for: {new_path}") + manager._apply_component_change_with_history( + index, + lambda: manager.luiFunction._apply_image_texture_path( + manager, comp_data, comp_obj, selected_path + ), + ) except Exception as e: print(f"Image texture update failed: {e}") if 'sprite' in comp_data: @@ -1066,11 +1452,14 @@ class LUIFunctionPropertiesMixin: color = comp_data.get('color', (0.7, 0.7, 0.7, 0.8)) color = list(color) if isinstance(color, tuple) else color changed, new_color = imgui.color_edit4("背景颜色", color) + if imgui.is_item_activated(): + manager._begin_component_snapshot_session(index, "lui_frame_color") if changed: comp_data['color'] = tuple(new_color) # 更新Frame颜色 if hasattr(comp_obj, 'set_color'): comp_obj.set_color(tuple(new_color)) + manager._finish_component_snapshot_session(index, "lui_frame_color") def _draw_type_props_selectbox(manager, index, comp_data, comp_obj, comp_type): imgui.spacing() @@ -1106,12 +1495,10 @@ class LUIFunctionPropertiesMixin: changed_sel, new_index = imgui.combo("Selected", current_index, opt_labels) if changed_sel and options: sel_id = opt_ids[new_index] - comp_data['selected_option_id'] = sel_id - try: - if hasattr(comp_obj, '_select_option'): - comp_obj._select_option(sel_id) - except Exception: - pass + manager._apply_component_change_with_history( + index, + lambda: manager.luiFunction._set_selectbox_selected_option(comp_data, comp_obj, sel_id), + ) imgui.spacing() imgui.text("Edit Options") @@ -1137,25 +1524,37 @@ class LUIFunctionPropertiesMixin: dirty = True if dirty: - # rebuild options with sequential ids to avoid duplicates - options = [(i, label) for i, label in enumerate(new_labels)] - comp_data['options'] = options - # keep selection if possible - if options: - if current_selected not in [oid for oid, _ in options]: - current_selected = options[0][0] - comp_data['selected_option_id'] = current_selected - else: - comp_data['selected_option_id'] = None - try: - if hasattr(comp_obj, 'set_options'): - comp_obj.set_options(options) - elif hasattr(comp_obj, 'options'): - comp_obj.options = options - if options and hasattr(comp_obj, '_select_option'): - comp_obj._select_option(comp_data['selected_option_id']) - except Exception as e: - print(f"Selectbox options update failed: {e}") + manager._apply_component_change_with_history( + index, + lambda: manager.luiFunction._apply_selectbox_options(comp_data, comp_obj, new_labels, current_selected), + ) + + def _set_selectbox_selected_option(manager, comp_data, comp_obj, selected_option_id): + comp_data['selected_option_id'] = selected_option_id + try: + if hasattr(comp_obj, '_select_option'): + comp_obj._select_option(selected_option_id) + except Exception: + pass + + def _apply_selectbox_options(manager, comp_data, comp_obj, new_labels, current_selected): + options = [(i, label) for i, label in enumerate(new_labels)] + comp_data['options'] = options + if options: + if current_selected not in [oid for oid, _ in options]: + current_selected = options[0][0] + comp_data['selected_option_id'] = current_selected + else: + comp_data['selected_option_id'] = None + try: + if hasattr(comp_obj, 'set_options'): + comp_obj.set_options(options) + elif hasattr(comp_obj, 'options'): + comp_obj.options = options + if options and hasattr(comp_obj, '_select_option'): + comp_obj._select_option(comp_data['selected_option_id']) + except Exception as e: + print(f"Selectbox options update failed: {e}") def _draw_type_props_http_text(manager, index, comp_data, comp_obj, comp_type): imgui.spacing() @@ -1164,50 +1563,77 @@ class LUIFunctionPropertiesMixin: http_url = str(comp_data.get('http_url', '')) changed_url, new_url = imgui.input_text("URL", http_url, 512) + if imgui.is_item_activated(): + manager._begin_component_snapshot_session(index, "lui_http_url") if changed_url: comp_data['http_url'] = new_url + manager._finish_component_snapshot_session(index, "lui_http_url") method_options = ["GET", "POST"] curr_method = str(comp_data.get('http_method', 'GET')).upper() curr_method_idx = 1 if curr_method == "POST" else 0 changed_method, new_method_idx = imgui.combo("Method", curr_method_idx, method_options) if changed_method: - comp_data['http_method'] = method_options[new_method_idx] + manager._apply_component_change_with_history( + index, + lambda: comp_data.__setitem__('http_method', method_options[new_method_idx]), + ) timeout_val = float(comp_data.get('http_timeout', 8.0)) changed_timeout, new_timeout = imgui.input_float("Timeout(s)", timeout_val, 1.0, 5.0, "%.1f") + if imgui.is_item_activated(): + manager._begin_component_snapshot_session(index, "lui_http_timeout") if changed_timeout: comp_data['http_timeout'] = max(1.0, new_timeout) + manager._finish_component_snapshot_session(index, "lui_http_timeout") auto_refresh = bool(comp_data.get('auto_refresh', True)) changed_auto, new_auto = imgui.checkbox("自动刷新", auto_refresh) if changed_auto: - comp_data['auto_refresh'] = new_auto + manager._apply_component_change_with_history( + index, + lambda: comp_data.__setitem__('auto_refresh', new_auto), + ) interval_val = float(comp_data.get('refresh_interval', 60.0)) changed_interval, new_interval = imgui.input_float("刷新间隔(s)", interval_val, 1.0, 10.0, "%.1f") + if imgui.is_item_activated(): + manager._begin_component_snapshot_session(index, "lui_http_refresh_interval") if changed_interval: comp_data['refresh_interval'] = max(1.0, new_interval) + manager._finish_component_snapshot_session(index, "lui_http_refresh_interval") json_path = str(comp_data.get('http_json_path', '')) changed_path, new_path = imgui.input_text("JSON路径(可选)", json_path, 256) + if imgui.is_item_activated(): + manager._begin_component_snapshot_session(index, "lui_http_json_path") if changed_path: comp_data['http_json_path'] = new_path + manager._finish_component_snapshot_session(index, "lui_http_json_path") headers_text = str(comp_data.get('http_headers', '{}')) changed_headers, new_headers = imgui.input_text("Headers(JSON)", headers_text, 512) + if imgui.is_item_activated(): + manager._begin_component_snapshot_session(index, "lui_http_headers") if changed_headers: comp_data['http_headers'] = new_headers + manager._finish_component_snapshot_session(index, "lui_http_headers") body_text = str(comp_data.get('http_body', '')) changed_body, new_body = imgui.input_text("Body", body_text, 512) + if imgui.is_item_activated(): + manager._begin_component_snapshot_session(index, "lui_http_body") if changed_body: comp_data['http_body'] = new_body + manager._finish_component_snapshot_session(index, "lui_http_body") max_chars = int(comp_data.get('max_chars', 300)) changed_max, new_max = imgui.input_int("最大显示字符", max_chars) + if imgui.is_item_activated(): + manager._begin_component_snapshot_session(index, "lui_http_max_chars") if changed_max: comp_data['max_chars'] = max(32, int(new_max)) + manager._finish_component_snapshot_session(index, "lui_http_max_chars") imgui.text(f"状态: {comp_data.get('http_status', '未请求')}") last_error = comp_data.get('last_error', '') @@ -1218,13 +1644,10 @@ class LUIFunctionPropertiesMixin: imgui.text("请求中...") if imgui.button("北京天气示例", (120, 24)): - comp_data['http_url'] = "https://api.open-meteo.com/v1/forecast?latitude=39.9042&longitude=116.4074¤t=temperature_2m,apparent_temperature,relative_humidity_2m,wind_speed_10m&timezone=Asia%2FShanghai" - comp_data['http_method'] = "GET" - comp_data['http_json_path'] = "current" - comp_data['http_headers'] = "{}" - comp_data['http_body'] = "" - comp_data['refresh_interval'] = 60.0 - comp_data['auto_refresh'] = True + manager._apply_component_change_with_history( + index, + lambda: manager.luiFunction._apply_http_weather_preset(manager, comp_data), + ) imgui.same_line() if imgui.button("立即请求", (100, 24)): @@ -1242,11 +1665,11 @@ class LUIFunctionPropertiesMixin: ) def _draw_video_source_and_audio_controls(manager, index, comp_data, comp_obj, comp_type): - manager.luiFunction._draw_video_audio_controls(manager, comp_data) - manager.luiFunction._draw_video_source_controls(manager, comp_data) - manager.luiFunction._process_pending_video_source(manager, comp_data, comp_obj) + manager.luiFunction._draw_video_audio_controls(manager, index, comp_data, comp_obj) + manager.luiFunction._draw_video_source_controls(manager, index, comp_data) + manager.luiFunction._process_pending_video_source(manager, index, comp_data, comp_obj) - def _draw_video_audio_controls(manager, comp_data): + def _draw_video_audio_controls(manager, index, comp_data, comp_obj): imgui.spacing() imgui.text("Audio") imgui.separator() @@ -1254,6 +1677,13 @@ class LUIFunctionPropertiesMixin: current_audio = comp_data.get("audio_path", "") audio_display = os.path.basename(current_audio) if current_audio else "" imgui.text(f"Current Audio: {audio_display if audio_display else 'None'}") + if current_audio: + imgui.same_line() + if imgui.button("清除音频", (90, 24)): + manager._apply_component_change_with_history( + index, + lambda: manager.luiFunction._clear_audio_for_component(comp_data), + ) if imgui.button("Select Audio", (110, 24)): selected_audio = manager._change_image_texture( @@ -1261,19 +1691,25 @@ class LUIFunctionPropertiesMixin: filetypes=[("Audio", "*.mp3;*.wav;*.ogg;*.flac;*.m4a"), ("All Files", "*.*")], ) if selected_audio: - manager.luiFunction._load_audio_for_component( - manager, comp_data, selected_audio, from_video=False, stop_current=False + manager._apply_component_change_with_history( + index, + lambda: manager.luiFunction._load_audio_for_component( + manager, comp_data, selected_audio, from_video=False, stop_current=False + ), ) vol = comp_data.get("volume", 1.0) changed, new_vol = imgui.slider_float("Volume", vol, 0.0, 1.0, "%.2f") + if imgui.is_item_activated(): + manager._begin_component_snapshot_session(index, "lui_video_volume") if changed: comp_data["volume"] = new_vol audio_sound = comp_data.get("audio") if audio_sound and hasattr(audio_sound, "setVolume"): audio_sound.setVolume(new_vol) + manager._finish_component_snapshot_session(index, "lui_video_volume") - def _draw_video_source_controls(manager, comp_data): + def _draw_video_source_controls(manager, index, comp_data): if "video_path" not in comp_data: return @@ -1281,6 +1717,13 @@ class LUIFunctionPropertiesMixin: is_url = "://" in current_source source_display = os.path.basename(current_source) if (current_source and not is_url) else current_source imgui.text(f"当前源: {source_display if source_display else '未设置'}") + if current_source: + imgui.same_line() + if imgui.button("清除源", (80, 24)): + manager._apply_component_change_with_history( + index, + lambda: manager.luiFunction._clear_video_source(manager, comp_data, comp_obj=None), + ) if imgui.button("选择本地视频", (110, 24)): selected_path = manager._change_image_texture( @@ -1293,7 +1736,7 @@ class LUIFunctionPropertiesMixin: if selected_path: comp_data["_pending_video_source"] = selected_path - def _process_pending_video_source(manager, comp_data, comp_obj): + def _process_pending_video_source(manager, index, comp_data, comp_obj): if "_pending_video_source" not in comp_data: return @@ -1301,9 +1744,19 @@ class LUIFunctionPropertiesMixin: if not selected_path: return - selected_path = selected_path.strip() + manager._apply_component_change_with_history( + index, + lambda: manager.luiFunction._set_video_source(manager, comp_data, comp_obj, selected_path), + ) + + def _set_video_source(manager, comp_data, comp_obj, selected_path): + selected_path = str(selected_path or "").strip() + if not selected_path: + return + comp_data["video_path"] = selected_path comp_data["duration"] = 0.0 + comp_data["playback_time"] = 0.0 print(f"✓ 尝试加载视频源: {selected_path}") try: @@ -1316,6 +1769,15 @@ class LUIFunctionPropertiesMixin: except Exception as e: print(f"⚠ 加载视频失败: {e}") + def _apply_http_weather_preset(manager, comp_data): + comp_data['http_url'] = "https://api.open-meteo.com/v1/forecast?latitude=39.9042&longitude=116.4074¤t=temperature_2m,apparent_temperature,relative_humidity_2m,wind_speed_10m&timezone=Asia%2FShanghai" + comp_data['http_method'] = "GET" + comp_data['http_json_path'] = "current" + comp_data['http_headers'] = "{}" + comp_data['http_body'] = "" + comp_data['refresh_interval'] = 60.0 + comp_data['auto_refresh'] = True + def _load_video_texture_from_source(manager, selected_path): from panda3d.core import Filename, MovieTexture, MovieVideo, loadPrcFileData @@ -1352,6 +1814,7 @@ class LUIFunctionPropertiesMixin: manager.luiFunction._start_video_texture_playback(manager, video_texture, comp_data) comp_data["is_playing"] = True + comp_data["playback_time"] = 0.0 def _sync_video_size_and_sprite(manager, comp_data, comp_obj, video_texture): orig_w = video_texture.getOrigFileXSize() @@ -1452,6 +1915,29 @@ class LUIFunctionPropertiesMixin: except Exception as e: print(f"Audio load failed: {e}") + def _clear_audio_for_component(manager, comp_data): + old_audio = comp_data.get("audio") + if old_audio and hasattr(old_audio, "stop"): + try: + old_audio.stop() + except Exception: + pass + comp_data["audio"] = None + comp_data["audio_path"] = "" + comp_data["audio_from_video"] = False + + def _clear_video_source(manager, comp_data, comp_obj=None): + if comp_data.get("audio_from_video"): + manager.luiFunction._clear_audio_for_component(comp_data) + manager.luiFunction._clear_video_audio_runtime(manager, comp_data) + target_obj = comp_obj or comp_data.get("object") + if target_obj is not None: + manager.luiFunction._apply_blank_video_texture(manager, comp_data, target_obj) + comp_data["video_path"] = "" + comp_data["duration"] = 0.0 + comp_data["playback_time"] = 0.0 + comp_data["is_playing"] = False + def _start_video_texture_playback(manager, video_texture, comp_data): if hasattr(video_texture, "play"): if hasattr(video_texture, "stop"): @@ -1470,31 +1956,32 @@ class LUIFunctionPropertiesMixin: # Play/Pause Button is_playing = comp_data.get('is_playing', False) if imgui.button("暂停" if is_playing else "播放", (60, 24)): - if is_playing: - if hasattr(video_tex, 'stop'): video_tex.stop() - if audio_sound and hasattr(audio_sound, 'stop'): audio_sound.stop() - comp_data['is_playing'] = False - else: - if hasattr(video_tex, 'play'): video_tex.play() - if audio_sound and hasattr(audio_sound, 'play'): audio_sound.play() - comp_data['is_playing'] = True + manager._apply_component_change_with_history( + index, + lambda: manager.luiFunction._set_video_playing_state( + manager, comp_data, video_tex, audio_sound, not is_playing + ), + ) imgui.same_line() if imgui.button("重播", (60, 24)): - if hasattr(video_tex, 'stop'): video_tex.stop() - if audio_sound and hasattr(audio_sound, 'stop'): audio_sound.stop() - if hasattr(video_tex, 'play'): video_tex.play() - if audio_sound and hasattr(audio_sound, 'play'): audio_sound.play() - comp_data['is_playing'] = True + manager._apply_component_change_with_history( + index, + lambda: manager.luiFunction._restart_video_playback( + manager, comp_data, video_tex, audio_sound + ), + ) # Loop Checkbox loop = comp_data.get('loop', True) changed, new_loop = imgui.checkbox("循环播放", loop) if changed: - comp_data['loop'] = new_loop - if hasattr(video_tex, 'setLoop'): - video_tex.setLoop(new_loop) - if audio_sound and hasattr(audio_sound, 'setLoop'): audio_sound.setLoop(new_loop) + manager._apply_component_change_with_history( + index, + lambda: manager.luiFunction._set_video_loop( + manager, comp_data, video_tex, audio_sound, new_loop + ), + ) # Time Display if hasattr(video_tex, 'getTime'): @@ -1560,8 +2047,11 @@ class LUIFunctionPropertiesMixin: if current_dur <= 0.1: imgui.text_colored((1, 1, 0, 1), "⚠未知时长,请手动设置:") changed, new_dur = imgui.input_float("总时长(s)", current_dur, 1.0, 10.0, "%.1f") + if imgui.is_item_activated(): + manager._begin_component_snapshot_session(index, "lui_video_duration") if changed: comp_data['duration'] = new_dur + manager._finish_component_snapshot_session(index, "lui_video_duration") duration = comp_data.get('duration', 0.0) @@ -1593,14 +2083,13 @@ class LUIFunctionPropertiesMixin: # Use a custom format for the slider to show seconds, but maybe we can show MM:SS in the text changed, seek_time = imgui.slider_float("##seek_slider", current_time, 0.0, display_max, "%.2fs") imgui.pop_item_width() - + if imgui.is_item_activated(): + manager._begin_component_snapshot_session(index, "lui_video_seek") if changed: - if hasattr(video_tex, 'setTime'): - video_tex.setTime(seek_time) - if audio_sound and hasattr(audio_sound, 'setTime'): audio_sound.setTime(seek_time) - # If paused, we might need to show the frame. - # Usually setTime works. - print(f"Seek to {seek_time}") + manager.luiFunction._seek_video_playback( + manager, comp_data, video_tex, audio_sound, seek_time + ) + manager._finish_component_snapshot_session(index, "lui_video_seek") # Update cached duration if we find a way, or if user inputs it? # For now just show time @@ -1608,6 +2097,49 @@ class LUIFunctionPropertiesMixin: else: imgui.text_colored((1, 0, 0, 1), "无有效视频纹理") + def _set_video_loop(manager, comp_data, video_tex, audio_sound, new_loop): + comp_data['loop'] = new_loop + if hasattr(video_tex, 'setLoop'): + video_tex.setLoop(new_loop) + if audio_sound and hasattr(audio_sound, 'setLoop'): + audio_sound.setLoop(new_loop) + + def _set_video_playing_state(manager, comp_data, video_tex, audio_sound, should_play): + if should_play: + if hasattr(video_tex, 'play'): + video_tex.play() + if audio_sound and hasattr(audio_sound, 'play'): + audio_sound.play() + else: + if hasattr(video_tex, 'stop'): + video_tex.stop() + if audio_sound and hasattr(audio_sound, 'stop'): + audio_sound.stop() + comp_data['is_playing'] = bool(should_play) + + def _restart_video_playback(manager, comp_data, video_tex, audio_sound): + if hasattr(video_tex, 'stop'): + video_tex.stop() + if audio_sound and hasattr(audio_sound, 'stop'): + audio_sound.stop() + if hasattr(video_tex, 'setTime'): + video_tex.setTime(0.0) + if audio_sound and hasattr(audio_sound, 'setTime'): + audio_sound.setTime(0.0) + if hasattr(video_tex, 'play'): + video_tex.play() + if audio_sound and hasattr(audio_sound, 'play'): + audio_sound.play() + comp_data['playback_time'] = 0.0 + comp_data['is_playing'] = True + + def _seek_video_playback(manager, comp_data, video_tex, audio_sound, seek_time): + if hasattr(video_tex, 'setTime'): + video_tex.setTime(seek_time) + if audio_sound and hasattr(audio_sound, 'setTime'): + audio_sound.setTime(seek_time) + comp_data['playback_time'] = float(seek_time) + def _draw_anchor_and_hierarchy_properties(manager, index, comp_data, comp_obj): imgui.spacing() imgui.separator() @@ -1663,8 +2195,9 @@ class LUIFunctionPropertiesMixin: # 绘制按钮 button_size = (50, 25) if imgui.button(f"{anchor_names[pos]}##anchor_{pos}", button_size): - # 更新锚点位置 - manager._update_component_anchor_position(index, pos) + manager._apply_lui_structure_change_with_history( + lambda target_pos=pos: manager._update_component_anchor_position(index, target_pos) + ) if is_current: imgui.pop_style_color(3) @@ -1681,14 +2214,20 @@ class LUIFunctionPropertiesMixin: anchor_offset_y = comp_data.get('anchor_manual_offset_y', 0.0) changed_x, new_offset_x = imgui.input_float("X偏移", anchor_offset_x, 1.0, 10.0, "%.1f") + if imgui.is_item_activated(): + manager._begin_lui_structure_snapshot_session("lui_anchor_offset_x") if changed_x: comp_data['anchor_manual_offset_x'] = new_offset_x manager._update_component_anchor_position(index, anchor_pos, manual_offset=(new_offset_x, anchor_offset_y)) + manager._finish_lui_structure_snapshot_session("lui_anchor_offset_x") changed_y, new_offset_y = imgui.input_float("Y偏移", anchor_offset_y, 1.0, 10.0, "%.1f") + if imgui.is_item_activated(): + manager._begin_lui_structure_snapshot_session("lui_anchor_offset_y") if changed_y: comp_data['anchor_manual_offset_y'] = new_offset_y manager._update_component_anchor_position(index, anchor_pos, manual_offset=(anchor_offset_x, new_offset_y)) + manager._finish_lui_structure_snapshot_session("lui_anchor_offset_y") imgui.spacing() @@ -1742,27 +2281,33 @@ class LUIFunctionPropertiesMixin: changed, new_p_select = imgui.combo("更改父级", current_p_select, parent_options) if changed: new_parent_idx = parent_indices[new_p_select] - if new_parent_idx == -1: - # Unparent to Root - if current_parent_index is not None and current_parent_index >= 0: - # Remove from old parent - old_p = manager.components[current_parent_index] - if index in old_p.get('children_indices', []): - old_p['children_indices'].remove(index) - - comp_data['parent_index'] = -1 - # Physical reparent to Canvas or Root - if manager.current_canvas_index >= 0: - canvas_panel = manager.canvases[manager.current_canvas_index]['panel'] - comp_obj.reparent_to(canvas_panel) - else: - # Fallback to overlay root - comp_obj.reparent_to(manager.overlay_root) - print(f"✓ 已将组件 #{index} 设为根节点") + def apply_parent_change(): + if new_parent_idx == -1: + # Unparent to Root + if current_parent_index is not None and current_parent_index >= 0: + # Remove from old parent + old_p = manager.components[current_parent_index] + if index in old_p.get('children_indices', []): + old_p['children_indices'].remove(index) + + comp_data['parent_index'] = -1 + # Physical reparent to Canvas or Root + if manager.current_canvas_index >= 0: + canvas_panel = manager.canvases[manager.current_canvas_index]['panel'] + comp_obj.reparent_to(canvas_panel) + else: + # Fallback to overlay root + comp_obj.reparent_to(manager.overlay_root) + print(f"✓ 已将组件 #{index} 设为根节点") + else: + # Set New Parent + manager._set_parent_child_relationship(index, new_parent_idx, keep_world=True) + print(f"✓ 已更改父级: 组件 #{index} -> 父级 #{new_parent_idx}") + + if hasattr(manager, "_apply_lui_structure_change_with_history"): + manager._apply_lui_structure_change_with_history(apply_parent_change) else: - # Set New Parent - manager._set_parent_child_relationship(index, new_parent_idx, keep_world=True) - print(f"✓ 已更改父级: 组件 #{index} -> 父级 #{new_parent_idx}") + apply_parent_change() # 删除按钮 imgui.spacing() diff --git a/ui/LUI/lui_manager_editor.py b/ui/LUI/lui_manager_editor.py index b3a52a8e..6542b592 100644 --- a/ui/LUI/lui_manager_editor.py +++ b/ui/LUI/lui_manager_editor.py @@ -1,5 +1,6 @@ """LUIManager tree/editor/layout mixin.""" +import copy import struct from pathlib import Path @@ -8,6 +9,938 @@ from imgui_bundle import imgui, imgui_ctx class LUIManagerEditorMixin: + def _ensure_component_edit_sessions(self): + if not hasattr(self, "_component_edit_sessions"): + self._component_edit_sessions = {} + return self._component_edit_sessions + + def _capture_component_snapshot(self, index): + if index < 0 or index >= len(self.components): + return None + + comp_data = self.components[index] + excluded_keys = { + "object", + "widget", + "sprite", + "ui_node", + "layout_obj", + "texture", + "texture_ref", + "atlas_tex", + "button_texture_ref", + "button_texture_ref_normal", + "button_texture_ref_hover", + "button_texture_ref_pressed", + "input_texture_ref", + "video_texture", + "keep_alive_node", + "keep_alive", + "_input_default_runtime", + } + + snapshot = {} + for key, value in comp_data.items(): + if key in excluded_keys or key.endswith("_ref") or key.endswith("_tex"): + continue + if isinstance(value, (str, int, float, bool, type(None))): + snapshot[key] = value + continue + if isinstance(value, (tuple, list, dict)): + try: + snapshot[key] = copy.deepcopy(value) + except Exception: + pass + return snapshot + + def _component_snapshots_equal(self, before_snapshot, after_snapshot): + before_snapshot = before_snapshot or {} + after_snapshot = after_snapshot or {} + return before_snapshot == after_snapshot + + def _begin_component_snapshot_session(self, index, session_key): + sessions = self._ensure_component_edit_sessions() + sessions.setdefault((index, session_key), self._capture_component_snapshot(index)) + + def _record_component_snapshot_command(self, index, before_snapshot, after_snapshot): + if not hasattr(self, "world") or not hasattr(self.world, "command_manager") or not self.world.command_manager: + return + if self._component_snapshots_equal(before_snapshot, after_snapshot): + return + + try: + from core.Command_System import SnapshotStateCommand + + self.world.command_manager.record_command( + SnapshotStateCommand( + lambda state, target_index=index: self._apply_component_snapshot(target_index, state), + before_snapshot, + after_snapshot, + ) + ) + except Exception: + pass + + def _finish_component_snapshot_session(self, index, session_key): + if not imgui.is_item_deactivated_after_edit(): + return + sessions = self._ensure_component_edit_sessions() + before_snapshot = sessions.pop((index, session_key), None) + if before_snapshot is None: + return + after_snapshot = self._capture_component_snapshot(index) + self._record_component_snapshot_command(index, before_snapshot, after_snapshot) + + def _apply_component_change_with_history(self, index, callback): + before_snapshot = self._capture_component_snapshot(index) + callback() + after_snapshot = self._capture_component_snapshot(index) + self._record_component_snapshot_command(index, before_snapshot, after_snapshot) + + def _ensure_lui_structure_edit_sessions(self): + if not hasattr(self, "_lui_structure_edit_sessions"): + self._lui_structure_edit_sessions = {} + return self._lui_structure_edit_sessions + + def _capture_canvas_snapshot(self, canvas_data): + if not canvas_data: + return None + + panel = canvas_data.get("panel") + background = canvas_data.get("background") + title_label = canvas_data.get("title_label") + left = float(getattr(panel, "left", 0.0) or 0.0) if panel is not None else 0.0 + top = float(getattr(panel, "top", 0.0) or 0.0) if panel is not None else 0.0 + width = float(getattr(panel, "width", 0.0) or 0.0) if panel is not None else 0.0 + height = float(getattr(panel, "height", 0.0) or 0.0) if panel is not None else 0.0 + + if panel is not None and hasattr(panel, "get_pos") and left == 0.0 and top == 0.0: + try: + pos = panel.get_pos() + left = float(pos.x) + top = float(pos.y) + except Exception: + pass + + return { + "name": canvas_data.get("name"), + "visible": bool(canvas_data.get("visible", True)), + "fill_mode": bool(canvas_data.get("fill_mode", False)), + "fixed": bool(canvas_data.get("fixed", False)), + "margins": copy.deepcopy(canvas_data.get("margins", {})), + "left": left, + "top": top, + "width": width, + "height": height, + "background_color": tuple(getattr(background, "color", (0.5, 0.5, 0.5, 1.0))) if background is not None else None, + "title_visible": bool(getattr(title_label, "visible", True)) if title_label is not None else None, + "title_text": getattr(title_label, "text", None) if title_label is not None else None, + "title_pos": ( + float(getattr(title_label, "left", 0.0) or 0.0), + float(getattr(title_label, "top", 0.0) or 0.0), + ) if title_label is not None else None, + } + + def _capture_lui_structure_snapshot(self): + return { + "canvases": [ + snapshot + for snapshot in (self._capture_canvas_snapshot(canvas) for canvas in getattr(self, "canvases", [])) + if snapshot is not None + ], + "components": [ + snapshot + for snapshot in (self._capture_component_snapshot(index) for index in range(len(getattr(self, "components", [])))) + if snapshot is not None + ], + "current_canvas_index": int(getattr(self, "current_canvas_index", -1)), + "selected_index": int(getattr(self, "selected_index", -1)), + "root_order": copy.deepcopy(getattr(self, "root_order", [])), + } + + def _lui_structure_snapshots_equal(self, before_snapshot, after_snapshot): + before_snapshot = before_snapshot or {} + after_snapshot = after_snapshot or {} + return before_snapshot == after_snapshot + + def _record_lui_structure_snapshot_command(self, before_snapshot, after_snapshot): + if not hasattr(self, "world") or not hasattr(self.world, "command_manager") or not self.world.command_manager: + return + if self._lui_structure_snapshots_equal(before_snapshot, after_snapshot): + return + + try: + from core.Command_System import SnapshotStateCommand + + self.world.command_manager.record_command( + SnapshotStateCommand( + self._apply_lui_structure_snapshot, + before_snapshot, + after_snapshot, + ) + ) + except Exception: + pass + + def _begin_lui_structure_snapshot_session(self, session_key): + sessions = self._ensure_lui_structure_edit_sessions() + sessions.setdefault(session_key, self._capture_lui_structure_snapshot()) + + def _finish_lui_structure_snapshot_session(self, session_key): + sessions = self._ensure_lui_structure_edit_sessions() + before_snapshot = sessions.pop(session_key, None) + if before_snapshot is None: + return + after_snapshot = self._capture_lui_structure_snapshot() + self._record_lui_structure_snapshot_command(before_snapshot, after_snapshot) + + def _apply_lui_structure_change_with_history(self, callback): + if getattr(self, "_restoring_lui_structure", False): + return callback() + + before_snapshot = self._capture_lui_structure_snapshot() + result = callback() + after_snapshot = self._capture_lui_structure_snapshot() + self._record_lui_structure_snapshot_command(before_snapshot, after_snapshot) + return result + + def _remove_lui_runtime_object(self, obj): + if obj is None: + return + try: + parent = getattr(obj, "parent", None) + if parent is not None and hasattr(parent, "remove_child"): + parent.remove_child(obj) + return + except Exception: + pass + for method_name in ("remove", "destroy", "detach_node", "hide"): + try: + method = getattr(obj, method_name, None) + if method: + method() + break + except Exception: + pass + + def _clear_lui_structure_runtime(self): + if hasattr(self, "_hide_resize_handles"): + try: + self._hide_resize_handles() + except Exception: + pass + + if hasattr(self, "debug_outlines") and isinstance(self.debug_outlines, dict): + for borders in list(self.debug_outlines.values()): + for border in borders or []: + try: + if hasattr(border, "remove"): + border.remove() + else: + border.visible = False + except Exception: + pass + self.debug_outlines = {} + + for comp_data in reversed(list(getattr(self, "components", []))): + audio = comp_data.get("audio") + if audio is not None: + try: + if hasattr(audio, "stop"): + audio.stop() + except Exception: + pass + ui_node = comp_data.get("ui_node") + if ui_node is not None: + try: + if not ui_node.isEmpty(): + ui_node.removeNode() + except Exception: + pass + keep_alive_node = comp_data.get("keep_alive_node") + if keep_alive_node is not None: + try: + if hasattr(keep_alive_node, "removeNode") and not keep_alive_node.isEmpty(): + keep_alive_node.removeNode() + except Exception: + pass + keep_alive = comp_data.get("keep_alive") + if keep_alive is not None: + try: + if hasattr(keep_alive, "destroy"): + keep_alive.destroy() + elif hasattr(keep_alive, "removeNode") and not keep_alive.isEmpty(): + keep_alive.removeNode() + except Exception: + pass + self._remove_lui_runtime_object(comp_data.get("object")) + + for canvas_data in reversed(list(getattr(self, "canvases", []))): + node = canvas_data.get("node") + if node is not None: + try: + if not node.isEmpty(): + node.removeNode() + except Exception: + pass + self._remove_lui_runtime_object(canvas_data.get("panel")) + + self.components = [] + self.canvases = [] + self.current_canvas_index = -1 + self.canvas_counter = 0 + self.selected_index = -1 + self._last_selected_index = -1 + self.root_order = [] + self.dragging_comp = None + self.pending_drag_comp = None + self.pending_drag_index = -1 + self.pending_drag_start_mouse = None + self.pending_drag_start_abs = None + self.pending_drag_start_parent = None + self.dragging_index = -1 + self.resizing_handle = None + self.resize_start_pos = (0, 0) + self.resize_start_bounds = {} + self.root = getattr(self, "overlay_root", None) + + def _apply_canvas_snapshot(self, canvas_index, snapshot): + if canvas_index < 0 or canvas_index >= len(self.canvases): + return + + snapshot = snapshot or {} + canvas_data = self.canvases[canvas_index] + canvas_data["name"] = snapshot.get("name", canvas_data.get("name")) + canvas_data["fill_mode"] = bool(snapshot.get("fill_mode", canvas_data.get("fill_mode", False))) + canvas_data["fixed"] = bool(snapshot.get("fixed", canvas_data.get("fixed", False))) + canvas_data["visible"] = bool(snapshot.get("visible", canvas_data.get("visible", True))) + canvas_data["margins"] = copy.deepcopy(snapshot.get("margins", canvas_data.get("margins", {}))) + + panel = canvas_data.get("panel") + background = canvas_data.get("background") + title_label = canvas_data.get("title_label") + canvas_node = canvas_data.get("node") + + if canvas_node is not None and hasattr(canvas_node, "setName"): + try: + canvas_node.setName(canvas_data["name"]) + except Exception: + pass + + if title_label is not None and hasattr(title_label, "text"): + try: + title_label.text = f"Canvas: {canvas_data['name']}" + except Exception: + pass + + if panel is None: + return + + if canvas_data["fill_mode"] and hasattr(self, "_update_canvas_geometry"): + try: + self._update_canvas_geometry(canvas_data) + except Exception: + pass + else: + left = float(snapshot.get("left", 0.0) or 0.0) + top = float(snapshot.get("top", 0.0) or 0.0) + width = float(snapshot.get("width", getattr(panel, "width", 0.0)) or 0.0) + height = float(snapshot.get("height", getattr(panel, "height", 0.0)) or 0.0) + try: + if hasattr(panel, "set_pos"): + panel.set_pos(left, top) + else: + panel.left = left + panel.top = top + except Exception: + pass + try: + if hasattr(panel, "width"): + panel.width = width + if hasattr(panel, "height"): + panel.height = height + if background is not None: + background.width = width + background.height = height + except Exception: + pass + + try: + if canvas_data["visible"] and hasattr(panel, "show"): + panel.show() + elif not canvas_data["visible"] and hasattr(panel, "hide"): + panel.hide() + except Exception: + pass + + if background is not None and snapshot.get("background_color") is not None: + try: + background.color = tuple(snapshot.get("background_color")) + except Exception: + pass + + if title_label is not None: + try: + title_text = snapshot.get("title_text") + if title_text is not None: + title_label.text = title_text + title_pos = snapshot.get("title_pos") + if title_pos is not None: + if hasattr(title_label, "set_pos"): + title_label.set_pos(title_pos[0], title_pos[1]) + else: + title_label.left = title_pos[0] + title_label.top = title_pos[1] + title_visible = snapshot.get("title_visible") + if title_visible is not None: + if title_visible and hasattr(title_label, "show"): + title_label.show() + elif not title_visible and hasattr(title_label, "hide"): + title_label.hide() + else: + title_label.visible = bool(title_visible) + except Exception: + pass + + def _compute_lui_structure_absolute_position(self, index, component_snapshots, memo=None): + if memo is None: + memo = {} + if index in memo: + return memo[index] + if index < 0 or index >= len(component_snapshots): + return 0.0, 0.0 + + snapshot = component_snapshots[index] or {} + left = float(snapshot.get("left", 0.0) or 0.0) + top = float(snapshot.get("top", 0.0) or 0.0) + parent_index = snapshot.get("parent_index") + if parent_index is not None and parent_index >= 0: + parent_left, parent_top = self._compute_lui_structure_absolute_position(parent_index, component_snapshots, memo) + left += parent_left + top += parent_top + + memo[index] = (left, top) + return memo[index] + + def _resolve_lui_restore_parent(self, snapshot): + parent_index = snapshot.get("parent_index") + if parent_index is None or parent_index < 0 or snapshot.get("visual_parent_canvas"): + return None + if parent_index >= len(self.components): + return None + try: + return self._resolve_parent_visual_object(self.components[parent_index]) + except Exception: + return self.components[parent_index].get("object") + + def _create_component_from_structure_snapshot(self, index, component_snapshots, memo=None): + snapshot = component_snapshots[index] or {} + canvas_index = snapshot.get("canvas_index") + if canvas_index is not None and 0 <= canvas_index < len(self.canvases): + self.current_canvas_index = canvas_index + + parent = self._resolve_lui_restore_parent(snapshot) + abs_left, abs_top = self._compute_lui_structure_absolute_position(index, component_snapshots, memo) + comp_type = snapshot.get("type") + width = float(snapshot.get("width", 100.0) or 100.0) + height = float(snapshot.get("height", 30.0) or 30.0) + + if comp_type == "Button": + created = self.luiFunction.create_button(self, text=snapshot.get("text", "Button"), x=abs_left, y=abs_top, parent=parent) + elif comp_type in ["Text", "Label"]: + created = self.luiFunction.create_label( + self, + text=snapshot.get("text", "Text"), + x=abs_left, + y=abs_top, + font_size=int(snapshot.get("font_size", 14) or 14), + parent=parent, + ) + elif comp_type == "Checkbox": + created = self.luiFunction.create_checkbox(self, label=snapshot.get("text", "Checkbox"), x=abs_left, y=abs_top, parent=parent) + elif comp_type == "InputField": + created = self.luiFunction.create_input_field( + self, + text=snapshot.get("value", snapshot.get("text", "")), + x=abs_left, + y=abs_top, + width=width, + parent=parent, + ) + elif comp_type == "Slider": + created = self.luiFunction.create_slider(self, x=abs_left, y=abs_top, width=width, parent=parent) + elif comp_type == "Frame": + created = self.luiFunction.create_frame(self, x=abs_left, y=abs_top, width=width, height=height, parent=parent) + elif comp_type == "Plane": + created = self.luiFunction.create_plane( + self, + x=abs_left, + y=abs_top, + width=width, + height=height, + color=tuple(snapshot.get("color", (1.0, 1.0, 1.0, 1.0))), + parent=parent, + ) + elif comp_type == "Image": + created = self.luiFunction.create_image( + self, + texture_path=snapshot.get("texture_path", "blank"), + x=abs_left, + y=abs_top, + width=width, + height=height, + parent=parent, + ) + elif comp_type == "Video": + created = self.luiFunction.create_video( + self, + video_path=snapshot.get("video_path", ""), + x=abs_left, + y=abs_top, + width=width, + height=height, + parent=parent, + ) + elif comp_type == "HttpText": + created = self.luiFunction.create_http_text( + self, + x=abs_left, + y=abs_top, + width=width, + height=height, + url=snapshot.get("http_url", ""), + parent=parent, + ) + elif comp_type == "Progressbar": + created = self.luiFunction.create_progressbar( + self, + value=float(snapshot.get("value", 50.0) or 50.0), + x=abs_left, + y=abs_top, + width=width, + parent=parent, + ) + elif comp_type == "Selectbox": + created = self.luiFunction.create_selectbox( + self, + options=copy.deepcopy(snapshot.get("options", [])), + x=abs_left, + y=abs_top, + width=width, + parent=parent, + ) + elif comp_type == "ScrollableRegion": + created = self.luiFunction.create_scrollable_region( + self, + x=abs_left, + y=abs_top, + width=width, + height=height, + parent=parent, + ) + elif comp_type == "TabbedFrame": + created = self.luiFunction.create_tabbed_frame( + self, + x=abs_left, + y=abs_top, + width=width, + height=height, + parent=parent, + ) + elif comp_type == "VerticalLayout": + created = self.luiFunction.create_vertical_layout( + self, + x=abs_left, + y=abs_top, + width=width, + height=height, + spacing=float(snapshot.get("layout_spacing", 0.0) or 0.0), + parent=parent, + ) + elif comp_type == "HorizontalLayout": + created = self.luiFunction.create_horizontal_layout( + self, + x=abs_left, + y=abs_top, + width=width, + height=height, + spacing=float(snapshot.get("layout_spacing", 0.0) or 0.0), + parent=parent, + ) + else: + raise ValueError(f"Unsupported LUI component type: {comp_type}") + + if created is None: + raise RuntimeError(f"Failed to recreate LUI component: {comp_type}") + return len(self.components) - 1 + + def _restore_lui_structure_postprocess(self): + for index, comp_data in enumerate(self.components): + comp_type = comp_data.get("type") + if comp_type in ["VerticalLayout", "HorizontalLayout"] and hasattr(self, "_update_layout_inner"): + try: + self._update_layout_inner(index) + except Exception: + pass + if comp_type == "ScrollableRegion" and hasattr(self, "_reparent_scrollable_children"): + try: + self._reparent_scrollable_children(index) + except Exception: + pass + + for index, comp_data in enumerate(self.components): + if comp_data.get("layout_mode") == "fill" and hasattr(self, "_apply_fill_layout"): + try: + self._apply_fill_layout(index) + except Exception: + pass + + for index, comp_data in enumerate(self.components): + if comp_data.get("type") in ["HorizontalLayout", "VerticalLayout"] and comp_data.get("layout_wrap", True): + try: + self._apply_wrap_layout(index) + except Exception: + pass + + root_indices = [ + index + for index, comp_data in enumerate(self.components) + if comp_data.get("parent_index") is None or comp_data.get("parent_index") < 0 + ] + for index in root_indices: + try: + self._sync_canvas_children(index) + except Exception: + pass + try: + self._update_anchored_children(index) + except Exception: + pass + + def _apply_lui_structure_snapshot(self, snapshot): + if getattr(self, "_restoring_lui_structure", False): + return + + self._restoring_lui_structure = True + try: + snapshot = snapshot or {} + canvas_snapshots = snapshot.get("canvases", []) or [] + component_snapshots = snapshot.get("components", []) or [] + + self._clear_lui_structure_runtime() + + for canvas_snapshot in canvas_snapshots: + self.luiFunction.create_canvas(self, name=canvas_snapshot.get("name")) + self._apply_canvas_snapshot(len(self.canvases) - 1, canvas_snapshot) + + abs_pos_memo = {} + for index in range(len(component_snapshots)): + self._create_component_from_structure_snapshot(index, component_snapshots, abs_pos_memo) + + for index, comp_snapshot in enumerate(component_snapshots): + if index >= len(self.components): + break + self._apply_component_snapshot(index, comp_snapshot) + + restored_root_order = snapshot.get("root_order", []) or [] + self.root_order = [index for index in restored_root_order if 0 <= index < len(self.components)] + if not self.root_order: + self.root_order = [ + index + for index, comp_data in enumerate(self.components) + if comp_data.get("parent_index") is None or comp_data.get("parent_index") < 0 + ] + + self._restore_lui_structure_postprocess() + + target_canvas = snapshot.get("current_canvas_index", -1) + if 0 <= target_canvas < len(self.canvases): + self.switch_canvas(target_canvas) + elif self.canvases: + self.switch_canvas(0) + else: + self.current_canvas_index = -1 + + selected_index = snapshot.get("selected_index", -1) + if 0 <= selected_index < len(self.components): + self.selected_index = selected_index + self._last_selected_index = selected_index + if hasattr(self, "_show_and_update_handles"): + try: + self._show_and_update_handles(self.components[selected_index]) + except Exception: + pass + else: + self.selected_index = -1 + self._last_selected_index = -1 + + self.canvas_counter = len(self.canvases) + + if hasattr(self.world, "updateSceneTree"): + try: + self.world.updateSceneTree() + except Exception: + pass + finally: + self._restoring_lui_structure = False + + def _create_canvas_with_history(self, name=None): + return self._apply_lui_structure_change_with_history( + lambda: self.luiFunction.create_canvas(self, name=name) + ) + + def _apply_component_snapshot(self, index, snapshot): + if index < 0 or index >= len(self.components): + return + + comp_data = self.components[index] + removable_state_keys = { + "button_texture_path", + "button_texture_path_normal", + "button_texture_path_hover", + "button_texture_path_pressed", + "button_atlas_uv", + "button_atlas_uv_normal", + "button_atlas_uv_hover", + "button_atlas_uv_pressed", + "button_image_size", + "button_image_size_normal", + "button_image_size_hover", + "button_image_size_pressed", + "input_texture_path", + "input_atlas_uv", + "input_image_size", + "current_texture", + "atlas_uv", + "image_size", + "keep_alive", + } + preserved_refs = {} + for key in ( + "object", + "widget", + "sprite", + "ui_node", + "layout_obj", + "texture", + "texture_ref", + "atlas_tex", + "button_texture_ref", + "button_texture_ref_normal", + "button_texture_ref_hover", + "button_texture_ref_pressed", + "input_texture_ref", + "video_texture", + "keep_alive_node", + "keep_alive", + "_input_default_runtime", + ): + if key in comp_data: + preserved_refs[key] = comp_data[key] + + snapshot = snapshot or {} + for key in removable_state_keys: + if key not in snapshot and key in comp_data: + comp_data.pop(key, None) + for key, value in snapshot.items(): + try: + comp_data[key] = copy.deepcopy(value) + except Exception: + comp_data[key] = value + + for key, value in preserved_refs.items(): + comp_data[key] = value + + self._sync_component_from_snapshot(index) + + def _sync_component_from_snapshot(self, index): + if index < 0 or index >= len(self.components): + return + + comp_data = self.components[index] + comp_obj = comp_data.get("object") + comp_type = comp_data.get("type") + if comp_obj is None: + return + + text_value = comp_data.get("text") + if text_value is not None: + try: + if comp_type == "Checkbox" and hasattr(comp_obj, "label") and hasattr(comp_obj.label, "text"): + comp_obj.label.text = text_value + elif comp_type == "HttpText" and comp_data.get("text_label") is not None and hasattr(comp_data["text_label"], "text"): + comp_data["text_label"].text = text_value + elif hasattr(comp_obj, "set_text"): + comp_obj.set_text(text_value) + elif hasattr(comp_obj, "text"): + comp_obj.text = text_value + except Exception: + pass + + if "font_size" in comp_data: + font_size = int(comp_data.get("font_size", 14)) + try: + if hasattr(comp_obj, '_text') and hasattr(comp_obj._text, 'font_size'): + comp_obj._text.font_size = font_size + if hasattr(comp_obj, '_shadow_text') and hasattr(comp_obj._shadow_text, 'font_size'): + comp_obj._shadow_text.font_size = font_size + if hasattr(comp_obj, 'text_handle') and hasattr(comp_obj.text_handle, 'font_size'): + comp_obj.text_handle.font_size = font_size + except Exception: + pass + + color = comp_data.get("color") + if color is not None: + try: + if comp_type == "Button": + self.luiFunction._apply_button_layout_color(self, comp_obj, tuple(color)) + elif comp_type in ["Text", "Label"]: + self.luiFunction._apply_text_color(self, comp_obj, tuple(color)) + elif comp_type in ["Plane", "Image"] and comp_data.get("sprite") is not None: + comp_data["sprite"].color = tuple(color) + elif comp_type == "Frame" and hasattr(comp_obj, "set_color"): + comp_obj.set_color(tuple(color)) + except Exception: + pass + + input_color = comp_data.get("input_color") + if input_color is not None: + try: + layout = getattr(comp_obj, "_layout", None) + if layout is not None: + for attr in ("_sprite_left", "_sprite_mid", "_sprite_right"): + spr = getattr(layout, attr, None) + if spr is not None: + spr.color = tuple(input_color) + except Exception: + pass + + if "value" in comp_data: + try: + if comp_type == "Slider" and hasattr(comp_obj, "set_value"): + comp_obj.set_value(comp_data["value"]) + elif hasattr(comp_obj, "value"): + comp_obj.value = comp_data["value"] + except Exception: + pass + + if "checked" in comp_data and hasattr(comp_obj, "checked"): + try: + comp_obj.checked = comp_data["checked"] + except Exception: + pass + + if "selected_option_id" in comp_data: + try: + options = comp_data.get("options", []) + if hasattr(comp_obj, "set_options"): + comp_obj.set_options(options) + elif hasattr(comp_obj, "options"): + comp_obj.options = options + if options and hasattr(comp_obj, "_select_option"): + comp_obj._select_option(comp_data.get("selected_option_id")) + except Exception: + pass + + layout_mode = comp_data.get("layout_mode", "manual") + if layout_mode != "fill": + target_left = float(comp_data.get("left", 0.0) or 0.0) + target_top = float(comp_data.get("top", 0.0) or 0.0) + parent_index = comp_data.get("parent_index") + if parent_index is not None and parent_index >= 0 and comp_data.get("visual_parent_canvas"): + try: + parent_abs_left, parent_abs_top = self._get_component_accumulated_pos(parent_index) + target_left += parent_abs_left + target_top += parent_abs_top + except Exception: + pass + + try: + if hasattr(comp_obj, "set_pos"): + comp_obj.set_pos(target_left, target_top) + else: + if hasattr(comp_obj, "left"): + comp_obj.left = target_left + if hasattr(comp_obj, "top"): + comp_obj.top = target_top + except Exception: + pass + + if "width" in comp_data: + width = float(comp_data["width"]) + try: + if hasattr(comp_obj, "width"): + comp_obj.width = width + if comp_data.get("sprite") is not None: + comp_data["sprite"].width = width + if comp_type in ["Slider", "InputField"] and hasattr(comp_obj, "set_width"): + comp_obj.set_width(width) + except Exception: + pass + + if "height" in comp_data: + height = float(comp_data["height"]) + try: + if hasattr(comp_obj, "height"): + comp_obj.height = height + if comp_data.get("sprite") is not None: + comp_data["sprite"].height = height + if comp_type == "Button" and hasattr(comp_obj, "_apply_stretch_sizes"): + comp_obj._apply_stretch_sizes() + if comp_type in ["Slider", "InputField"] and hasattr(comp_obj, "set_height"): + comp_obj.set_height(height) + except Exception: + pass + + if "z_offset" in comp_data: + z_offset = float(comp_data["z_offset"]) + try: + if hasattr(comp_obj, "set_z_offset"): + comp_obj.set_z_offset(z_offset) + elif hasattr(comp_obj, "z_offset"): + comp_obj.z_offset = z_offset + except Exception: + pass + + if layout_mode == "fill" and hasattr(self, "_apply_fill_layout"): + try: + self._apply_fill_layout(index) + except Exception: + pass + + if comp_type == "HttpText" and hasattr(self.luiFunction, "sync_http_text_layout"): + try: + text_label = comp_data.get("text_label") + if text_label is not None: + if "text_color" in comp_data and hasattr(text_label, "color"): + text_label.color = tuple(comp_data.get("text_color", (1.0, 1.0, 1.0, 1.0))) + if "text" in comp_data and hasattr(text_label, "text"): + text_label.text = comp_data["text"] + self.luiFunction.sync_http_text_layout(self, comp_data) + except Exception: + pass + + if hasattr(self.luiFunction, "_reapply_component_resource_state"): + try: + self.luiFunction._reapply_component_resource_state(self, comp_data, comp_obj, comp_type) + except Exception as e: + print(f"LUI资源状态回放失败: {e}") + + if comp_type in ["VerticalLayout", "HorizontalLayout"] and hasattr(self, "_update_layout_inner"): + try: + self._update_layout_inner(index) + except Exception: + pass + + parent_index = comp_data.get("parent_index") + try: + if parent_index is not None and parent_index >= 0 and hasattr(self, "_update_anchored_children"): + self._update_anchored_children(parent_index) + if hasattr(self, "_update_anchored_children"): + self._update_anchored_children(index) + except Exception: + pass + def _add_to_scene_tree(self, comp_data): """Add a virtual node to represent the LUI component in the scene tree""" from panda3d.core import NodePath @@ -423,6 +1356,10 @@ class LUIManagerEditorMixin: print("\n=== Create child component ===") print(f"Parent index: {parent_index}, Type: {comp_type}") + before_snapshot = None + if not getattr(self, "_restoring_lui_structure", False): + before_snapshot = self._capture_lui_structure_snapshot() + if parent_index < 0 or parent_index >= len(self.components): print("Error: invalid parent index") return None @@ -534,6 +1471,11 @@ class LUIManagerEditorMixin: parent_data['children_indices'].append(child_index) print(f"Linked child #{child_index} -> parent #{parent_index}") + if before_snapshot is not None: + self._record_lui_structure_snapshot_command( + before_snapshot, + self._capture_lui_structure_snapshot(), + ) return child_index else: print("Child creation failed") @@ -661,7 +1603,7 @@ class LUIManagerEditorMixin: imgui.separator() if imgui.button("创建新Canvas", (150, 25)): - self.luiFunction.create_canvas(self) + self._create_canvas_with_history() imgui.same_line() @@ -691,40 +1633,54 @@ class LUIManagerEditorMixin: is_visible = canvas.get('visible', True) changed, visible = imgui.checkbox("显示Canvas", is_visible) if changed: - canvas['visible'] = visible - if visible: - panel.show() - else: - panel.hide() - + self._apply_lui_structure_change_with_history( + lambda: ( + canvas.__setitem__('visible', visible), + panel.show() if visible else panel.hide(), + ) + ) + # Fill Mode & Margins is_fill_mode = canvas.get('fill_mode', False) imgui.separator() changed, fill_mode = imgui.checkbox("场景填充模式", is_fill_mode) if changed: - canvas['fill_mode'] = fill_mode - if fill_mode: - # 刚切换到填充模式时,更新一次 - self._update_canvas_geometry(canvas) - + def apply_fill_mode(): + canvas['fill_mode'] = fill_mode + if fill_mode: + self._update_canvas_geometry(canvas) + self._apply_lui_structure_change_with_history(apply_fill_mode) + if fill_mode: imgui.text("场景边距设置 (调整以匹配视口)") margins = canvas.get('margins', {'left':0,'right':0,'top':0,'bottom':0}) geometry_changed = False - + changed, margins['left'] = imgui.drag_float("左边距", margins['left'], 1.0, 0, 1000) + if imgui.is_item_activated(): + self._begin_lui_structure_snapshot_session("canvas_margin_left") if changed: geometry_changed = True - + self._finish_lui_structure_snapshot_session("canvas_margin_left") + changed, margins['right'] = imgui.drag_float("右边距", margins['right'], 1.0, 0, 1000) + if imgui.is_item_activated(): + self._begin_lui_structure_snapshot_session("canvas_margin_right") if changed: geometry_changed = True - + self._finish_lui_structure_snapshot_session("canvas_margin_right") + changed, margins['top'] = imgui.drag_float("顶边距", margins['top'], 1.0, 0, 500) + if imgui.is_item_activated(): + self._begin_lui_structure_snapshot_session("canvas_margin_top") if changed: geometry_changed = True - + self._finish_lui_structure_snapshot_session("canvas_margin_top") + changed, margins['bottom'] = imgui.drag_float("底边距", margins['bottom'], 1.0, 0, 500) + if imgui.is_item_activated(): + self._begin_lui_structure_snapshot_session("canvas_margin_bottom") if changed: geometry_changed = True - + self._finish_lui_structure_snapshot_session("canvas_margin_bottom") + if geometry_changed: canvas['margins'] = margins self._update_canvas_geometry(canvas) @@ -737,17 +1693,24 @@ class LUIManagerEditorMixin: is_fixed = canvas.get('fixed', False) changed, fixed = imgui.checkbox("锁定位置 (禁止拖动)", is_fixed) if changed: - canvas['fixed'] = fixed - + self._apply_lui_structure_change_with_history( + lambda: canvas.__setitem__('fixed', fixed) + ) + if not is_fixed: changed, pos = imgui.drag_float2("Canvas位置", curr_pos) + if imgui.is_item_activated(): + self._begin_lui_structure_snapshot_session("canvas_position") if changed: panel.set_pos(pos[0], pos[1]) + self._finish_lui_structure_snapshot_session("canvas_position") - + # Manual Size curr_size = (panel.get_width(), panel.get_height()) changed, size = imgui.drag_float2("Canvas尺寸", curr_size, 1, 100, 4000) + if imgui.is_item_activated(): + self._begin_lui_structure_snapshot_session("canvas_size") if changed: panel.width = size[0] panel.height = size[1] @@ -756,7 +1719,8 @@ class LUIManagerEditorMixin: bg = canvas['background'] bg.width = size[0] bg.height = size[1] - + self._finish_lui_structure_snapshot_session("canvas_size") + # Canvas Color if 'background' in canvas: imgui.separator() @@ -765,11 +1729,14 @@ class LUIManagerEditorMixin: current_color = bg.color # 转换为可编辑的格式 (list) edit_color = [current_color[0], current_color[1], current_color[2], current_color[3]] - + changed, new_color = imgui.color_edit4("背景颜色/透明度", edit_color) + if imgui.is_item_activated(): + self._begin_lui_structure_snapshot_session("canvas_bg_color") if changed: bg.color = (new_color[0], new_color[1], new_color[2], new_color[3]) - + self._finish_lui_structure_snapshot_session("canvas_bg_color") + # Title settings if 'title_label' in canvas: imgui.separator() @@ -780,26 +1747,34 @@ class LUIManagerEditorMixin: is_title_visible = title.visible changed, title_visible = imgui.checkbox("显示标题", is_title_visible) if changed: - try: - if title_visible: - title.show() - else: - title.hide() - except: - title.visible = title_visible + def apply_title_visible(): + try: + if title_visible: + title.show() + else: + title.hide() + except Exception: + title.visible = title_visible + self._apply_lui_structure_change_with_history(apply_title_visible) # Text content curr_text = title.text changed, new_text = imgui.input_text("标题文本", curr_text, 128) + if imgui.is_item_activated(): + self._begin_lui_structure_snapshot_session("canvas_title_text") if changed: title.text = new_text + self._finish_lui_structure_snapshot_session("canvas_title_text") # Position curr_pos_val = title.get_pos() curr_pos = (curr_pos_val.x, curr_pos_val.y) changed, new_pos = imgui.drag_float2("标题位置", curr_pos) + if imgui.is_item_activated(): + self._begin_lui_structure_snapshot_session("canvas_title_pos") if changed: title.set_pos(new_pos[0], new_pos[1]) + self._finish_lui_structure_snapshot_session("canvas_title_pos") imgui.unindent() else: @@ -974,7 +1949,9 @@ class LUIManagerEditorMixin: target_idx = -1 if 0 <= src_idx < len(self.components): if target_idx == -1: - self._set_parent_root(src_idx, keep_world=True) + self._apply_lui_structure_change_with_history( + lambda: self._set_parent_root(src_idx, keep_world=True) + ) elif target_idx != src_idx: if target_idx in self._get_descendants(src_idx): print("Invalid drop: cannot parent to descendant") @@ -985,15 +1962,21 @@ class LUIManagerEditorMixin: want_reorder = bool(getattr(io, 'key_shift', False)) if want_reorder and src_parent == tgt_parent: if src_parent is None or src_parent < 0: - if not self.root_order: - self.root_order = [i for i, c in enumerate(self.components) if c.get('parent_index') is None or c.get('parent_index') < 0] - self.root_order = self._reorder_list(self.root_order, src_idx, target_idx) + def reorder_root(): + if not self.root_order: + self.root_order = [i for i, c in enumerate(self.components) if c.get('parent_index') is None or c.get('parent_index') < 0] + self.root_order = self._reorder_list(self.root_order, src_idx, target_idx) + self._apply_lui_structure_change_with_history(reorder_root) else: - parent_data = self.components[src_parent] - children = parent_data.get('children_indices', []) - parent_data['children_indices'] = self._reorder_list(children, src_idx, target_idx) + def reorder_children(): + parent_data = self.components[src_parent] + children = parent_data.get('children_indices', []) + parent_data['children_indices'] = self._reorder_list(children, src_idx, target_idx) + self._apply_lui_structure_change_with_history(reorder_children) else: - self._set_parent_child_relationship(src_idx, target_idx, keep_world=True) + self._apply_lui_structure_change_with_history( + lambda: self._set_parent_child_relationship(src_idx, target_idx, keep_world=True) + ) # Clear drag state if released outside any item if self._tree_drag_src is not None and imgui.is_mouse_released(0): @@ -1037,7 +2020,12 @@ class LUIManagerEditorMixin: # 使用新的锚点更新方法 if hasattr(self, '_temp_selected_index_for_anchor'): selected_idx = self._temp_selected_index_for_anchor - self._update_component_anchor_position(selected_idx, pos) + self._apply_lui_structure_change_with_history( + lambda target_idx=selected_idx, target_pos=pos: self._update_component_anchor_position( + target_idx, + target_pos, + ) + ) self.anchor_popup_open = False if hasattr(self, '_temp_selected_index_for_anchor'): @@ -1057,6 +2045,9 @@ class LUIManagerEditorMixin: def _create_component_by_type(self, type_index): """Create LUI component by index - 默认生成在Canvas中心""" + before_snapshot = None + if not getattr(self, "_restoring_lui_structure", False): + before_snapshot = self._capture_lui_structure_snapshot() # 1. 确定生成位置 (默认 Canvas 中心) canvas_relative_x = 100 @@ -1136,12 +2127,19 @@ class LUIManagerEditorMixin: print(f"✓ 创建组件成功,已重定向至 Canvas 中心: ({canvas_relative_x:.1f}, {canvas_relative_y:.1f})") if hasattr(self.world, 'updateSceneTree'): self.world.updateSceneTree() + if before_snapshot is not None: + self._record_lui_structure_snapshot_command( + before_snapshot, + self._capture_lui_structure_snapshot(), + ) except Exception as e: print(f"创建组件失败: {str(e)}") def set_component_anchor(self, comp_index, anchor_position): """设置组件锚点 - 保持向后兼容""" - self._update_component_anchor_position(comp_index, anchor_position) + self._apply_lui_structure_change_with_history( + lambda: self._update_component_anchor_position(comp_index, anchor_position) + ) def _update_anchored_children(self, parent_index): """Recursively update anchored/fill children.""" @@ -1722,8 +2720,13 @@ class LUIManagerEditorMixin: if imgui.button(f"{button_text}##popup_{pos}", (50, 25)): if hasattr(self, '_temp_selected_index_for_anchor'): selected_idx = self._temp_selected_index_for_anchor - # Reset manual offset by passing (0,0) explicitly to match original behavior logic - self._update_component_anchor_position(selected_idx, pos, manual_offset=(0.0, 0.0)) + self._apply_lui_structure_change_with_history( + lambda target_idx=selected_idx, target_pos=pos: self._update_component_anchor_position( + target_idx, + target_pos, + manual_offset=(0.0, 0.0), + ) + ) # Clean up temp attribute delattr(self, '_temp_selected_index_for_anchor') diff --git a/ui/LUI/lui_manager_interaction.py b/ui/LUI/lui_manager_interaction.py index af85fec5..9cd8dea9 100644 --- a/ui/LUI/lui_manager_interaction.py +++ b/ui/LUI/lui_manager_interaction.py @@ -39,6 +39,9 @@ class LUIManagerInteractionMixin: if canvas_data.get('fixed', False): return + if hasattr(self, "_begin_lui_structure_snapshot_session"): + self._begin_lui_structure_snapshot_session("canvas_drag") + if canvas_data.get('fill_mode', False): canvas_data['fill_mode'] = False print("✓ 自动禁用填充模式以允许自由拖动") @@ -357,6 +360,8 @@ class LUIManagerInteractionMixin: return True if not self._is_left_mouse_down(): + if hasattr(self, "_finish_lui_structure_snapshot_session"): + self._finish_lui_structure_snapshot_session("canvas_drag") self.dragging_canvas = None return True @@ -395,6 +400,8 @@ class LUIManagerInteractionMixin: # Start drag now comp_data = self.pending_drag_comp + if hasattr(self, "_begin_lui_structure_snapshot_session"): + self._begin_lui_structure_snapshot_session("lui_drag") self.dragging_comp = comp_data self.dragging_index = self.pending_drag_index self._original_parent_obj = self.pending_drag_start_parent @@ -510,6 +517,8 @@ class LUIManagerInteractionMixin: comp_obj.top = comp_data.get('top', 0) print(f"✓ 组件归位到逻辑父级") + if hasattr(self, "_finish_lui_structure_snapshot_session"): + self._finish_lui_structure_snapshot_session("lui_drag") self.dragging_comp = None return True return False @@ -795,6 +804,8 @@ class LUIManagerInteractionMixin: 'width': comp_data.get('width', 100), 'height': comp_data.get('height', 30), } + if hasattr(self, "_begin_lui_structure_snapshot_session"): + self._begin_lui_structure_snapshot_session("lui_resize") def _update_resize_handles(self, task): # Restore selection if it was cleared immediately @@ -1278,6 +1289,8 @@ class LUIManagerInteractionMixin: if anchor_pos: self._update_component_anchor_position(self.selected_index, anchor_pos) + if hasattr(self, "_finish_lui_structure_snapshot_session"): + self._finish_lui_structure_snapshot_session("lui_resize") self.resizing_handle = None return True diff --git a/ui/panels/app_actions.py b/ui/panels/app_actions.py index e9f6100c..3ddb658c 100644 --- a/ui/panels/app_actions.py +++ b/ui/panels/app_actions.py @@ -1,5 +1,9 @@ import os +import datetime +import json +import webbrowser + from imgui_bundle import imgui from direct.showbase.ShowBaseGlobal import globalClock from direct.task.TaskManagerGlobal import taskMgr @@ -256,9 +260,55 @@ class AppActions: def _on_save_as_project(self): """处理另存为项目菜单项""" - self.add_info_message("另存为项目(功能待实现)") - # TODO: 实现另存为对话框 - # self.show_save_as_dialog = True + current_project_path = getattr(getattr(self, "project_manager", None), "current_project_path", None) + if current_project_path: + self.save_as_project_name = f"{os.path.basename(current_project_path)}_副本" + self.save_as_project_path = os.path.dirname(current_project_path) + else: + self.save_as_project_name = "新项目" + self.save_as_project_path = os.getcwd() + self.add_info_message("打开另存为项目对话框") + self.show_save_as_dialog = True + + + def _show_about_dialog(self): + """显示关于对话框。""" + self.show_about_dialog = True + + + def _get_documentation_target(self): + """返回优先级最高的本地帮助文档。""" + candidate_names = [ + "PROJECT_MODULE_INDEX.md", + "IMGUI_MODULE_ANALYSIS.md", + "PROJECT_FULL_CATALOG.md", + "PROJECT_OPTIMIZATION_ANALYSIS.md", + "AGENTS.md", + ] + for candidate_name in candidate_names: + candidate_path = os.path.join(os.getcwd(), candidate_name) + if os.path.isfile(candidate_path): + return candidate_path + return None + + + def _open_documentation(self): + """打开本地项目文档。""" + doc_path = self._get_documentation_target() + if not doc_path: + self.add_error_message("未找到可用的项目文档") + return False + + try: + if os.name == "nt": + os.startfile(doc_path) + else: + webbrowser.open(f"file://{doc_path}") + self.add_success_message(f"已打开文档: {os.path.basename(doc_path)}") + return True + except Exception as e: + self.add_error_message(f"打开文档失败: {e}") + return False def _on_exit(self): @@ -623,6 +673,7 @@ class AppActions: # 反序列化并添加节点 created_any = False + last_created_node = None source_nodes = getattr(self, "clipboard_source_nodes", []) or [] for i, node_data in enumerate(self.clipboard): def _paste_create_node(parent): @@ -659,10 +710,37 @@ class AppActions: return created new_node = None - if hasattr(self, "command_manager") and self.command_manager: + if self.clipboard_mode == "cut" and i < len(source_nodes): + source_node = source_nodes[i] + if source_node and not source_node.isEmpty(): + try: + from core.Command_System import AttachNodeCommand, CompositeCommand, DeleteNodeCommand + + attach_cmd = AttachNodeCommand(source_node, parent_node, self) + if hasattr(self, "command_manager") and self.command_manager: + last_cmd = None + if hasattr(self.command_manager, "pop_last_command"): + last_cmd = self.command_manager.pop_last_command() + + if isinstance(last_cmd, DeleteNodeCommand) and getattr(last_cmd, "node", None) == source_node: + attach_cmd.execute() + self.command_manager.record_command(CompositeCommand([last_cmd, attach_cmd])) + new_node = source_node + else: + if last_cmd is not None: + self.command_manager.record_command(last_cmd) + self.command_manager.execute_command(attach_cmd) + new_node = source_node + else: + attach_cmd.execute() + new_node = source_node + except Exception as e: + print(f"[Paste] attach existing cut node failed, fallback to recreate: {e}") + + if new_node is None and hasattr(self, "command_manager") and self.command_manager: try: from core.Command_System import CreateNodeCommand - create_cmd = CreateNodeCommand(_paste_create_node, parent_node) + create_cmd = CreateNodeCommand(_paste_create_node, parent_node, world=self) self.command_manager.execute_command(create_cmd) new_node = create_cmd.created_node except Exception as e: @@ -673,6 +751,7 @@ class AppActions: if new_node: created_any = True + last_created_node = new_node # Ensure pasted model can be picked by legacy collision fallback. try: if hasattr(self, "scene_manager") and self.scene_manager: @@ -689,6 +768,15 @@ class AppActions: else: self.add_error_message("节点反序列化失败") + if created_any and last_created_node and hasattr(self, "selection") and self.selection: + try: + self.selection.updateSelection(last_created_node) + ssbo_editor = getattr(self, "ssbo_editor", None) + if ssbo_editor and hasattr(ssbo_editor, "sync_scene_selection"): + ssbo_editor.sync_scene_selection(last_created_node) + except Exception as e: + print(f"[Paste] select pasted node failed: {e}") + # 如果是剪切模式且粘贴成功,清空剪切板 if created_any and self.clipboard_mode == "cut": self.clipboard = [] @@ -861,7 +949,6 @@ class AppActions: """打开项目的具体实现(不依赖Qt)""" import json import datetime - import os try: # 检查项目管理器是否已初始化 @@ -940,10 +1027,6 @@ class AppActions: def _create_new_project_impl(self, name, path): """创建新项目的具体实现(不依赖Qt)""" - import json - import datetime - import os - full_project_path = os.path.normpath(os.path.join(path, name)) print(f"创建项目路径: {full_project_path}") @@ -986,6 +1069,84 @@ class AppActions: except Exception as e: print(f"创建项目失败: {e}") return False + + + def _save_project_as_impl(self, name, path): + """将当前项目保存到新的项目目录。""" + if not hasattr(self, "project_manager") or not self.project_manager: + self.add_error_message("项目管理器未初始化") + return False + + if not name or not path: + self.add_warning_message("项目名称和保存位置不能为空") + return False + + full_project_path = os.path.normpath(os.path.join(path, name)) + current_project_path = ( + os.path.normpath(self.project_manager.current_project_path) + if self.project_manager.current_project_path + else None + ) + + if current_project_path and os.path.normcase(full_project_path) == os.path.normcase(current_project_path): + self.add_warning_message("目标路径与当前项目相同,已执行普通保存") + return self._save_project_impl() + + try: + if os.path.exists(full_project_path): + if not os.path.isdir(full_project_path): + self.add_error_message("目标路径已存在且不是文件夹") + return False + if os.listdir(full_project_path): + self.add_error_message("目标目录已存在且非空,请选择空目录或新的项目名称") + return False + else: + os.makedirs(full_project_path) + + os.makedirs(os.path.join(full_project_path, "models"), exist_ok=True) + os.makedirs(os.path.join(full_project_path, "textures"), exist_ok=True) + os.makedirs(os.path.join(full_project_path, "scenes"), exist_ok=True) + + previous_project_path = self.project_manager.current_project_path + previous_project_config = dict(self.project_manager.project_config or {}) + + timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + project_config = dict(previous_project_config) + project_config.update({ + "name": name, + "path": full_project_path, + "last_modified": timestamp, + "scene_file": "scenes/scene.bam", + }) + if "created_at" in project_config: + project_config["created_at"] = project_config.get("created_at") or timestamp + elif "created" in project_config: + project_config["created"] = project_config.get("created") or timestamp + else: + project_config["created_at"] = timestamp + if "version" not in project_config: + project_config["version"] = "1.0" + + config_file = os.path.join(full_project_path, "project.json") + with open(config_file, "w", encoding="utf-8") as f: + json.dump(project_config, f, ensure_ascii=False, indent=4) + + self.project_manager.current_project_path = full_project_path + self.project_manager.project_config = project_config + + if not self.project_manager.saveProject(): + self.project_manager.current_project_path = previous_project_path + self.project_manager.project_config = previous_project_config + self._update_window_title(os.path.basename(previous_project_path) if previous_project_path else "未命名项目") + self.add_error_message("项目另存为失败") + return False + + self._update_window_title(name) + self.add_success_message(f"项目另存为成功: {name}") + return True + except Exception as e: + self.add_error_message(f"项目另存为失败: {e}") + return False def _update_window_title(self, project_name): diff --git a/ui/panels/create_actions.py b/ui/panels/create_actions.py index f5b277f8..6cb96f37 100644 --- a/ui/panels/create_actions.py +++ b/ui/panels/create_actions.py @@ -13,70 +13,82 @@ class CreateActions: else: setattr(self.app, name, value) + def _execute_create_command(self, creator, success_message, failure_message, error_prefix): + try: + result = None + if hasattr(self, "command_manager") and self.command_manager: + from core.Command_System import CreateNodeCommand + + command = CreateNodeCommand( + lambda _parent: creator(), + getattr(self, "render", None), + world=self, + ) + self.command_manager.execute_command(command) + result = command.created_node + else: + result = creator() + + if result: + if hasattr(self, "selection") and self.selection and hasattr(self.selection, "updateSelection"): + self.selection.updateSelection(result) + self.add_success_message(success_message) + else: + self.add_error_message(failure_message) + return result + except Exception as e: + self.add_error_message(f"{error_prefix}: {str(e)}") + return None + def _on_create_empty_object(self): """创建空对象""" - try: - result = self.createEmptyObject() - if result: - self.add_success_message("空对象创建成功") - else: - self.add_error_message("空对象创建失败") - return result - except Exception as e: - self.add_error_message(f"创建空对象失败: {str(e)}") + return self._execute_create_command( + self.createEmptyObject, + "空对象创建成功", + "空对象创建失败", + "创建空对象失败", + ) def _on_create_cube(self): """创建立方体""" - try: - result = self.createCube() - if result: - self.add_success_message("立方体创建成功") - else: - self.add_error_message("立方体创建失败") - return result - except Exception as e: - self.add_error_message(f"创建立方体失败: {str(e)}") + return self._execute_create_command( + self.createCube, + "立方体创建成功", + "立方体创建失败", + "创建立方体失败", + ) def _on_create_sphere(self): """创建球体""" - try: - result = self.createSphere() - if result: - self.add_success_message("球体创建成功") - else: - self.add_error_message("球体创建失败") - return result - except Exception as e: - self.add_error_message(f"创建球体失败: {str(e)}") + return self._execute_create_command( + self.createSphere, + "球体创建成功", + "球体创建失败", + "创建球体失败", + ) def _on_create_cylinder(self): """创建圆柱体""" - try: - result = self.createCylinder() - if result: - self.add_success_message("圆柱体创建成功") - else: - self.add_error_message("圆柱体创建失败") - return result - except Exception as e: - self.add_error_message(f"创建圆柱体失败: {str(e)}") + return self._execute_create_command( + self.createCylinder, + "圆柱体创建成功", + "圆柱体创建失败", + "创建圆柱体失败", + ) def _on_create_plane(self): """创建平面""" - try: - result = self.createPlane() - if result: - self.add_success_message("平面创建成功") - else: - self.add_error_message("平面创建失败") - return result - except Exception as e: - self.add_error_message(f"创建平面失败: {str(e)}") + return self._execute_create_command( + self.createPlane, + "平面创建成功", + "平面创建失败", + "创建平面失败", + ) def _on_create_spot_light(self): """创建聚光灯""" diff --git a/ui/panels/dialog_panels.py b/ui/panels/dialog_panels.py index 9f0021bb..39c67dd4 100644 --- a/ui/panels/dialog_panels.py +++ b/ui/panels/dialog_panels.py @@ -1,4 +1,6 @@ import os +import platform +import sys from imgui_bundle import imgui, imgui_ctx @@ -125,6 +127,67 @@ class DialogPanels: imgui.same_line() if imgui.button("取消"): self.show_open_project_dialog = False + + + def _draw_save_as_project_dialog(self): + """绘制另存为项目对话框。""" + if not self.show_save_as_dialog: + return + + if not getattr(self, "save_as_project_name", "").strip(): + current_project_path = getattr(getattr(self, "project_manager", None), "current_project_path", None) + default_name = os.path.basename(current_project_path) if current_project_path else "新项目" + self.save_as_project_name = f"{default_name}_副本" + if not getattr(self, "save_as_project_path", "").strip(): + current_project_path = getattr(getattr(self, "project_manager", None), "current_project_path", None) + self.save_as_project_path = os.path.dirname(current_project_path) if current_project_path else os.getcwd() + + flags = ( + imgui.WindowFlags_.no_resize + | imgui.WindowFlags_.no_collapse + | imgui.WindowFlags_.modal + ) + self.style_manager.prepare_centered_dialog(460, 240) + + with imgui_ctx.begin("项目另存为", True, flags) as window: + if not window.opened: + self.show_save_as_dialog = False + return + + imgui.text("将当前项目保存到新的项目目录") + imgui.separator() + + changed, project_name = imgui.input_text("项目名称", self.save_as_project_name, 256) + if changed: + self.save_as_project_name = project_name + + changed, project_path = imgui.input_text("保存位置", self.save_as_project_path, 512) + if changed: + self.save_as_project_path = project_path + + imgui.same_line() + if imgui.button("浏览..."): + self.path_browser_mode = "save_as_project" + self.path_browser_current_path = self.save_as_project_path if self.save_as_project_path else os.getcwd() + self.show_path_browser = True + self._refresh_path_browser() + + imgui.separator() + target_path = os.path.normpath(os.path.join(self.save_as_project_path or "", self.save_as_project_name or "")) + imgui.text("目标路径:") + imgui.same_line() + imgui.text_colored((0.7, 0.7, 0.7, 1.0), target_path) + + if imgui.button("保存"): + if self.save_as_project_name.strip() and self.save_as_project_path.strip(): + if self._save_project_as_impl(self.save_as_project_name.strip(), self.save_as_project_path.strip()): + self.show_save_as_dialog = False + else: + self.add_warning_message("项目名称和保存位置不能为空") + + imgui.same_line() + if imgui.button("取消"): + self.show_save_as_dialog = False def _draw_path_browser(self): @@ -383,6 +446,10 @@ class DialogPanels: # 打开项目模式:使用当前路径 self.open_project_path = self.path_browser_current_path self.add_info_message(f"已选择项目路径: {self.open_project_path}") + elif self.path_browser_mode == "save_as_project": + # 另存为项目模式:使用当前路径作为目标目录 + self.save_as_project_path = self.path_browser_current_path + self.add_info_message(f"已选择保存位置: {self.save_as_project_path}") elif self.path_browser_mode == "import_model": # 导入模型模式:使用选择的文件路径 self.import_file_path = self.path_browser_selected_path @@ -391,6 +458,43 @@ class DialogPanels: self.add_error_message(f"应用路径失败: {e}") + def _draw_about_dialog(self): + """绘制关于对话框。""" + if not self.show_about_dialog: + return + + flags = ( + imgui.WindowFlags_.no_resize + | imgui.WindowFlags_.no_collapse + | imgui.WindowFlags_.modal + ) + self.style_manager.prepare_centered_dialog(460, 260) + + with imgui_ctx.begin("关于 EG", True, flags) as window: + if not window.opened: + self.show_about_dialog = False + return + + current_project_path = getattr(getattr(self, "project_manager", None), "current_project_path", None) or "未打开项目" + imgui.text("EG 编辑器") + imgui.separator() + imgui.text(f"Python: {platform.python_version()}") + imgui.text(f"平台: {platform.system()} {platform.release()}") + imgui.text(f"可执行文件: {os.path.basename(sys.executable)}") + imgui.separator() + imgui.text("当前项目:") + imgui.text_colored((0.7, 0.7, 0.7, 1.0), current_project_path) + imgui.separator() + imgui.text_wrapped("当前帮助菜单的文档入口会打开项目目录下的本地 Markdown 文档。") + + if imgui.button("打开文档"): + self._open_documentation() + + imgui.same_line() + if imgui.button("关闭"): + self.show_about_dialog = False + + def _draw_spot_light_dialog(self): """绘制聚光灯创建对话框""" if not self.show_spot_light_dialog: diff --git a/ui/panels/editor_panels_right.py b/ui/panels/editor_panels_right.py index eb9125f6..0114ec68 100644 --- a/ui/panels/editor_panels_right.py +++ b/ui/panels/editor_panels_right.py @@ -12,6 +12,67 @@ class EditorPanelsRightMixin( ): """Right panel aggregator mixin.""" + 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)) + if new_visible: + node.show() + else: + node.hide() + return + from core.Command_System import VisibilityNodeCommand + self.app.command_manager.execute_command( + VisibilityNodeCommand(node, old_visible, new_visible) + ) + + def _record_name_change(self, node, old_name, new_name): + if old_name == new_name: + return + if hasattr(self.app, "command_manager") and self.app.command_manager: + from core.Command_System import RenameNodeCommand + self.app.command_manager.record_command( + RenameNodeCommand(node, old_name, new_name, world=self.app) + ) + else: + self.app._update_node_name(node, new_name) + + def _ensure_light_edit_sessions(self): + if not hasattr(self, "_light_edit_sessions"): + self._light_edit_sessions = {} + return self._light_edit_sessions + + def _begin_light_edit_session(self, node, session_key): + sessions = self._ensure_light_edit_sessions() + sessions.setdefault(session_key, self.app._capture_light_snapshot(node)) + + def _record_light_snapshot_command(self, node, before_snapshot, after_snapshot): + if not hasattr(self.app, "command_manager") or not self.app.command_manager: + return + if self.app._light_snapshots_equal(before_snapshot, after_snapshot): + return + try: + from core.Command_System import SnapshotStateCommand + + self.app.command_manager.record_command( + SnapshotStateCommand( + lambda state, target_node=node: self.app._apply_light_snapshot(target_node, state), + before_snapshot, + after_snapshot, + ) + ) + except Exception: + pass + + def _finish_light_edit_session(self, node, session_key): + if not imgui.is_item_deactivated_after_edit(): + return + sessions = self._ensure_light_edit_sessions() + before_snapshot = sessions.pop(session_key, None) + if before_snapshot is None: + return + after_snapshot = self.app._capture_light_snapshot(node) + self._record_light_snapshot_command(node, before_snapshot, after_snapshot) + def _draw_property_panel(self): """绘制属性面板""" # 使用面板类型的窗口标志,支持docking @@ -120,11 +181,7 @@ class EditorPanelsRightMixin( changed, is_visible = imgui.checkbox("##node_enabled", user_visible) if changed: - node.setPythonTag("user_visible", is_visible) - if is_visible: - node.show() - else: - node.hide() + self._record_visibility_change(node, user_visible, is_visible) imgui.same_line() imgui.text_disabled("启用") @@ -132,7 +189,17 @@ class EditorPanelsRightMixin( imgui.set_next_item_width(-1) changed, new_name = imgui.input_text("##name", node_name, 256) if changed and hasattr(self.app, "selection"): + if not hasattr(self.app, "_name_edit_session"): + self.app._name_edit_session = None + if not self.app._name_edit_session or self.app._name_edit_session[0] != node: + self.app._name_edit_session = (node, node_name) self.app._update_node_name(node, new_name) + if imgui.is_item_deactivated_after_edit(): + session = getattr(self.app, "_name_edit_session", None) + if session and session[0] == node: + _, original_name = session + self._record_name_change(node, original_name, node.getName()) + self.app._name_edit_session = None parent_name = "SceneRoot" try: @@ -388,26 +455,19 @@ class EditorPanelsRightMixin( def _draw_light_properties(self, node): """绘制光源属性""" imgui.text("光源属性") - - # 光源颜色 - if hasattr(node, 'getColor'): - try: - color = node.getColor() - # 确保颜色是有效的 - if not color or len(color) < 3: - color = (1.0, 1.0, 1.0, 1.0) # 默认白色 - except: - color = (1.0, 1.0, 1.0, 1.0) # 默认白色 - - changed, new_r = imgui.drag_float("颜色 R", color[0], 0.01, 0.0, 1.0) - if changed: node.setColor(new_r, color[1], color[2], color[3] if len(color) > 3 else 1.0) - - changed, new_g = imgui.drag_float("颜色 G", color[1], 0.01, 0.0, 1.0) - if changed: node.setColor(color[0], new_g, color[2], color[3] if len(color) > 3 else 1.0) - - changed, new_b = imgui.drag_float("颜色 B", color[2], 0.01, 0.0, 1.0) - if changed: node.setColor(color[0], color[1], new_b, color[3] if len(color) > 3 else 1.0) - + + color = self.app._get_light_color(node) + changed, new_color = imgui.color_edit3( + "颜色##light_color", + (color[0], color[1], color[2]), + imgui.ColorEditFlags_.display_rgb.value, + ) + if imgui.is_item_activated(): + self._begin_light_edit_session(node, "light_color") + if changed: + self.app._apply_light_color(node, (new_color[0], new_color[1], new_color[2], color[3])) + self._finish_light_edit_session(node, "light_color") + # 光源强度 imgui.text("光源强度: (暂不支持编辑)") diff --git a/ui/panels/editor_panels_right_collision.py b/ui/panels/editor_panels_right_collision.py index aafc5819..06e35413 100644 --- a/ui/panels/editor_panels_right_collision.py +++ b/ui/panels/editor_panels_right_collision.py @@ -3,6 +3,49 @@ from imgui_bundle import imgui, imgui_ctx class EditorPanelsRightCollisionMixin: """Auto-split mixin from editor_panels_right.py.""" + def _ensure_collision_edit_sessions(self): + if not hasattr(self, "_collision_edit_sessions"): + self._collision_edit_sessions = {} + return self._collision_edit_sessions + + def _begin_collision_edit_session(self, node, session_key): + sessions = self._ensure_collision_edit_sessions() + sessions.setdefault(session_key, self.app._capture_collision_snapshot(node)) + + def _record_collision_snapshot_command(self, node, before_snapshot, after_snapshot): + if not hasattr(self.app, "command_manager") or not self.app.command_manager: + return + if self.app._collision_snapshots_equal(before_snapshot, after_snapshot): + return + try: + from core.Command_System import SnapshotStateCommand + + self.app.command_manager.record_command( + SnapshotStateCommand( + lambda state, target_node=node: self.app._apply_collision_snapshot(target_node, state), + before_snapshot, + after_snapshot, + ) + ) + except Exception: + pass + + def _finish_collision_edit_session(self, node, session_key): + if not imgui.is_item_deactivated_after_edit(): + return + sessions = self._ensure_collision_edit_sessions() + before_snapshot = sessions.pop(session_key, None) + if before_snapshot is None: + return + after_snapshot = self.app._capture_collision_snapshot(node) + self._record_collision_snapshot_command(node, before_snapshot, after_snapshot) + + def _apply_collision_change_with_history(self, node, apply_callback): + before_snapshot = self.app._capture_collision_snapshot(node) + apply_callback() + after_snapshot = self.app._capture_collision_snapshot(node) + self._record_collision_snapshot_command(node, before_snapshot, after_snapshot) + def _draw_collision_properties(self, node): """绘制碰撞检测属性""" if not node or node.isEmpty(): @@ -37,13 +80,14 @@ class EditorPanelsRightCollisionMixin: current_index = collision_shapes.index(current_shape) if current_shape in collision_shapes else 0 changed, selected_index = imgui.combo("##collision_shape", current_index, collision_shapes) if changed: - # 始终更新选择的形状 selected_shape = collision_shapes[selected_index] - self.app._selected_collision_shape = selected_shape - # 如果已经有碰撞体,询问用户是否要重新创建 if has_collision: - self._remove_collision_from_node(node) - self._add_collision_to_node(node) + self._apply_collision_change_with_history( + node, + lambda: self._change_collision_shape(node, selected_shape), + ) + else: + self.app._selected_collision_shape = selected_shape imgui.separator() @@ -55,18 +99,27 @@ class EditorPanelsRightCollisionMixin: # X位置 changed, new_x = imgui.drag_float("X##collision_pos_x", pos_offset[0], 0.1, -100.0, 100.0, "%.2f") + if imgui.is_item_activated() and has_collision: + self._begin_collision_edit_session(node, "collision_position") if changed and has_collision: self._update_collision_position(node, 'x', new_x) + self._finish_collision_edit_session(node, "collision_position") # Y位置 changed, new_y = imgui.drag_float("Y##collision_pos_y", pos_offset[1], 0.1, -100.0, 100.0, "%.2f") + if imgui.is_item_activated() and has_collision: + self._begin_collision_edit_session(node, "collision_position") if changed and has_collision: self._update_collision_position(node, 'y', new_y) + self._finish_collision_edit_session(node, "collision_position") # Z位置 changed, new_z = imgui.drag_float("Z##collision_pos_z", pos_offset[2], 0.1, -100.0, 100.0, "%.2f") + if imgui.is_item_activated() and has_collision: + self._begin_collision_edit_session(node, "collision_position") if changed and has_collision: self._update_collision_position(node, 'z', new_z) + self._finish_collision_edit_session(node, "collision_position") # 形状特定参数(始终显示,但根据状态启用/禁用) shape_type = self._get_current_collision_shape_type(node) @@ -80,17 +133,17 @@ class EditorPanelsRightCollisionMixin: is_visible = self._is_collision_visible(node) visibility_text = "隐藏碰撞体" if is_visible else "显示碰撞体" if imgui.button(visibility_text): - self._toggle_collision_visibility(node) + self._apply_collision_change_with_history(node, lambda: self._toggle_collision_visibility(node)) imgui.same_line() # 移除碰撞按钮 if imgui.button("移除碰撞"): - self._remove_collision_from_node(node) + self._apply_collision_change_with_history(node, lambda: self._remove_collision_from_node(node)) else: # 添加碰撞按钮 if imgui.button("添加碰撞"): - self._add_collision_to_node(node) + self._apply_collision_change_with_history(node, lambda: self._add_collision_to_node(node)) imgui.separator() @@ -115,6 +168,23 @@ class EditorPanelsRightCollisionMixin: import traceback traceback.print_exc() + def _change_collision_shape(self, node, selected_shape): + self.app._selected_collision_shape = selected_shape + offset = self._get_collision_position_offset(node) + visible = self._is_collision_visible(node) + self._remove_collision_from_node(node) + self._add_collision_to_node(node) + collision_node = self.app.property_helpers._get_collision_node(node) + if collision_node: + try: + collision_node.setPos(*offset) + except Exception: + pass + if visible: + collision_node.show() + else: + collision_node.hide() + def _draw_shape_specific_parameters(self, node, shape_type, has_collision=True): """绘制形状特定参数""" try: @@ -140,8 +210,11 @@ class EditorPanelsRightCollisionMixin: # 半径调整 changed, new_radius = imgui.drag_float("半径##sphere_radius", radius, 0.1, 0.1, 100.0, "%.2f") + if imgui.is_item_activated() and has_collision: + self._begin_collision_edit_session(node, "collision_sphere_radius") if changed and has_collision: self._update_sphere_radius(node, new_radius) + self._finish_collision_edit_session(node, "collision_sphere_radius") except Exception as e: print(f"绘制球形参数失败: {e}") @@ -156,16 +229,25 @@ class EditorPanelsRightCollisionMixin: # 尺寸调整 changed, new_x = imgui.drag_float("长度##box_length", size[0], 0.1, 0.1, 100.0, "%.2f") + if imgui.is_item_activated() and has_collision: + self._begin_collision_edit_session(node, "collision_box_size") if changed and has_collision: self._update_box_size(node, 'x', new_x) + self._finish_collision_edit_session(node, "collision_box_size") changed, new_y = imgui.drag_float("宽度##box_width", size[1], 0.1, 0.1, 100.0, "%.2f") + if imgui.is_item_activated() and has_collision: + self._begin_collision_edit_session(node, "collision_box_size") if changed and has_collision: self._update_box_size(node, 'y', new_y) + self._finish_collision_edit_session(node, "collision_box_size") changed, new_z = imgui.drag_float("高度##box_height", size[2], 0.1, 0.1, 100.0, "%.2f") + if imgui.is_item_activated() and has_collision: + self._begin_collision_edit_session(node, "collision_box_size") if changed and has_collision: self._update_box_size(node, 'z', new_z) + self._finish_collision_edit_session(node, "collision_box_size") except Exception as e: print(f"绘制盒型参数失败: {e}") @@ -181,13 +263,19 @@ class EditorPanelsRightCollisionMixin: # 半径调整 changed, new_radius = imgui.drag_float("半径##capsule_radius", radius, 0.1, 0.1, 100.0, "%.2f") + if imgui.is_item_activated() and has_collision: + self._begin_collision_edit_session(node, "collision_capsule_radius") if changed and has_collision: self._update_capsule_radius(node, new_radius) + self._finish_collision_edit_session(node, "collision_capsule_radius") # 高度调整 changed, new_height = imgui.drag_float("高度##capsule_height", height, 0.1, 0.1, 100.0, "%.2f") + if imgui.is_item_activated() and has_collision: + self._begin_collision_edit_session(node, "collision_capsule_height") if changed and has_collision: self._update_capsule_height(node, new_height) + self._finish_collision_edit_session(node, "collision_capsule_height") except Exception as e: print(f"绘制胶囊体参数失败: {e}") @@ -202,16 +290,25 @@ class EditorPanelsRightCollisionMixin: # 法向量调整 changed, new_x = imgui.drag_float("法向量 X##plane_normal_x", normal[0], 0.1, -1.0, 1.0, "%.2f") + if imgui.is_item_activated() and has_collision: + self._begin_collision_edit_session(node, "collision_plane_normal") if changed and has_collision: self._update_plane_normal(node, 'x', new_x) + self._finish_collision_edit_session(node, "collision_plane_normal") changed, new_y = imgui.drag_float("法向量 Y##plane_normal_y", normal[1], 0.1, -1.0, 1.0, "%.2f") + if imgui.is_item_activated() and has_collision: + self._begin_collision_edit_session(node, "collision_plane_normal") if changed and has_collision: self._update_plane_normal(node, 'y', new_y) + self._finish_collision_edit_session(node, "collision_plane_normal") changed, new_z = imgui.drag_float("法向量 Z##plane_normal_z", normal[2], 0.1, -1.0, 1.0, "%.2f") + if imgui.is_item_activated() and has_collision: + self._begin_collision_edit_session(node, "collision_plane_normal") if changed and has_collision: self._update_plane_normal(node, 'z', new_z) + self._finish_collision_edit_session(node, "collision_plane_normal") except Exception as e: print(f"绘制平面参数失败: {e}") diff --git a/ui/panels/editor_panels_right_material.py b/ui/panels/editor_panels_right_material.py index 9975f253..0a8df3aa 100644 --- a/ui/panels/editor_panels_right_material.py +++ b/ui/panels/editor_panels_right_material.py @@ -3,6 +3,50 @@ from imgui_bundle import imgui, imgui_ctx class EditorPanelsRightMaterialMixin: """Auto-split mixin from editor_panels_right.py.""" + def _ensure_material_edit_sessions(self): + if not hasattr(self, "_material_edit_sessions"): + self._material_edit_sessions = {} + return self._material_edit_sessions + + def _begin_material_edit_session(self, node, session_key): + sessions = self._ensure_material_edit_sessions() + sessions.setdefault(session_key, self.app._capture_node_material_snapshot(node)) + + def _record_material_snapshot_command(self, node, before_snapshot, after_snapshot): + if not hasattr(self.app, "command_manager") or not self.app.command_manager: + return + if self.app._material_snapshots_equal(before_snapshot, after_snapshot): + return + + try: + from core.Command_System import MaterialStateCommand + + self.app.command_manager.record_command( + MaterialStateCommand( + lambda state, target_node=node: self.app._apply_node_material_snapshot(target_node, state), + before_snapshot, + after_snapshot, + ) + ) + except Exception: + pass + + def _finish_material_edit_session(self, node, session_key): + if not imgui.is_item_deactivated_after_edit(): + return + sessions = self._ensure_material_edit_sessions() + before_snapshot = sessions.pop(session_key, None) + if before_snapshot is None: + return + after_snapshot = self.app._capture_node_material_snapshot(node) + self._record_material_snapshot_command(node, before_snapshot, after_snapshot) + + def _apply_material_change_with_history(self, node, apply_callback): + before_snapshot = self.app._capture_node_material_snapshot(node) + apply_callback() + after_snapshot = self.app._capture_node_material_snapshot(node) + self._record_material_snapshot_command(node, before_snapshot, after_snapshot) + def _draw_appearance_properties(self, node): """绘制材质属性(Unity风格主材质入口)。""" materials = self.app._get_node_materials(node) @@ -56,8 +100,11 @@ class EditorPanelsRightMaterialMixin: base_color, imgui.ColorEditFlags_.display_rgb.value, ) + if imgui.is_item_activated(): + self._begin_material_edit_session(node, "appearance_primary_color") if changed: apply_primary_color(new_color) + self._finish_material_edit_session(node, "appearance_primary_color") imgui.same_line() if imgui.button("颜色选择器##material_color_picker"): @@ -65,7 +112,10 @@ class EditorPanelsRightMaterialMixin: target_object=None, property_name=None, initial_color=base_color, - callback=apply_primary_color, + callback=lambda color: self._apply_material_change_with_history( + node, + lambda: apply_primary_color(color), + ), ) surface_options = [ @@ -86,15 +136,21 @@ class EditorPanelsRightMaterialMixin: [label for label, _ in surface_options], ) if changed: - apply_surface_type(surface_options[selected_index][1]) + self._apply_material_change_with_history( + node, + lambda: apply_surface_type(surface_options[selected_index][1]), + ) current_surface = surface_options[selected_index][1] if self.app._get_material_surface_type(material) == 3: opacity = self.app._get_material_opacity(material) transparency = 1.0 - opacity changed, new_transparency = imgui.slider_float("透明度", transparency, 0.0, 1.0) + if imgui.is_item_activated(): + self._begin_material_edit_session(node, "appearance_opacity") if changed: apply_opacity(1.0 - new_transparency) + self._finish_material_edit_session(node, "appearance_opacity") imgui.separator() @@ -119,8 +175,11 @@ class EditorPanelsRightMaterialMixin: try: roughness_value = float(material.roughness) changed, new_roughness = imgui.slider_float(f"粗糙度##rough_{i}", roughness_value, 0.0, 1.0) + if imgui.is_item_activated(): + self._begin_material_edit_session(node, f"material_{i}_roughness") if changed: self._update_material_roughness(material, new_roughness) + self._finish_material_edit_session(node, f"material_{i}_roughness") except: imgui.text_colored((0.7, 0.7, 0.7, 1.0), "粗糙度: 不可用") @@ -128,8 +187,11 @@ class EditorPanelsRightMaterialMixin: try: metallic_value = float(material.metallic) changed, new_metallic = imgui.slider_float(f"金属性##metal_{i}", metallic_value, 0.0, 1.0) + if imgui.is_item_activated(): + self._begin_material_edit_session(node, f"material_{i}_metallic") if changed: self._update_material_metallic(material, new_metallic) + self._finish_material_edit_session(node, f"material_{i}_metallic") except: imgui.text_colored((0.7, 0.7, 0.7, 1.0), "金属性: 不可用") @@ -137,8 +199,11 @@ class EditorPanelsRightMaterialMixin: try: ior_value = float(material.refractive_index) changed, new_ior = imgui.slider_float(f"折射率##ior_{i}", ior_value, 1.0, 3.0) + if imgui.is_item_activated(): + self._begin_material_edit_session(node, f"material_{i}_ior") if changed: self._update_material_ior(material, new_ior) + self._finish_material_edit_session(node, f"material_{i}_ior") except: imgui.text_colored((0.7, 0.7, 0.7, 1.0), "折射率: 不可用") @@ -150,8 +215,14 @@ class EditorPanelsRightMaterialMixin: if imgui.begin_combo(f"预设##preset_{i}", presets[current_preset]): for j, preset_name in enumerate(presets): if imgui.selectable(preset_name, j == current_preset): - self._apply_material_preset(material, preset_name) - self._apply_material_surface_state(node, material) + self._apply_material_change_with_history( + node, + lambda selected_preset=preset_name: ( + self._apply_material_preset(material, selected_preset), + self._apply_material_surface_state(node, material), + self.app._refresh_pipeline_material_mode(node, material), + ), + ) imgui.end_combo() # 纹理信息 @@ -186,7 +257,7 @@ class EditorPanelsRightMaterialMixin: self._apply_material_to_node(node) imgui.same_line() if imgui.button("重置材质"): - self._reset_material(node) + self._apply_material_change_with_history(node, lambda: self._reset_material(node)) def _draw_shading_model_panel(self, node, material, material_index): """绘制着色模型选择面板""" diff --git a/ui/panels/editor_panels_right_transform.py b/ui/panels/editor_panels_right_transform.py index 342e4d80..31d9b1a4 100644 --- a/ui/panels/editor_panels_right_transform.py +++ b/ui/panels/editor_panels_right_transform.py @@ -4,6 +4,33 @@ from imgui_bundle import imgui class EditorPanelsRightTransformMixin: """Compact Unity-style transform editor.""" + def _ensure_transform_edit_sessions(self): + if not hasattr(self, "_transform_edit_sessions"): + self._transform_edit_sessions = {} + return self._transform_edit_sessions + + def _record_transform_property_command(self, node, row_id, old_values, new_values): + if not hasattr(self, "command_manager") or not self.command_manager: + return + if tuple(round(float(v), 6) for v in old_values) == tuple(round(float(v), 6) for v in new_values): + return + + try: + from core.Command_System import MoveNodeCommand, RotateNodeCommand, ScaleNodeCommand + + command = None + if row_id == "position": + command = MoveNodeCommand(node, tuple(old_values), tuple(new_values)) + elif row_id == "rotation": + command = RotateNodeCommand(node, tuple(old_values), tuple(new_values)) + elif row_id == "scale": + command = ScaleNodeCommand(node, tuple(old_values), tuple(new_values)) + + if command is not None: + self.command_manager.record_command(command) + except Exception: + pass + def _draw_transform_properties(self, node): imgui.push_style_var(imgui.StyleVar_.frame_padding, (6.0, 4.0)) imgui.push_style_var(imgui.StyleVar_.item_spacing, (6.0, 4.0)) @@ -13,6 +40,7 @@ class EditorPanelsRightTransformMixin: scale = node.getScale() self._draw_transform_row( + node, "位置", "position", (position.x, position.y, position.z), @@ -22,6 +50,7 @@ class EditorPanelsRightTransformMixin: reset_values=(0.0, 0.0, 0.0), ) self._draw_transform_row( + node, "旋转", "rotation", (rotation.x, rotation.y, rotation.z), @@ -31,6 +60,7 @@ class EditorPanelsRightTransformMixin: reset_values=(0.0, 0.0, 0.0), ) self._draw_transform_row( + node, "缩放", "scale", (scale.x, scale.y, scale.z), @@ -46,6 +76,7 @@ class EditorPanelsRightTransformMixin: def _draw_transform_row( self, + node, label, row_id, values, @@ -65,6 +96,8 @@ class EditorPanelsRightTransformMixin: current_values = [float(value) for value in values] original_values = list(current_values) changed_any = False + edit_finished = False + sessions = self._ensure_transform_edit_sessions() table_flags = ( imgui.TableFlags_.sizing_stretch_same.value @@ -106,6 +139,7 @@ class EditorPanelsRightTransformMixin: format=value_format, ) if axis_changed: + sessions.setdefault(row_id, list(original_values)) current_values = self._apply_vector_value_change( original_values, current_values, @@ -115,12 +149,18 @@ class EditorPanelsRightTransformMixin: reset_values, ) changed_any = True + if imgui.is_item_deactivated_after_edit(): + edit_finished = True imgui.end_table() if changed_any: apply_callback(tuple(current_values)) + if edit_finished: + initial_values = sessions.pop(row_id, list(original_values)) + self._record_transform_property_command(node, row_id, initial_values, current_values) + def _apply_vector_value_change( self, original_values, diff --git a/ui/panels/editor_panels_top.py b/ui/panels/editor_panels_top.py index ad4d039e..aa3137f7 100644 --- a/ui/panels/editor_panels_top.py +++ b/ui/panels/editor_panels_top.py @@ -245,8 +245,10 @@ class EditorPanelsTopMixin: # 帮助菜单 with imgui_ctx.begin_menu("帮助") as help_menu: if help_menu: - imgui.menu_item("关于", "", False, True) - imgui.menu_item("文档", "", False, True) + if imgui.menu_item("关于", "", False, True)[1]: + self.app._show_about_dialog() + if imgui.menu_item("文档", "", False, True)[1]: + self.app._open_documentation() # 右侧显示FPS imgui.set_cursor_pos_x(imgui.get_window_size().x - 140) diff --git a/ui/panels/panel_delegates.py b/ui/panels/panel_delegates.py index 5c09023a..8fa86aeb 100644 --- a/ui/panels/panel_delegates.py +++ b/ui/panels/panel_delegates.py @@ -187,6 +187,15 @@ class PanelDelegates: def _get_current_collision_shape_type(self, *args, **kwargs): return self.property_helpers._get_current_collision_shape_type(*args, **kwargs) + def _capture_collision_snapshot(self, *args, **kwargs): + return self.property_helpers._capture_collision_snapshot(*args, **kwargs) + + def _collision_snapshots_equal(self, *args, **kwargs): + return self.property_helpers._collision_snapshots_equal(*args, **kwargs) + + def _apply_collision_snapshot(self, *args, **kwargs): + return self.property_helpers._apply_collision_snapshot(*args, **kwargs) + def _get_collision_position_offset(self, *args, **kwargs): return self.property_helpers._get_collision_position_offset(*args, **kwargs) @@ -244,9 +253,33 @@ class PanelDelegates: def _update_node_name(self, *args, **kwargs): return self.property_helpers._update_node_name(*args, **kwargs) + def _get_light_color(self, *args, **kwargs): + return self.property_helpers._get_light_color(*args, **kwargs) + + def _apply_light_color(self, *args, **kwargs): + return self.property_helpers._apply_light_color(*args, **kwargs) + + def _capture_light_snapshot(self, *args, **kwargs): + return self.property_helpers._capture_light_snapshot(*args, **kwargs) + + def _light_snapshots_equal(self, *args, **kwargs): + return self.property_helpers._light_snapshots_equal(*args, **kwargs) + + def _apply_light_snapshot(self, *args, **kwargs): + return self.property_helpers._apply_light_snapshot(*args, **kwargs) + def _get_material_base_color(self, *args, **kwargs): return self.property_helpers._get_material_base_color(*args, **kwargs) + def _capture_node_material_snapshot(self, *args, **kwargs): + return self.property_helpers._capture_node_material_snapshot(*args, **kwargs) + + def _material_snapshots_equal(self, *args, **kwargs): + return self.property_helpers._material_snapshots_equal(*args, **kwargs) + + def _apply_node_material_snapshot(self, *args, **kwargs): + return self.property_helpers._apply_node_material_snapshot(*args, **kwargs) + def _get_node_materials(self, *args, **kwargs): return self.property_helpers._get_node_materials(*args, **kwargs) @@ -418,6 +451,12 @@ class PanelDelegates: def _on_save_as_project(self, *args, **kwargs): return self.app_actions._on_save_as_project(*args, **kwargs) + def _show_about_dialog(self, *args, **kwargs): + return self.app_actions._show_about_dialog(*args, **kwargs) + + def _open_documentation(self, *args, **kwargs): + return self.app_actions._open_documentation(*args, **kwargs) + def _on_exit(self, *args, **kwargs): return self.app_actions._on_exit(*args, **kwargs) @@ -514,6 +553,9 @@ class PanelDelegates: def _create_new_project_impl(self, *args, **kwargs): return self.app_actions._create_new_project_impl(*args, **kwargs) + def _save_project_as_impl(self, *args, **kwargs): + return self.app_actions._save_project_as_impl(*args, **kwargs) + def _update_window_title(self, *args, **kwargs): return self.app_actions._update_window_title(*args, **kwargs) @@ -535,6 +577,9 @@ class PanelDelegates: def _draw_open_project_dialog(self, *args, **kwargs): return self.dialog_panels._draw_open_project_dialog(*args, **kwargs) + def _draw_save_as_project_dialog(self, *args, **kwargs): + return self.dialog_panels._draw_save_as_project_dialog(*args, **kwargs) + def _draw_path_browser(self, *args, **kwargs): return self.dialog_panels._draw_path_browser(*args, **kwargs) @@ -571,6 +616,9 @@ class PanelDelegates: def _refresh_heightmap_browser(self, *args, **kwargs): return self.dialog_panels._refresh_heightmap_browser(*args, **kwargs) + def _draw_about_dialog(self, *args, **kwargs): + return self.dialog_panels._draw_about_dialog(*args, **kwargs) + def setup_drag_drop_support(self): """初始化拖拽支持。""" return self.model_drag_drop.setup_drag_drop_support() diff --git a/ui/panels/property_helpers.py b/ui/panels/property_helpers.py index 66303286..d3ec08c4 100644 --- a/ui/panels/property_helpers.py +++ b/ui/panels/property_helpers.py @@ -130,6 +130,20 @@ class PropertyHelpers: except Exception as e: print(f"检查碰撞状态失败: {e}") return False + + def _get_collision_node(self, node): + """Return the first collision child node for a scene node.""" + try: + if not node or node.isEmpty(): + return None + for child in node.getChildren(): + if hasattr(child, 'getName') and child.getName(): + name = child.getName() + if 'collision' in name.lower() or 'Collision' in name: + return child + except Exception: + pass + return None def _get_current_collision_shape(self, node): @@ -246,11 +260,7 @@ class PropertyHelpers: if hasattr(self, 'collision_manager'): # 使用碰撞管理器添加碰撞体 shape_type = self._get_shape_type_from_name(shape_name) - collision_node = self.collision_manager.setupAdvancedCollision( - node, - shape_type=shape_type, - mask_type='MODEL_COLLISION' - ) + collision_node = self._build_collision_node(node, shape_type) if collision_node: print(f"成功为节点 {node.getName()} 添加 {shape_name} 碰撞体") @@ -297,6 +307,56 @@ class PropertyHelpers: print(f"移除碰撞体失败: {e}") import traceback traceback.print_exc() + + def _build_collision_node(self, node, shape_type, position_offset=None, visible=None, **shape_kwargs): + """Create a collision node for the given shape and restore editor state.""" + try: + if not hasattr(self, 'collision_manager') or not self.collision_manager: + return None + collision_node = self.collision_manager.setupAdvancedCollision( + node, + shape_type=shape_type, + mask_type='MODEL_COLLISION', + **shape_kwargs, + ) + if not collision_node: + return None + + if position_offset is not None: + try: + collision_node.setPos(*position_offset) + except Exception: + pass + + if visible is not None: + if visible: + collision_node.show() + else: + collision_node.hide() + return collision_node + except Exception as e: + print(f"创建碰撞节点失败: {e}") + return None + + def _rebuild_collision_node(self, node, shape_type, position_offset=None, visible=None, **shape_kwargs): + """Recreate collision geometry while preserving offset/visibility by default.""" + try: + had_collision = self._has_collision(node) + if position_offset is None and had_collision: + position_offset = self._get_collision_position_offset(node) + if visible is None and had_collision: + visible = self._is_collision_visible(node) + self._remove_collision_from_node(node) + return self._build_collision_node( + node, + shape_type, + position_offset=position_offset, + visible=visible, + **shape_kwargs, + ) + except Exception as e: + print(f"重建碰撞节点失败: {e}") + return None def _toggle_collision_visibility(self, node): @@ -380,17 +440,8 @@ class PropertyHelpers: def _update_sphere_radius(self, node, radius): """更新球形半径""" try: - # 重新创建碰撞体来更新参数 if hasattr(self, 'collision_manager'): - # 先移除旧的碰撞体 - self._remove_collision_from_node(node) - # 重新创建带有新参数的碰撞体 - self.collision_manager.setupAdvancedCollision( - node, - shape_type='sphere', - mask_type='MODEL_COLLISION', - radius=radius - ) + self._rebuild_collision_node(node, 'sphere', radius=radius) print(f"更新球形半径为: {radius}") except Exception as e: print(f"更新球形半径失败: {e}") @@ -439,14 +490,12 @@ class PropertyHelpers: # 重新创建碰撞体 if hasattr(self, 'collision_manager'): - self._remove_collision_from_node(node) - self.collision_manager.setupAdvancedCollision( - node, - shape_type='box', - mask_type='MODEL_COLLISION', + self._rebuild_collision_node( + node, + 'box', width=new_size[0], length=new_size[1], - height=new_size[2] + height=new_size[2], ) print(f"更新盒型尺寸: {new_size}") except Exception as e: @@ -480,14 +529,7 @@ class PropertyHelpers: # 重新创建碰撞体 if hasattr(self, 'collision_manager'): - self._remove_collision_from_node(node) - self.collision_manager.setupAdvancedCollision( - node, - shape_type='capsule', - mask_type='MODEL_COLLISION', - radius=radius, - height=height - ) + self._rebuild_collision_node(node, 'capsule', radius=radius, height=height) print(f"更新胶囊体半径为: {radius}") except Exception as e: print(f"更新胶囊体半径失败: {e}") @@ -522,14 +564,7 @@ class PropertyHelpers: # 重新创建碰撞体 if hasattr(self, 'collision_manager'): - self._remove_collision_from_node(node) - self.collision_manager.setupAdvancedCollision( - node, - shape_type='capsule', - mask_type='MODEL_COLLISION', - radius=radius, - height=height - ) + self._rebuild_collision_node(node, 'capsule', radius=radius, height=height) print(f"更新胶囊体高度为: {height}") except Exception as e: print(f"更新胶囊体高度失败: {e}") @@ -578,13 +613,7 @@ class PropertyHelpers: # 重新创建碰撞体 if hasattr(self, 'collision_manager'): - self._remove_collision_from_node(node) - self.collision_manager.setupAdvancedCollision( - node, - shape_type='plane', - mask_type='MODEL_COLLISION', - normal=normal_vec - ) + self._rebuild_collision_node(node, 'plane', normal=normal_vec) print(f"更新平面法向量为: ({normal_vec.x:.2f}, {normal_vec.y:.2f}, {normal_vec.z:.2f})") except Exception as e: print(f"更新平面法向量失败: {e}") @@ -602,6 +631,113 @@ class PropertyHelpers: except Exception as e: print(f"手动碰撞检测失败: {e}") + def _capture_collision_snapshot(self, node): + """Capture collision editor state for undo/redo.""" + snapshot = { + "has_collision": False, + "shape_name": getattr(self.app, "_selected_collision_shape", "球形 (Sphere)"), + } + try: + if not node or node.isEmpty() or not self._has_collision(node): + return snapshot + + shape_name = self._get_current_collision_shape(node) + shape_type = self._get_current_collision_shape_type(node) + snapshot.update({ + "has_collision": True, + "shape_name": shape_name, + "shape_type": shape_type, + "position_offset": tuple(float(v) for v in self._get_collision_position_offset(node)), + "visible": bool(self._is_collision_visible(node)), + }) + + if shape_type == "sphere": + snapshot["radius"] = float(self._get_sphere_radius(node)) + elif shape_type == "box": + snapshot["size"] = tuple(float(v) for v in self._get_box_size(node)) + elif shape_type == "capsule": + snapshot["radius"] = float(self._get_capsule_radius(node)) + snapshot["height"] = float(self._get_capsule_height(node)) + elif shape_type == "plane": + snapshot["normal"] = tuple(float(v) for v in self._get_plane_normal(node)) + return snapshot + except Exception as e: + print(f"捕获碰撞快照失败: {e}") + return snapshot + + def _collision_snapshots_equal(self, before_snapshot, after_snapshot): + """Compare collision snapshots with float tolerance.""" + before_snapshot = before_snapshot or {} + after_snapshot = after_snapshot or {} + if bool(before_snapshot.get("has_collision")) != bool(after_snapshot.get("has_collision")): + return False + if before_snapshot.get("shape_name") != after_snapshot.get("shape_name"): + return False + if before_snapshot.get("shape_type") != after_snapshot.get("shape_type"): + return False + if bool(before_snapshot.get("visible", False)) != bool(after_snapshot.get("visible", False)): + return False + + def _rounded(values): + if values is None: + return None + if isinstance(values, (tuple, list)): + return tuple(round(float(value), 6) for value in values) + return round(float(values), 6) + + for key in ("position_offset", "size", "normal", "radius", "height"): + if _rounded(before_snapshot.get(key)) != _rounded(after_snapshot.get(key)): + return False + return True + + def _apply_collision_snapshot(self, node, snapshot): + """Apply collision state back to a node.""" + try: + if not node or node.isEmpty(): + return + + snapshot = snapshot or {} + shape_name = snapshot.get("shape_name") + if shape_name: + self.app._selected_collision_shape = shape_name + + self._remove_collision_from_node(node) + if not snapshot.get("has_collision"): + return + + shape_type = snapshot.get("shape_type") or self._get_shape_type_from_name(shape_name or "球形 (Sphere)") + build_kwargs = {} + if shape_type == "sphere": + build_kwargs["radius"] = float(snapshot.get("radius", 1.0)) + elif shape_type == "box": + size = snapshot.get("size") or (1.0, 1.0, 1.0) + build_kwargs.update({ + "width": float(size[0]), + "length": float(size[1]), + "height": float(size[2]), + }) + elif shape_type == "capsule": + build_kwargs.update({ + "radius": float(snapshot.get("radius", 1.0)), + "height": float(snapshot.get("height", 2.0)), + }) + elif shape_type == "plane": + from panda3d.core import Vec3 + normal = snapshot.get("normal") or (0.0, 0.0, 1.0) + build_kwargs["normal"] = Vec3(*normal) + + collision_node = self._build_collision_node( + node, + shape_type, + position_offset=snapshot.get("position_offset"), + visible=bool(snapshot.get("visible", False)), + **build_kwargs, + ) + if collision_node and snapshot.get("visible", False): + collision_node.show() + except Exception as e: + print(f"应用碰撞快照失败: {e}") + def _update_node_name(self, node, new_name): """更新节点名称""" if new_name and new_name != node.getName(): @@ -609,6 +745,86 @@ class PropertyHelpers: # 更新场景树显示 if hasattr(self, 'scene_tree'): self.scene_tree.refresh() + + def _get_light_color(self, node): + """Return editable light color.""" + try: + stored_color = node.getPythonTag("editor_light_color") if hasattr(node, "getPythonTag") else None + if stored_color and len(stored_color) >= 3: + return ( + float(stored_color[0]), + float(stored_color[1]), + float(stored_color[2]), + float(stored_color[3]) if len(stored_color) > 3 else 1.0, + ) + + if hasattr(node, "hasColor") and node.hasColor(): + color = node.getColor() + if color and len(color) >= 3: + return ( + float(color[0]), + float(color[1]), + float(color[2]), + float(color[3]) if len(color) > 3 else 1.0, + ) + + rp_light = node.getPythonTag("rp_light_object") + if rp_light is not None: + try: + light_color = rp_light.get_color() if hasattr(rp_light, "get_color") else getattr(rp_light, "color", None) + if light_color is not None: + return ( + float(light_color.x), + float(light_color.y), + float(light_color.z), + 1.0, + ) + except Exception: + pass + except Exception: + return (1.0, 1.0, 1.0, 1.0) + + def _apply_light_color(self, node, color): + """Apply light color to both editor node and RP light object.""" + try: + rgba = ( + float(color[0]), + float(color[1]), + float(color[2]), + float(color[3]) if len(color) > 3 else 1.0, + ) + node.setPythonTag("editor_light_color", rgba) + node.setColor(*rgba) + except Exception: + pass + + try: + rp_light = node.getPythonTag("rp_light_object") + if rp_light is not None: + rgb = rgba[:3] + if hasattr(rp_light, "set_color"): + rp_light.set_color(*rgb) + elif hasattr(rp_light, "setColor"): + rp_light.setColor(*rgb) + elif hasattr(rp_light, "color"): + rp_light.color = rgb + if hasattr(rp_light, "set_needs_update"): + rp_light.set_needs_update(True) + except Exception as e: + print(f"应用光源颜色失败: {e}") + + def _capture_light_snapshot(self, node): + return {"color": tuple(float(v) for v in self._get_light_color(node))} + + def _light_snapshots_equal(self, before_snapshot, after_snapshot): + before_color = tuple(round(float(v), 6) for v in (before_snapshot or {}).get("color", (1.0, 1.0, 1.0, 1.0))) + after_color = tuple(round(float(v), 6) for v in (after_snapshot or {}).get("color", (1.0, 1.0, 1.0, 1.0))) + return before_color == after_color + + def _apply_light_snapshot(self, node, snapshot): + color = (snapshot or {}).get("color") + if color is not None: + self._apply_light_color(node, color) def _get_material_base_color(self, material): @@ -627,6 +843,118 @@ class PropertyHelpers: except: return (1.0, 1.0, 1.0, 1.0) # 默认白色 + def _capture_node_material_snapshot(self, node): + """Capture editable material state for a node.""" + materials = self._get_node_materials(node) + if not materials: + fallback_material = self._ensure_material_for_node(node) + materials = [fallback_material] if fallback_material else [] + + snapshot = [] + for material in materials: + emission = None + try: + if hasattr(material, "emission") and material.emission is not None: + emission = ( + float(material.emission.x), + float(material.emission.y), + float(material.emission.z), + float(material.emission.w), + ) + except Exception: + emission = None + + entry = { + "material": material, + "base_color": tuple(float(v) for v in self._get_material_base_color(material)), + "roughness": float(material.roughness) if hasattr(material, "roughness") and material.roughness is not None else None, + "metallic": float(material.metallic) if hasattr(material, "metallic") and material.metallic is not None else None, + "ior": float(material.refractive_index) if hasattr(material, "refractive_index") and material.refractive_index is not None else None, + "emission": emission, + } + snapshot.append(entry) + return snapshot + + def _material_snapshots_equal(self, before_snapshot, after_snapshot): + """Compare two material snapshots with a small float tolerance.""" + before_snapshot = before_snapshot or [] + after_snapshot = after_snapshot or [] + if len(before_snapshot) != len(after_snapshot): + return False + + def _rounded_tuple(values): + if values is None: + return None + return tuple(round(float(value), 6) for value in values) + + for before_entry, after_entry in zip(before_snapshot, after_snapshot): + before_material = before_entry.get("material") + after_material = after_entry.get("material") + before_key = getattr(before_material, "this", None) or id(before_material) + after_key = getattr(after_material, "this", None) or id(after_material) + if before_key != after_key: + return False + + if _rounded_tuple(before_entry.get("base_color")) != _rounded_tuple(after_entry.get("base_color")): + return False + if _rounded_tuple(before_entry.get("emission")) != _rounded_tuple(after_entry.get("emission")): + return False + + for field_name in ("roughness", "metallic", "ior"): + before_value = before_entry.get(field_name) + after_value = after_entry.get(field_name) + if before_value is None or after_value is None: + if before_value != after_value: + return False + continue + if round(float(before_value), 6) != round(float(after_value), 6): + return False + return True + + def _apply_node_material_snapshot(self, node, snapshot): + """Apply a captured material snapshot back onto a node.""" + try: + from panda3d.core import Vec4 + + if not node or node.isEmpty(): + return + + snapshot = snapshot or [] + for entry in snapshot: + material = entry.get("material") + if material is None: + continue + + base_color = entry.get("base_color") + if base_color is not None: + self._set_material_base_color(material, tuple(base_color)) + + roughness = entry.get("roughness") + if roughness is not None and hasattr(material, "set_roughness"): + material.set_roughness(float(roughness)) + + metallic = entry.get("metallic") + if metallic is not None and hasattr(material, "set_metallic"): + material.set_metallic(float(metallic)) + + ior = entry.get("ior") + if ior is not None and hasattr(material, "set_refractive_index"): + material.set_refractive_index(float(ior)) + + emission = entry.get("emission") + if emission is not None and hasattr(material, "set_emission"): + material.set_emission(Vec4(*emission)) + + self._apply_material_to_geom_states(node, material) + self._apply_material_surface_state(node, material) + + for entry in snapshot: + material = entry.get("material") + if material is not None: + self._refresh_pipeline_material_mode(node, material) + except Exception as e: + print(f"应用材质快照失败: {e}") + def _apply_material_to_geom_states(self, node, material): """Bake the editable material into every GeomState so RP can see runtime edits.""" try: