feat(editor): 添加模型导入功能支持;refactor(gizmo): 优化变换组件渲染状态
--- 添加了文件菜单中的"Import Model..."选项,支持导入多种格式的 Panda3D模型文件(.bam .egg .gltf .glb .obj .dae .fbx .stl)。 --- 为Linux系统适配了文件对话框选择逻辑,在没有zenity、kdialog等 工具时回退到tkinter实现。实现了跨平台的模型导入功能,并将 导入的模型添加到场景层级中,同时选中该模型以供编辑。 --- 为变换组件应用固定渲染状态,确保其始终正确显示在场景之上。
This commit is contained in:
parent
785aafb094
commit
c2732e7c6b
@ -1,8 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import platform
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from imgui_bundle import hello_imgui, imgui, immapp, immvision # type: ignore
|
||||
from imgui_bundle import hello_imgui, imgui, immapp, immvision, portable_file_dialogs as pfd # type: ignore
|
||||
|
||||
from .config import INI_FILENAME, WINDOW_TITLE
|
||||
from .renderer import PandaRendererThread
|
||||
@ -21,6 +23,8 @@ class EditorApp:
|
||||
self.viewport_frame_shape: tuple[int, int, int] | None = None
|
||||
self._default_font = None
|
||||
self._inspector_transform_edits: dict[tuple[str, str], tuple[float, float, float]] = {}
|
||||
self._open_model_dialog = None
|
||||
self._runner_params: hello_imgui.RunnerParams | None = None
|
||||
self.render_scale = 1.0
|
||||
self._viewport_mouse_buttons = {"lmb": False, "mmb": False, "rmb": False}
|
||||
self._viewport_keys = {
|
||||
@ -48,6 +52,7 @@ class EditorApp:
|
||||
def build_runner_params(self) -> tuple[hello_imgui.RunnerParams, immapp.AddOnsParams]:
|
||||
runner = hello_imgui.RunnerParams()
|
||||
runner.app_window_params.window_title = WINDOW_TITLE
|
||||
self._runner_params = runner
|
||||
runner.app_window_params.window_geometry.size = (1600, 960)
|
||||
runner.app_window_params.restore_previous_geometry = True
|
||||
runner.ini_filename = INI_FILENAME
|
||||
@ -62,6 +67,7 @@ class EditorApp:
|
||||
|
||||
runner.callbacks.before_exit = self._shutdown
|
||||
runner.callbacks.load_additional_fonts = self._load_fonts
|
||||
runner.callbacks.show_menus = self._show_menus
|
||||
runner.docking_params = self._build_docking_layout()
|
||||
runner.docking_params.layout_condition = hello_imgui.DockingLayoutCondition.application_start
|
||||
|
||||
@ -91,6 +97,80 @@ class EditorApp:
|
||||
|
||||
imgui.get_io().font_default = self._default_font
|
||||
|
||||
def _show_menus(self) -> None:
|
||||
if imgui.begin_menu("File"):
|
||||
if imgui.menu_item_simple("Import Model..."):
|
||||
self._request_model_import()
|
||||
imgui.end_menu()
|
||||
if self._runner_params is not None:
|
||||
hello_imgui.show_view_menu(self._runner_params)
|
||||
self._poll_model_import_dialog()
|
||||
|
||||
def _request_model_import(self) -> None:
|
||||
self._console_entries.append(("Info", "Opening model import file dialog...", "Assets"))
|
||||
try:
|
||||
if self._should_use_tk_file_dialog():
|
||||
selected_path = self._open_model_with_tkinter()
|
||||
if selected_path:
|
||||
self._queue_model_import(selected_path)
|
||||
else:
|
||||
self._console_entries.append(("Info", "Model import canceled", "Assets"))
|
||||
return
|
||||
|
||||
self._open_model_dialog = pfd.open_file(
|
||||
"Import Panda3D Model",
|
||||
filters=[
|
||||
"Panda3D model files",
|
||||
"*.bam *.egg *.gltf *.glb *.obj *.dae *.fbx *.stl",
|
||||
"All files",
|
||||
"*",
|
||||
],
|
||||
)
|
||||
except Exception as exc:
|
||||
self._console_entries.append(("Error", f"Failed to open import dialog: {exc!r}", "Assets"))
|
||||
|
||||
def _should_use_tk_file_dialog(self) -> bool:
|
||||
if platform.system() != "Linux":
|
||||
return False
|
||||
linux_dialog_helpers = ("zenity", "kdialog", "yad", "matedialog", "qarma")
|
||||
return not any(shutil.which(helper) for helper in linux_dialog_helpers)
|
||||
|
||||
def _open_model_with_tkinter(self) -> str:
|
||||
import tkinter as tk
|
||||
from tkinter import filedialog
|
||||
|
||||
root = tk.Tk()
|
||||
root.withdraw()
|
||||
try:
|
||||
root.attributes("-topmost", True)
|
||||
except tk.TclError:
|
||||
pass
|
||||
try:
|
||||
return filedialog.askopenfilename(
|
||||
title="Import Panda3D Model",
|
||||
filetypes=[
|
||||
("Panda3D model files", "*.bam *.egg *.gltf *.glb *.obj *.dae *.fbx *.stl"),
|
||||
("All files", "*"),
|
||||
],
|
||||
)
|
||||
finally:
|
||||
root.destroy()
|
||||
|
||||
def _poll_model_import_dialog(self) -> None:
|
||||
if self._open_model_dialog is None or not self._open_model_dialog.ready():
|
||||
return
|
||||
selected_paths = self._open_model_dialog.result()
|
||||
self._open_model_dialog = None
|
||||
if not selected_paths:
|
||||
self._console_entries.append(("Info", "Model import canceled", "Assets"))
|
||||
return
|
||||
|
||||
self._queue_model_import(selected_paths[0])
|
||||
|
||||
def _queue_model_import(self, model_path: str) -> None:
|
||||
self._console_entries.append(("Info", f"Importing model: {model_path}", "Assets"))
|
||||
self.renderer.queue_input_event("import_model", path=model_path)
|
||||
|
||||
def _build_docking_layout(self) -> hello_imgui.DockingParams:
|
||||
layout = hello_imgui.DockingParams()
|
||||
|
||||
@ -308,7 +388,11 @@ class EditorApp:
|
||||
self._hierarchy_auto_expand_paths.clear()
|
||||
|
||||
def _draw_node_tree(self, node, node_key: str) -> None:
|
||||
tree_flags = imgui.TreeNodeFlags_.span_full_width | imgui.TreeNodeFlags_.open_on_arrow
|
||||
tree_flags = (
|
||||
imgui.TreeNodeFlags_.span_full_width
|
||||
| imgui.TreeNodeFlags_.open_on_arrow
|
||||
| imgui.TreeNodeFlags_.open_on_double_click
|
||||
)
|
||||
if not node.children:
|
||||
tree_flags |= imgui.TreeNodeFlags_.leaf
|
||||
if node.path == self.selected_node_path:
|
||||
|
||||
@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
import queue
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import direct.task.Task as PandaTaskModule
|
||||
import numpy as np
|
||||
@ -11,6 +12,7 @@ from direct.showbase.ShowBase import ShowBase
|
||||
from imgui_bundle import immvision # type: ignore
|
||||
from panda3d.core import (
|
||||
NodePath,
|
||||
Filename,
|
||||
FrameBufferProperties,
|
||||
GraphicsOutput,
|
||||
GraphicsPipe,
|
||||
@ -230,7 +232,7 @@ class PandaRendererThread(threading.Thread):
|
||||
last_time = now
|
||||
smoothed_dt = smoothed_dt * 0.9 + dt * 0.1
|
||||
|
||||
self._drain_input_events(camera_controller, transform_gizmo, camera_view_gizmo, picker_ray)
|
||||
self._drain_input_events(base, scene_root, camera_controller, transform_gizmo, camera_view_gizmo, picker_ray)
|
||||
base.taskMgr.step()
|
||||
self._sync_camera_snapshot(camera_controller)
|
||||
|
||||
@ -285,6 +287,8 @@ class PandaRendererThread(threading.Thread):
|
||||
|
||||
def _drain_input_events(
|
||||
self,
|
||||
base: ShowBase,
|
||||
scene_root: NodePath,
|
||||
camera_controller: CameraOrbitController,
|
||||
transform_gizmo: TransformGizmo | None,
|
||||
camera_view_gizmo: CameraViewGizmo | None,
|
||||
@ -401,6 +405,15 @@ class PandaRendererThread(threading.Thread):
|
||||
self._set_node_transform(path, field, value)
|
||||
if transform_gizmo is not None:
|
||||
self._attach_gizmo_to_selected(transform_gizmo)
|
||||
elif kind == "import_model":
|
||||
path = str(payload.get("path", ""))
|
||||
try:
|
||||
self._import_model(base, scene_root, path)
|
||||
if transform_gizmo is not None:
|
||||
self._attach_gizmo_to_selected(transform_gizmo)
|
||||
except Exception as exc:
|
||||
with self._frame_lock:
|
||||
self._last_error = f"Import failed: {exc!r}"
|
||||
elif kind == "select_path":
|
||||
path = str(payload.get("path", ""))
|
||||
self._set_selected_node_path(path)
|
||||
@ -481,6 +494,36 @@ class PandaRendererThread(threading.Thread):
|
||||
elif field == "scale":
|
||||
node.set_scale(x, y, z)
|
||||
|
||||
def _import_model(self, base: ShowBase, scene_root: NodePath, os_path: str) -> None:
|
||||
if not os_path:
|
||||
return
|
||||
panda_filename = Filename.from_os_specific(os_path)
|
||||
panda_filename.make_absolute()
|
||||
panda_path = panda_filename.get_fullpath()
|
||||
|
||||
model = base.loader.loadModel(panda_path)
|
||||
if model is None or model.is_empty():
|
||||
raise RuntimeError(f"Failed to load model: {panda_path}")
|
||||
|
||||
base_name = Path(os_path).stem or "ImportedModel"
|
||||
model_name = self._unique_child_name(scene_root, base_name)
|
||||
model.set_name(model_name)
|
||||
model.reparentTo(scene_root)
|
||||
model.set_pos(0.0, 0.0, 0.0)
|
||||
|
||||
selected_path = f"{scene_root.get_name()}/{model_name}"
|
||||
self._update_scene_snapshot(scene_root)
|
||||
self._set_selected_node_path(selected_path)
|
||||
|
||||
def _unique_child_name(self, parent: NodePath, base_name: str) -> str:
|
||||
existing_names = {child.get_name() for child in parent.get_children()}
|
||||
if base_name not in existing_names:
|
||||
return base_name
|
||||
index = 1
|
||||
while f"{base_name}_{index}" in existing_names:
|
||||
index += 1
|
||||
return f"{base_name}_{index}"
|
||||
|
||||
def _sync_camera_snapshot(self, camera_controller: CameraOrbitController) -> None:
|
||||
target_x, target_y, target_z, distance, yaw, pitch = camera_controller.camera_state_tuple()
|
||||
with self._state_lock:
|
||||
|
||||
@ -109,6 +109,7 @@ class TransformGizmo(DirectObject):
|
||||
self.move_gizmo.root.reparentTo(self.gizmo_render)
|
||||
self.rotate_gizmo.root.reparentTo(self.gizmo_render)
|
||||
self.scale_gizmo.root.reparentTo(self.gizmo_render)
|
||||
self._apply_overlay_render_state()
|
||||
|
||||
# Keep debug flag consistent across all gizmos
|
||||
self.move_gizmo.debug = self.__debug
|
||||
@ -139,6 +140,15 @@ class TransformGizmo(DirectObject):
|
||||
self.accept("mouse3", self._on_mouse2_down)
|
||||
self.accept("mouse3-up", self._on_mouse2_up)
|
||||
self.world.taskMgr.add(self._update_task, "transform_gizmo_update")
|
||||
|
||||
def _apply_overlay_render_state(self) -> None:
|
||||
for root in (self.move_gizmo.root, self.rotate_gizmo.root, self.scale_gizmo.root):
|
||||
root.setBin("fixed", 80)
|
||||
root.setDepthTest(False)
|
||||
root.setDepthWrite(False)
|
||||
root.setLightOff(1)
|
||||
root.setShaderOff(1)
|
||||
root.setTwoSided(True)
|
||||
|
||||
def _setup_overlay_rendering(self):
|
||||
"""
|
||||
|
||||
Loading…
Reference in New Issue
Block a user