From c2732e7c6b2767b87656535e15c484a4dc34841b Mon Sep 17 00:00:00 2001 From: zhaohao <13375501096@163.com> Date: Fri, 24 Apr 2026 17:55:14 +0800 Subject: [PATCH] =?UTF-8?q?feat(editor):=20=E6=B7=BB=E5=8A=A0=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E5=AF=BC=E5=85=A5=E5=8A=9F=E8=83=BD=E6=94=AF=E6=8C=81?= =?UTF-8?q?;refactor(gizmo):=20=E4=BC=98=E5=8C=96=E5=8F=98=E6=8D=A2?= =?UTF-8?q?=E7=BB=84=E4=BB=B6=E6=B8=B2=E6=9F=93=E7=8A=B6=E6=80=81=20---=20?= =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E4=BA=86=E6=96=87=E4=BB=B6=E8=8F=9C=E5=8D=95?= =?UTF-8?q?=E4=B8=AD=E7=9A=84"Import=20Model..."=E9=80=89=E9=A1=B9?= =?UTF-8?q?=EF=BC=8C=E6=94=AF=E6=8C=81=E5=AF=BC=E5=85=A5=E5=A4=9A=E7=A7=8D?= =?UTF-8?q?=E6=A0=BC=E5=BC=8F=E7=9A=84=20Panda3D=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E6=96=87=E4=BB=B6(.bam=20.egg=20.gltf=20.glb=20.obj=20.dae=20.?= =?UTF-8?q?fbx=20.stl)=E3=80=82=20---=20=E4=B8=BALinux=E7=B3=BB=E7=BB=9F?= =?UTF-8?q?=E9=80=82=E9=85=8D=E4=BA=86=E6=96=87=E4=BB=B6=E5=AF=B9=E8=AF=9D?= =?UTF-8?q?=E6=A1=86=E9=80=89=E6=8B=A9=E9=80=BB=E8=BE=91=EF=BC=8C=E5=9C=A8?= =?UTF-8?q?=E6=B2=A1=E6=9C=89zenity=E3=80=81kdialog=E7=AD=89=20=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=E6=97=B6=E5=9B=9E=E9=80=80=E5=88=B0tkinter=E5=AE=9E?= =?UTF-8?q?=E7=8E=B0=E3=80=82=E5=AE=9E=E7=8E=B0=E4=BA=86=E8=B7=A8=E5=B9=B3?= =?UTF-8?q?=E5=8F=B0=E7=9A=84=E6=A8=A1=E5=9E=8B=E5=AF=BC=E5=85=A5=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=EF=BC=8C=E5=B9=B6=E5=B0=86=20=E5=AF=BC=E5=85=A5?= =?UTF-8?q?=E7=9A=84=E6=A8=A1=E5=9E=8B=E6=B7=BB=E5=8A=A0=E5=88=B0=E5=9C=BA?= =?UTF-8?q?=E6=99=AF=E5=B1=82=E7=BA=A7=E4=B8=AD=EF=BC=8C=E5=90=8C=E6=97=B6?= =?UTF-8?q?=E9=80=89=E4=B8=AD=E8=AF=A5=E6=A8=A1=E5=9E=8B=E4=BB=A5=E4=BE=9B?= =?UTF-8?q?=E7=BC=96=E8=BE=91=E3=80=82=20---=20=E4=B8=BA=E5=8F=98=E6=8D=A2?= =?UTF-8?q?=E7=BB=84=E4=BB=B6=E5=BA=94=E7=94=A8=E5=9B=BA=E5=AE=9A=E6=B8=B2?= =?UTF-8?q?=E6=9F=93=E7=8A=B6=E6=80=81=EF=BC=8C=E7=A1=AE=E4=BF=9D=E5=85=B6?= =?UTF-8?q?=E5=A7=8B=E7=BB=88=E6=AD=A3=E7=A1=AE=E6=98=BE=E7=A4=BA=E5=9C=A8?= =?UTF-8?q?=E5=9C=BA=E6=99=AF=E4=B9=8B=E4=B8=8A=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/impanda3d/editor.py | 88 +++++++++++++++++++++++++- src/impanda3d/renderer.py | 45 ++++++++++++- src/transform_gizmo/transform_gizmo.py | 10 +++ 3 files changed, 140 insertions(+), 3 deletions(-) diff --git a/src/impanda3d/editor.py b/src/impanda3d/editor.py index 921da68..3d310ea 100644 --- a/src/impanda3d/editor.py +++ b/src/impanda3d/editor.py @@ -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: diff --git a/src/impanda3d/renderer.py b/src/impanda3d/renderer.py index 3ae5ca5..b4302b8 100644 --- a/src/impanda3d/renderer.py +++ b/src/impanda3d/renderer.py @@ -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: diff --git a/src/transform_gizmo/transform_gizmo.py b/src/transform_gizmo/transform_gizmo.py index 895bd60..a4735cc 100644 --- a/src/transform_gizmo/transform_gizmo.py +++ b/src/transform_gizmo/transform_gizmo.py @@ -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): """