撤销和重做优化

This commit is contained in:
ayuan9957 2026-03-13 12:33:23 +08:00
parent 1cfbeda77b
commit 6e76b54ddb
18 changed files with 3362 additions and 570 deletions

View File

@ -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:

View File

@ -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):

View File

@ -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

66
main.py
View File

@ -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()

View File

@ -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)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -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

View File

@ -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):

View File

@ -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):
"""创建聚光灯"""

View File

@ -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:

View File

@ -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("光源强度: (暂不支持编辑)")

View File

@ -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}")

View File

@ -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):
"""绘制着色模型选择面板"""

View File

@ -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,

View File

@ -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)

View File

@ -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()

View File

@ -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: