feat(editor): 添加模型导入功能支持;refactor(gizmo): 优化变换组件渲染状态

---
添加了文件菜单中的"Import Model..."选项,支持导入多种格式的
Panda3D模型文件(.bam .egg .gltf .glb .obj .dae .fbx .stl)。
---
为Linux系统适配了文件对话框选择逻辑,在没有zenity、kdialog等
工具时回退到tkinter实现。实现了跨平台的模型导入功能,并将
导入的模型添加到场景层级中,同时选中该模型以供编辑。
---
为变换组件应用固定渲染状态,确保其始终正确显示在场景之上。
This commit is contained in:
zhaohao 2026-04-24 17:55:14 +08:00
parent 785aafb094
commit c2732e7c6b
3 changed files with 140 additions and 3 deletions

View File

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

View File

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

View File

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