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