diff --git a/fonts/FontAwesome7Free-Solid-900.otf b/fonts/FontAwesome7Free-Solid-900.otf new file mode 100644 index 0000000..8287a79 Binary files /dev/null and b/fonts/FontAwesome7Free-Solid-900.otf differ diff --git a/fonts/monaco.ttf b/fonts/monaco.ttf new file mode 100644 index 0000000..57217b3 Binary files /dev/null and b/fonts/monaco.ttf differ diff --git a/fonts/msyh.ttc b/fonts/msyh.ttc new file mode 100644 index 0000000..ea174b2 Binary files /dev/null and b/fonts/msyh.ttc differ diff --git a/src/camera_view_gizmo/README.md b/src/camera_view_gizmo/README.md new file mode 100644 index 0000000..afc2fba --- /dev/null +++ b/src/camera_view_gizmo/README.md @@ -0,0 +1,27 @@ +# Camera View Gizmo + +Independent Panda3D camera navigation widget. + +Features: + +- top-right overlay widget +- six axis-aligned camera views +- configurable smooth transition, default `0.2s` +- perspective / orthographic toggle button +- no dependency on ImGui or this project's transform gizmo + +Minimal usage: + +```python +from src.plugins.camera_view_gizmo import CameraViewGizmo, CameraViewGizmoConfig + +gizmo = CameraViewGizmo( + base, + camera_np=base.camera, + get_pivot=lambda: (0, 0, 0), + config=CameraViewGizmoConfig(), +) +``` + +For hosts that keep their own orbit-controller state, pass an +`on_camera_changed` callback and sync the host controller from the camera pose. diff --git a/src/camera_view_gizmo/__init__.py b/src/camera_view_gizmo/__init__.py new file mode 100644 index 0000000..a1cc4be --- /dev/null +++ b/src/camera_view_gizmo/__init__.py @@ -0,0 +1,9 @@ +from .camera_view_gizmo import CameraViewGizmo, ProjectionMode, ViewFace +from .config import CameraViewGizmoConfig + +__all__ = [ + "CameraViewGizmo", + "CameraViewGizmoConfig", + "ProjectionMode", + "ViewFace", +] diff --git a/src/camera_view_gizmo/camera_view_gizmo.py b/src/camera_view_gizmo/camera_view_gizmo.py new file mode 100644 index 0000000..3756a85 --- /dev/null +++ b/src/camera_view_gizmo/camera_view_gizmo.py @@ -0,0 +1,777 @@ +from __future__ import annotations + +import importlib.util +import math +from dataclasses import dataclass +from typing import Callable, Dict, Optional + +import panda3d.core as p3d +from direct.showbase.DirectObject import DirectObject +from direct.showbase.ShowBase import ShowBase +from direct.showbase.ShowBaseGlobal import globalClock +from direct.task import Task + +from .config import CameraViewGizmoConfig + +HAS_IMGUI = False +if importlib.util.find_spec("imgui_bundle"): + from imgui_bundle import imgui + HAS_IMGUI = True + + +class ViewFace: + POS_X = "+x" + NEG_X = "-x" + POS_Y = "+y" + NEG_Y = "-y" + POS_Z = "+z" + NEG_Z = "-z" + + ALL = (POS_X, NEG_X, POS_Y, NEG_Y, POS_Z, NEG_Z) + + +class ProjectionMode: + PERSPECTIVE = "perspective" + ORTHOGRAPHIC = "orthographic" + + +@dataclass(slots=True) +class _EndpointVisual: + face: str + axis: p3d.Vec3 + color: p3d.Vec4 + label: str + root: p3d.NodePath + card: p3d.NodePath + text: p3d.NodePath + + +@dataclass(slots=True) +class _TransitionState: + start_pos: p3d.Point3 + end_pos: p3d.Point3 + start_pivot: p3d.Point3 + end_pivot: p3d.Point3 + start_up: p3d.Vec3 + end_up: p3d.Vec3 + duration: float + elapsed: float = 0.0 + + +class CameraViewGizmo(DirectObject): + """ + Independent Panda3D camera navigation widget. + + It renders a small overlay in the top-right corner, mirrors the main + camera orientation, supports six axis-aligned views, and can toggle the + main camera lens between perspective and orthographic projection. + """ + + FACE_AXIS: Dict[str, p3d.Vec3] = { + ViewFace.POS_X: p3d.Vec3(1, 0, 0), + ViewFace.NEG_X: p3d.Vec3(-1, 0, 0), + ViewFace.POS_Y: p3d.Vec3(0, 1, 0), + ViewFace.NEG_Y: p3d.Vec3(0, -1, 0), + ViewFace.POS_Z: p3d.Vec3(0, 0, 1), + ViewFace.NEG_Z: p3d.Vec3(0, 0, -1), + } + + FACE_LABEL: Dict[str, str] = { + ViewFace.POS_X: "+X", + ViewFace.NEG_X: "-X", + ViewFace.POS_Y: "+Y", + ViewFace.NEG_Y: "-Y", + ViewFace.POS_Z: "+Z", + ViewFace.NEG_Z: "-Z", + } + + OPPOSITE_FACE: Dict[str, str] = { + ViewFace.POS_X: ViewFace.NEG_X, + ViewFace.NEG_X: ViewFace.POS_X, + ViewFace.POS_Y: ViewFace.NEG_Y, + ViewFace.NEG_Y: ViewFace.POS_Y, + ViewFace.POS_Z: ViewFace.NEG_Z, + ViewFace.NEG_Z: ViewFace.POS_Z, + } + + def __init__( + self, + base: ShowBase, + camera_np: Optional[p3d.NodePath] = None, + lens: Optional[p3d.Lens] = None, + *, + window: Optional[p3d.GraphicsOutput] = None, + config: Optional[CameraViewGizmoConfig] = None, + get_pivot: Optional[Callable[[], p3d.LPoint3f | p3d.LPoint3d | p3d.LVecBase3f | tuple[float, float, float]]] = None, + on_camera_changed: Optional[Callable[["CameraViewGizmo"], None]] = None, + interaction_blocker: Optional[Callable[[], bool]] = None, + register_events: bool = True, + ) -> None: + super().__init__() + self.base = base + self.camera_np = camera_np or getattr(base, "camera", None) or getattr(base, "cam", None) + if self.camera_np is None: + raise RuntimeError("CameraViewGizmo requires a valid Panda3D camera transform NodePath") + self.camera_lens_np = self._resolve_camera_lens_np(self.camera_np) or getattr(base, "cam", None) + if self.camera_lens_np is None or self.camera_lens_np.isEmpty(): + raise RuntimeError("CameraViewGizmo could not resolve a Panda3D Camera node with lens") + self.camera_node: p3d.Camera = self.camera_lens_np.node() + self.render = base.render + self.window = window or base.win + self.config = config or CameraViewGizmoConfig() + self.get_pivot = get_pivot or (lambda: p3d.Point3(0, 0, 0)) + self.on_camera_changed = on_camera_changed + self.interaction_blocker = interaction_blocker + self._events_registered = bool(register_events) + self._projection_icon_hovered = False + self._hover_face: str | None = None + self._pointer_inside_widget = False + self._pointer_pressed_on_widget = False + self._pending_pointer_xy: tuple[float, float] | None = None + self._last_region_rect: tuple[float, float, float, float] | None = None + self._transition: _TransitionState | None = None + self._stored_perspective_lens: p3d.Lens | None = None + self._lens = lens or self.camera_node.getLens() + self._projection_mode = self._detect_projection_mode(self._lens) + + self._widget_root = p3d.NodePath("camera_view_gizmo_render") + self._model_root = self._widget_root.attachNewNode("camera_view_gizmo_root") + self._ui_root = self._widget_root.attachNewNode("camera_view_gizmo_ui") + self._overlay_camera_np: p3d.NodePath | None = None + self._overlay_dr: p3d.DisplayRegion | None = None + self._axis_line_nodes: dict[str, p3d.NodePath] = {} + self._endpoints: dict[str, _EndpointVisual] = {} + self._center_perspective_np: p3d.NodePath | None = None + self._center_orthographic_np: p3d.NodePath | None = None + + self._setup_overlay() + self._build_visuals() + if self._events_registered: + self._register_events() + + self.base.taskMgr.add(self._update_task, "camera-view-gizmo-update") + + def _resolve_camera_lens_np(self, node_path: p3d.NodePath | None) -> p3d.NodePath | None: + if node_path is None or node_path.isEmpty(): + return None + try: + if hasattr(node_path.node(), "getLens"): + return node_path + except Exception: + pass + try: + candidate = node_path.find("**/+Camera") + if candidate is not None and not candidate.isEmpty(): + return candidate + except Exception: + pass + return None + + @property + def is_hovering(self) -> bool: + return self._pointer_inside_widget + + @property + def captures_mouse(self) -> bool: + return self._pointer_inside_widget or self._pointer_pressed_on_widget + + @property + def projection_mode(self) -> str: + return self._projection_mode + + def destroy(self) -> None: + self.ignoreAll() + self.base.taskMgr.remove("camera-view-gizmo-update") + if self._overlay_dr is not None and self.window is not None: + self.window.removeDisplayRegion(self._overlay_dr) + self._overlay_dr = None + if self._widget_root is not None: + self._widget_root.removeNode() + self._widget_root = None + + def _is_interaction_blocked(self) -> bool: + blocker = self.interaction_blocker + if blocker is not None: + try: + return bool(blocker()) + except Exception: + pass + if HAS_IMGUI: + try: + io = imgui.get_io() + if io and io.want_capture_mouse: + return True + except Exception: + pass + return False + + def snap_to_face(self, face: str, duration: float | None = None) -> None: + face = str(face).lower() + if face not in self.FACE_AXIS: + raise ValueError(f"Unsupported view face: {face}") + + pivot = self._get_pivot_point() + axis = p3d.Vec3(self.FACE_AXIS[face]) + current_pos = self.camera_np.getPos(self.render) + distance = max((current_pos - pivot).length(), 0.5) + end_pos = pivot + axis * distance + start_up = self.camera_np.getQuat(self.render).getUp() + end_up = self._up_vector_for_face(face) + + actual_duration = self.config.transition_duration if duration is None else max(0.0, float(duration)) + if actual_duration <= 1e-4: + self._transition = None + self._apply_camera_pose(end_pos, pivot, end_up) + return + + self._transition = _TransitionState( + start_pos=p3d.Point3(current_pos), + end_pos=p3d.Point3(end_pos), + start_pivot=p3d.Point3(pivot), + end_pivot=p3d.Point3(pivot), + start_up=p3d.Vec3(start_up), + end_up=p3d.Vec3(end_up), + duration=actual_duration, + ) + + def toggle_projection(self) -> None: + if self._projection_mode == ProjectionMode.PERSPECTIVE: + self.set_projection_mode(ProjectionMode.ORTHOGRAPHIC) + else: + self.set_projection_mode(ProjectionMode.PERSPECTIVE) + + def set_projection_mode(self, mode: str) -> None: + mode = str(mode).lower() + if mode == self._projection_mode: + return + if mode == ProjectionMode.ORTHOGRAPHIC: + self._switch_to_orthographic() + elif mode == ProjectionMode.PERSPECTIVE: + self._switch_to_perspective() + else: + raise ValueError(f"Unsupported projection mode: {mode}") + self._projection_mode = mode + self._notify_camera_changed() + + def _setup_overlay(self) -> None: + if self.window is None: + raise RuntimeError("CameraViewGizmo requires an active Panda3D graphics output") + + self._overlay_dr = self.window.makeDisplayRegion() + self._overlay_dr.setSort(self.config.overlay_sort) + self._overlay_dr.setClearColorActive(False) + self._overlay_dr.setClearDepthActive(True) + + cam = p3d.Camera("camera_view_gizmo_cam") + cam_lens = p3d.OrthographicLens() + cam_lens.setFilmSize(self.config.widget_film_size, self.config.widget_film_size) + cam_lens.setNearFar(-100.0, 100.0) + cam.setLens(cam_lens) + cam.setScene(self._widget_root) + self._overlay_camera_np = self._widget_root.attachNewNode(cam) + self._overlay_camera_np.setPos(0, -12, 0) + self._overlay_camera_np.lookAt(0, 0, 0) + self._overlay_dr.setCamera(self._overlay_camera_np) + + def _build_visuals(self) -> None: + self._build_axes() + self._build_center_projection_toggle() + + def _build_axes(self) -> None: + axis_specs = [ + ("x", p3d.Vec3(1, 0, 0), self.config.x_axis_rgba), + ("y", p3d.Vec3(0, 1, 0), self.config.y_axis_rgba), + ("z", p3d.Vec3(0, 0, 1), self.config.z_axis_rgba), + ] + for axis_name, axis_vec, rgba in axis_specs: + self._axis_line_nodes[axis_name] = self._create_axis_line(axis_name, axis_vec, rgba) + + for face in ViewFace.ALL: + axis = self.FACE_AXIS[face] + positive = face.startswith("+") + base_rgba = self._color_for_axis(axis) + if not positive: + base_rgba = self._lighten_rgba(base_rgba, self.config.negative_axis_rgba_scale) + endpoint = self._create_endpoint(face, axis, base_rgba, self.FACE_LABEL[face]) + self._endpoints[face] = endpoint + + def _build_center_projection_toggle(self) -> None: + self._center_perspective_np = self._create_center_circle_icon() + self._center_orthographic_np = self._create_center_cube_icon() + self._ui_root.setPos(0.0, 0.0, self._projection_icon_center_z()) + + def _create_center_circle_icon(self) -> p3d.NodePath: + root = self._ui_root.attachNewNode("camera_view_gizmo_center_circle") + radius = self.config.center_icon_size + + ring = p3d.LineSegs("camera_view_gizmo_center_ring") + ring.setThickness(self.config.center_icon_line_thickness) + ring.setColor(*self.config.center_icon_rgba) + segments = 28 + for index in range(segments + 1): + angle = (math.tau * index) / segments + x = math.cos(angle) * radius + z = math.sin(angle) * radius + if index == 0: + ring.moveTo(x, 0.0, z) + else: + ring.drawTo(x, 0.0, z) + ring_np = root.attachNewNode(ring.create()) + ring_np.setDepthWrite(False) + ring_np.setDepthTest(False) + ring_np.setTransparency(p3d.TransparencyAttrib.MAlpha) + ring_np.setBin("fixed", 7) + return root + + def _create_center_cube_icon(self) -> p3d.NodePath: + root = self._ui_root.attachNewNode("camera_view_gizmo_center_cube") + cube = p3d.LineSegs("camera_view_gizmo_center_cube_lines") + cube.setThickness(self.config.center_icon_line_thickness) + cube.setColor(*self.config.center_icon_rgba) + + size = self.config.center_icon_size * 0.78 + corners = [ + p3d.Point3(-size, -size, -size), + p3d.Point3(size, -size, -size), + p3d.Point3(size, size, -size), + p3d.Point3(-size, size, -size), + p3d.Point3(-size, -size, size), + p3d.Point3(size, -size, size), + p3d.Point3(size, size, size), + p3d.Point3(-size, size, size), + ] + edges = [ + (0, 1), (1, 2), (2, 3), (3, 0), + (4, 5), (5, 6), (6, 7), (7, 4), + (0, 4), (1, 5), (2, 6), (3, 7), + ] + for start_idx, end_idx in edges: + cube.moveTo(corners[start_idx]) + cube.drawTo(corners[end_idx]) + cube_np = root.attachNewNode(cube.create()) + cube_np.setDepthWrite(False) + cube_np.setDepthTest(False) + cube_np.setTransparency(p3d.TransparencyAttrib.MAlpha) + cube_np.setBin("fixed", 7) + root.setHpr(45.0, -26.0, 0.0) + return root + + def _create_axis_line(self, axis_name: str, axis_vec: p3d.Vec3, rgba: tuple[float, float, float, float]) -> p3d.NodePath: + segs = p3d.LineSegs(f"camera_view_gizmo_axis_{axis_name}") + segs.setThickness(self.config.axis_thickness) + length = self.config.axis_length + segs.setColor(rgba[0], rgba[1], rgba[2], self.config.axis_line_alpha_back) + segs.moveTo(*(axis_vec * -length)) + segs.drawTo(0, 0, 0) + segs.setColor(rgba[0], rgba[1], rgba[2], self.config.axis_line_alpha_front) + segs.moveTo(0, 0, 0) + segs.drawTo(*(axis_vec * length)) + axis_np = self._model_root.attachNewNode(segs.create()) + axis_np.setTransparency(p3d.TransparencyAttrib.MAlpha) + axis_np.setDepthWrite(False) + axis_np.setBin("fixed", 2) + return axis_np + + def _create_endpoint( + self, + face: str, + axis: p3d.Vec3, + rgba: p3d.Vec4, + label: str, + ) -> _EndpointVisual: + root = self._model_root.attachNewNode(f"camera_view_gizmo_endpoint_{face}") + root.setPos(*(axis * self.config.axis_length)) + + card_maker = p3d.CardMaker(f"camera_view_gizmo_endpoint_card_{face}") + radius = self.config.endpoint_radius * self.config.endpoint_size_scale + card_maker.setFrame(-radius, radius, -radius, radius) + card = root.attachNewNode(card_maker.generate()) + card.setBillboardPointEye() + card.setTransparency(p3d.TransparencyAttrib.MAlpha) + card.setDepthWrite(False) + card.setBin("fixed", 3) + card.setColor(rgba) + + text_node = p3d.TextNode(f"camera_view_gizmo_endpoint_label_{face}") + text_node.setAlign(p3d.TextNode.ACenter) + text_node.setText(label) + text_node.setTextColor(*self.config.endpoint_text_rgba) + if self.config.endpoint_font: + font = self.base.loader.loadFont(self.config.endpoint_font) + if font: + text_node.setFont(font) + text = root.attachNewNode(text_node) + text.setBillboardPointEye() + label_scale = self.config.endpoint_label_scale * self.config.endpoint_size_scale + text.setPos(0, -0.01, -(label_scale * 0.38)) + text.setScale(label_scale) + text.setDepthWrite(False) + text.setDepthTest(False) + text.setBin("fixed", 4) + + return _EndpointVisual( + face=face, + axis=p3d.Vec3(axis), + color=p3d.Vec4(rgba), + label=label, + root=root, + card=card, + text=text, + ) + + def _register_events(self) -> None: + self.accept("mouse1", self._on_mouse1_down) + self.accept("mouse1-up", self._on_mouse1_up) + self.accept("mouse-move", self._on_mouse_move) + + def _on_mouse1_down(self, evt=None) -> None: + if self._is_interaction_blocked(): + self._pointer_pressed_on_widget = False + return + pointer = self._extract_pointer_xy(evt) + if pointer is None: + self._pointer_pressed_on_widget = False + return + self._pending_pointer_xy = pointer + self._pointer_pressed_on_widget = self._is_pointer_in_region(pointer) + if not self._pointer_pressed_on_widget: + return + + local = self._pointer_to_widget_local(pointer) + if local is None: + return + + if self._is_projection_toggle_hit(local): + self.toggle_projection() + return + + face = self._pick_face(local) + if face: + face = self._resolve_face_click_target(face) + self.snap_to_face(face) + + def handle_mouse1_down(self, x: float, y: float) -> None: + self._on_mouse1_down({"x": x, "y": y}) + + def handle_mouse1_up(self, x: float, y: float) -> None: + self._on_mouse1_up({"x": x, "y": y}) + + def handle_mouse_move(self, x: float, y: float) -> None: + self._on_mouse_move({"x": x, "y": y}) + + def _on_mouse1_up(self, evt=None) -> None: + pointer = self._extract_pointer_xy(evt) + if pointer is not None: + self._pending_pointer_xy = pointer + self._pointer_pressed_on_widget = False + + def _on_mouse_move(self, evt=None) -> None: + if self._is_interaction_blocked(): + self._pointer_inside_widget = False + self._projection_icon_hovered = False + self._hover_face = None + return + pointer = self._extract_pointer_xy(evt) + if pointer is not None: + self._pending_pointer_xy = pointer + + def _update_task(self, task: Task): + self._update_display_region() + self._update_hover_state() + self._sync_widget_orientation() + self._advance_transition(globalClock.getDt()) + self._update_visual_state() + return task.cont + + def _update_display_region(self) -> None: + if self.window is None or self._overlay_dr is None: + return + win_x = max(1, self.window.getXSize()) + win_y = max(1, self.window.getYSize()) + size_px = float(min(self.config.size_px, win_x, win_y)) + margin = float(self.config.margin_px) + left = max(0.0, win_x - size_px - margin) + right = min(float(win_x), win_x - margin) + top = max(0.0, margin) + bottom = min(float(win_y), margin + size_px) + if right <= left or bottom <= top: + return + rect = (left, top, right, bottom) + if rect == self._last_region_rect: + return + self._last_region_rect = rect + self._overlay_dr.setDimensions( + left / win_x, + right / win_x, + max(0.0, (win_y - bottom) / win_y), + min(1.0, (win_y - top) / win_y), + ) + + def _update_hover_state(self) -> None: + if self._is_interaction_blocked(): + self._pointer_inside_widget = False + self._hover_face = None + self._projection_icon_hovered = False + self._pointer_pressed_on_widget = False + return + pointer = self._get_pointer_xy() + if pointer is None or not self._is_pointer_in_region(pointer): + self._pointer_inside_widget = False + self._hover_face = None + self._projection_icon_hovered = False + return + self._pointer_inside_widget = True + local = self._pointer_to_widget_local(pointer) + self._projection_icon_hovered = self._is_projection_toggle_hit(local) if local is not None else False + self._hover_face = None if self._projection_icon_hovered else self._pick_face(local) + + def _sync_widget_orientation(self) -> None: + quat = p3d.Quat(self.camera_np.getQuat(self.render)) + quat.invertInPlace() + self._model_root.setQuat(quat) + + def _advance_transition(self, dt: float) -> None: + if self._transition is None: + return + self._transition.elapsed = min(self._transition.elapsed + max(0.0, float(dt)), self._transition.duration) + t = self._transition.elapsed / self._transition.duration + t = t * t * (3.0 - 2.0 * t) + + pos = self._lerp_point(self._transition.start_pos, self._transition.end_pos, t) + pivot = self._lerp_point(self._transition.start_pivot, self._transition.end_pivot, t) + up = self._lerp_vec(self._transition.start_up, self._transition.end_up, t) + self._apply_camera_pose(pos, pivot, up) + if self._transition.elapsed >= self._transition.duration: + self._transition = None + + def _update_visual_state(self) -> None: + center_color = self.config.center_icon_hover_rgba if self._projection_icon_hovered else self.config.center_icon_rgba + if self._center_perspective_np is not None: + if self._projection_mode == ProjectionMode.PERSPECTIVE: + self._center_perspective_np.show() + else: + self._center_perspective_np.hide() + self._center_perspective_np.setColor(*center_color) + if self._center_orthographic_np is not None: + if self._projection_mode == ProjectionMode.ORTHOGRAPHIC: + self._center_orthographic_np.show() + else: + self._center_orthographic_np.hide() + self._center_orthographic_np.setColor(*center_color) + + for face, endpoint in self._endpoints.items(): + local_pos = self._model_root.getQuat().xform(endpoint.axis * self.config.axis_length) + depth = max(-1.0, min(1.0, float(local_pos.y / max(1e-6, self.config.axis_length)))) + frontness = max(0.0, (-depth + 1.0) * 0.5) + front_radius = self.config.endpoint_radius * self.config.endpoint_size_scale + back_radius = self.config.endpoint_radius_back * self.config.endpoint_size_scale + radius = back_radius + (front_radius - back_radius) * frontness + if face == self._hover_face: + radius *= self.config.endpoint_hover_scale + endpoint.card.setScale(radius / max(front_radius, 1e-6)) + alpha = 0.58 + 0.42 * frontness + endpoint.card.setColor(endpoint.color.x, endpoint.color.y, endpoint.color.z, alpha) + endpoint.card.setY(-0.06 * depth) + endpoint.text.setSa(0.72 + 0.28 * frontness) + + for axis_name, axis_np in self._axis_line_nodes.items(): + axis_vec = { + "x": p3d.Vec3(1, 0, 0), + "y": p3d.Vec3(0, 1, 0), + "z": p3d.Vec3(0, 0, 1), + }[axis_name] + local_axis = self._model_root.getQuat().xform(axis_vec) + axis_np.setY(0.03 * local_axis.y) + + def _detect_projection_mode(self, lens: p3d.Lens) -> str: + return ProjectionMode.PERSPECTIVE if lens.isPerspective() else ProjectionMode.ORTHOGRAPHIC + + def _switch_to_orthographic(self) -> None: + current_lens = self.camera_node.getLens() + self._stored_perspective_lens = current_lens.makeCopy() + + ortho = p3d.OrthographicLens() + aspect = self._safe_aspect_ratio() + pivot = self._get_pivot_point() + cam_pos = self.camera_np.getPos(self.render) + distance = max((cam_pos - pivot).length(), 0.5) + fov_y = float(current_lens.getFov()[1]) + ortho_height = max(0.1, 2.0 * distance * math.tan(math.radians(fov_y) * 0.5)) + ortho.setFilmSize(max(0.1, ortho_height * aspect), ortho_height) + try: + ortho.setNearFar(current_lens.getNear(), current_lens.getFar()) + except Exception: + ortho.setNearFar(-1000.0, 1000.0) + self.camera_node.setLens(ortho) + + def _switch_to_perspective(self) -> None: + perspective = self._stored_perspective_lens.makeCopy() if self._stored_perspective_lens else p3d.PerspectiveLens() + self.camera_node.setLens(perspective) + + def _apply_camera_pose(self, pos: p3d.Point3, pivot: p3d.Point3, up: p3d.Vec3) -> None: + self.camera_np.setPos(self.render, pos) + safe_up = p3d.Vec3(up) + if safe_up.lengthSquared() <= 1e-8: + safe_up = p3d.Vec3(0, 0, 1) + safe_up.normalize() + self.camera_np.lookAt(pivot, safe_up) + self._notify_camera_changed() + + def _notify_camera_changed(self) -> None: + if self.on_camera_changed is not None: + self.on_camera_changed(self) + + def _get_pointer_xy(self) -> tuple[float, float] | None: + if self._pending_pointer_xy is not None: + return self._pending_pointer_xy + win = self.window + if win is not None and hasattr(win, "getPointer"): + pointer = win.getPointer(0) + return float(pointer.getX()), float(pointer.getY()) + return self._pending_pointer_xy + + def _extract_pointer_xy(self, evt=None) -> tuple[float, float] | None: + if isinstance(evt, dict) and "x" in evt and "y" in evt: + return float(evt["x"]), float(evt["y"]) + return self._get_pointer_xy() + + def _is_pointer_in_region(self, pointer: tuple[float, float] | None) -> bool: + if pointer is None or self._last_region_rect is None: + return False + x, y = pointer + left, top, right, bottom = self._last_region_rect + return left <= x <= right and top <= y <= bottom + + def _pointer_to_widget_local(self, pointer: tuple[float, float] | None) -> tuple[float, float] | None: + if pointer is None or self._last_region_rect is None: + return None + x, y = pointer + left, top, right, bottom = self._last_region_rect + width = max(1e-6, right - left) + height = max(1e-6, bottom - top) + nx = ((x - left) / width) * 2.0 - 1.0 + ny = (1.0 - ((y - top) / height)) * 2.0 - 1.0 + half_film = self.config.widget_film_size * 0.5 + return nx * half_film, ny * half_film + + def _pick_face(self, local_xy: tuple[float, float] | None) -> str | None: + if local_xy is None: + return None + x, z = local_xy + best_face = None + best_dist_sq = None + for face, endpoint in self._endpoints.items(): + pos = self._model_root.getQuat().xform(endpoint.axis * self.config.axis_length) + radius = ( + self.config.endpoint_radius + if face.startswith("+") + else self.config.endpoint_radius_back + ) * self.config.endpoint_size_scale + dx = x - pos.x + dz = z - pos.z + dist_sq = dx * dx + dz * dz + if dist_sq > (radius * radius): + continue + if best_face is None or dist_sq < best_dist_sq: + best_face = face + best_dist_sq = dist_sq + return best_face + + def _is_projection_toggle_hit(self, local_xy: tuple[float, float] | None) -> bool: + if local_xy is None: + return False + x, z = local_xy + z -= self._projection_icon_center_z() + radius = self.config.center_icon_hit_radius + return (x * x + z * z) <= (radius * radius) + + def _resolve_face_click_target(self, face: str) -> str: + normalized_face = str(face).lower() + if normalized_face not in self.FACE_AXIS: + return normalized_face + if self._is_camera_aligned_to_face(normalized_face): + return self.OPPOSITE_FACE.get(normalized_face, normalized_face) + return normalized_face + + def _is_camera_aligned_to_face(self, face: str, tolerance_deg: float = 8.0) -> bool: + pivot = self._get_pivot_point() + cam_pos = self.camera_np.getPos(self.render) + view_vec = p3d.Vec3(cam_pos - pivot) + if view_vec.lengthSquared() <= 1e-8: + return False + view_vec.normalize() + + target_axis = p3d.Vec3(self.FACE_AXIS[face]) + if target_axis.lengthSquared() <= 1e-8: + return False + target_axis.normalize() + + dot = max(-1.0, min(1.0, float(view_vec.dot(target_axis)))) + tolerance_cos = math.cos(math.radians(max(0.0, float(tolerance_deg)))) + return dot >= tolerance_cos + + def _get_pivot_point(self) -> p3d.Point3: + value = self.get_pivot() + if isinstance(value, p3d.NodePath): + return p3d.Point3(value.getPos(self.render)) + if isinstance(value, p3d.LVecBase3): + return p3d.Point3(value) + return p3d.Point3(*value) + + def _up_vector_for_face(self, face: str) -> p3d.Vec3: + if face in {ViewFace.POS_Z, ViewFace.NEG_Z}: + return p3d.Vec3(*self.config.top_bottom_up_axis) + return p3d.Vec3(*self.config.horizontal_up_axis) + + def _safe_aspect_ratio(self) -> float: + win = self.window + if win is None: + return 1.0 + x = max(1, win.getXSize()) + y = max(1, win.getYSize()) + return float(x) / float(y) + + def _color_for_axis(self, axis: p3d.Vec3) -> p3d.Vec4: + if abs(axis.x) > 0.5: + return p3d.Vec4(*self.config.x_axis_rgba) + if abs(axis.y) > 0.5: + return p3d.Vec4(*self.config.y_axis_rgba) + return p3d.Vec4(*self.config.z_axis_rgba) + + def _projection_icon_center_z(self) -> float: + endpoint_extent = self.config.endpoint_radius * self.config.endpoint_size_scale + return -( + self.config.axis_length + + endpoint_extent + + self.config.projection_icon_gap + + self.config.center_icon_hit_radius + ) + + @staticmethod + def _lighten_rgba(color: p3d.Vec4, factor: float) -> p3d.Vec4: + t = max(0.0, min(1.0, float(factor))) + return p3d.Vec4( + color.x + (1.0 - color.x) * t, + color.y + (1.0 - color.y) * t, + color.z + (1.0 - color.z) * t, + color.w, + ) + + @staticmethod + def _lerp_point(a: p3d.Point3, b: p3d.Point3, t: float) -> p3d.Point3: + return p3d.Point3( + a.x + (b.x - a.x) * t, + a.y + (b.y - a.y) * t, + a.z + (b.z - a.z) * t, + ) + + @staticmethod + def _lerp_vec(a: p3d.Vec3, b: p3d.Vec3, t: float) -> p3d.Vec3: + value = p3d.Vec3( + a.x + (b.x - a.x) * t, + a.y + (b.y - a.y) * t, + a.z + (b.z - a.z) * t, + ) + if value.lengthSquared() > 1e-8: + value.normalize() + return value diff --git a/src/camera_view_gizmo/config.py b/src/camera_view_gizmo/config.py new file mode 100644 index 0000000..7273bc7 --- /dev/null +++ b/src/camera_view_gizmo/config.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(slots=True) +class CameraViewGizmoConfig: + # 组件在屏幕中的像素尺寸(正方形边长) + size_px: int = 148 + # 组件距离窗口右上角的外边距 + margin_px: int = 8 + # 点击六视图后相机过渡动画时长(秒) + transition_duration: float = 0.2 + # 三轴从中心延伸到端点的长度 + axis_length: float = 1.0 + # 六方向小方块整体缩放倍数 + endpoint_size_scale: float = 1.5 + # 正方向端点方块的基础半径 + endpoint_radius: float = 0.22 + # 反方向端点方块的基础半径 + endpoint_radius_back: float = 0.16 + # 鼠标悬停在端点上时的放大倍率 + endpoint_hover_scale: float = 1.25 + # 端点文字标签的基础缩放 + endpoint_label_scale: float = 0.18 + # 三轴线条的粗细 + axis_thickness: float = 3.0 + # Overlay 正交镜头的视口尺寸,影响整体构图比例 + widget_film_size: float = 4.6 + # 底部投影切换图标的尺寸 + center_icon_size: float = 0.28 + # 底部投影切换图标的点击命中半径 + center_icon_hit_radius: float = 0.36 + # 底部投影切换图标线条粗细 + center_icon_line_thickness: float = 2.2 + # 底部投影切换图标与六方向视图之间的间距 + projection_icon_gap: float = 0.24 + # Overlay DisplayRegion 的渲染排序值,越大越后绘制 + overlay_sort: int = 8 + # X 轴正方向的颜色 + x_axis_rgba: tuple[float, float, float, float] = (0.93, 0.34, 0.29, 1.0) + # Y 轴正方向的颜色 + y_axis_rgba: tuple[float, float, float, float] = (0.22, 0.74, 0.34, 1.0) + # Z 轴正方向的颜色 + z_axis_rgba: tuple[float, float, float, float] = (0.31, 0.57, 0.95, 1.0) + # 反方向端点颜色向白色提亮的比例 + negative_axis_rgba_scale: float = 0.46 + # 朝向屏幕前方的轴线透明度 + axis_line_alpha_front: float = 0.95 + # 朝向屏幕后方的轴线透明度 + axis_line_alpha_back: float = 0.42 + # 透视/正交切换图标的默认颜色 + center_icon_rgba: tuple[float, float, float, float] = (0.92, 0.93, 0.96, 0.95) + # 透视/正交切换图标悬停时的高亮颜色 + center_icon_hover_rgba: tuple[float, float, float, float] = (1.0, 0.95, 0.64, 1.0) + # 六方向端点文字的颜色 + endpoint_text_rgba: tuple[float, float, float, float] = (1.0, 1.0, 1.0, 1.0) + # 六方向端点文字使用的字体路径,None 表示使用默认字体 + endpoint_font: str | None = None + # 顶视图/底视图时相机的朝上方向 + top_bottom_up_axis: tuple[float, float, float] = (0.0, 1.0, 0.0) + # 侧视图/前后视图时相机的朝上方向 + horizontal_up_axis: tuple[float, float, float] = (0.0, 0.0, 1.0) diff --git a/src/camera_view_gizmo/demo_basic.py b/src/camera_view_gizmo/demo_basic.py new file mode 100644 index 0000000..40d21c1 --- /dev/null +++ b/src/camera_view_gizmo/demo_basic.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from direct.showbase.ShowBase import ShowBase +import panda3d.core as p3d + +from src.plugins.camera_view_gizmo import CameraViewGizmo, CameraViewGizmoConfig + + +class DemoApp(ShowBase): + def __init__(self) -> None: + super().__init__() + self.disableMouse() + + self.target = p3d.Point3(0, 0, 0) + + model = self.loader.loadModel("models/environment") + model.reparentTo(self.render) + model.setScale(0.05) + model.setPos(-8, 42, 0) + + self.camera.setPos(14, -18, 10) + self.camera.lookAt(self.target) + + self.gizmo = CameraViewGizmo( + self, + camera_np=self.camera, + get_pivot=lambda: self.target, + config=CameraViewGizmoConfig(), + ) + + +if __name__ == "__main__": + app = DemoApp() + app.run() diff --git a/src/impanda3d/editor.py b/src/impanda3d/editor.py index 7d5900b..921da68 100644 --- a/src/impanda3d/editor.py +++ b/src/impanda3d/editor.py @@ -1,5 +1,7 @@ from __future__ import annotations +from pathlib import Path + from imgui_bundle import hello_imgui, imgui, immapp, immvision # type: ignore from .config import INI_FILENAME, WINDOW_TITLE @@ -13,9 +15,12 @@ class EditorApp: self.selected_node_path = "SceneRoot/PreviewRoot/Teapot" self.renderer.select_node_path(self.selected_node_path) + self._hierarchy_auto_expand_paths = self._ancestor_paths(self.selected_node_path) self.viewport_texture: immvision.GlTexture | None = None self.viewport_frame_version = -1 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.render_scale = 1.0 self._viewport_mouse_buttons = {"lmb": False, "mmb": False, "rmb": False} self._viewport_keys = { @@ -28,6 +33,17 @@ class EditorApp: "r": False, "space": False, } + self._console_entries = [ + ("Info", "Editor scene loaded", "Scene"), + ("Info", "编辑器启动完成,中文字体显示测试:控制台中文日志已启用", "Console"), + ("Info", "Panda3D offscreen viewport connected to ImGui", "Renderer"), + ("Info", "simplepbr preview pipeline active", "Renderer"), + ("Warning", "Viewport selection uses render picking; very small objects may need zooming in", "Picker"), + ] + self._console_show_info = True + self._console_show_warnings = True + self._console_show_errors = True + self._console_collapse = False def build_runner_params(self) -> tuple[hello_imgui.RunnerParams, immapp.AddOnsParams]: runner = hello_imgui.RunnerParams() @@ -38,19 +54,43 @@ class EditorApp: runner.imgui_window_params.menu_app_title = "RobotMetaCore" runner.imgui_window_params.show_menu_bar = True - runner.imgui_window_params.show_status_bar = True + runner.imgui_window_params.show_status_bar = False + runner.imgui_window_params.show_status_fps = False runner.imgui_window_params.default_imgui_window_type = ( hello_imgui.DefaultImGuiWindowType.provide_full_screen_dock_space ) - runner.callbacks.show_status = self._show_status_bar runner.callbacks.before_exit = self._shutdown + runner.callbacks.load_additional_fonts = self._load_fonts runner.docking_params = self._build_docking_layout() runner.docking_params.layout_condition = hello_imgui.DockingLayoutCondition.application_start addons = immapp.AddOnsParams() return runner, addons + def _load_fonts(self) -> None: + fonts_dir = Path(__file__).resolve().parents[2] / "fonts" + font_size = 15.0 + + monaco_params = hello_imgui.FontLoadingParams() + monaco_params.inside_assets = False + self._default_font = hello_imgui.load_font( + str(fonts_dir / "monaco.ttf"), + font_size, + monaco_params, + ) + + msyh_params = hello_imgui.FontLoadingParams() + msyh_params.inside_assets = False + msyh_params.merge_to_last_font = True + hello_imgui.load_font( + str(fonts_dir / "msyh.ttc"), + font_size, + msyh_params, + ) + + imgui.get_io().font_default = self._default_font + def _build_docking_layout(self) -> hello_imgui.DockingParams: layout = hello_imgui.DockingParams() @@ -90,9 +130,9 @@ class EditorApp: gui_function_=self.show_inspector, ), hello_imgui.DockableWindow( - label_="Stats", + label_="Console", dock_space_name_="BottomSpace", - gui_function_=self.show_stats, + gui_function_=self.show_console, ), ] return layout @@ -171,6 +211,9 @@ class EditorApp: if hovered and io.mouse_wheel != 0.0: self.renderer.queue_input_event("wheel", delta=io.mouse_wheel) + if hovered and ctrl_down and imgui.is_key_pressed(imgui.Key.z): + self.renderer.queue_input_event("undo_transform") + if hovered or self._viewport_mouse_buttons["rmb"]: key_states = { "w": imgui.is_key_down(imgui.Key.w), @@ -241,8 +284,10 @@ class EditorApp: self._handle_viewport_input(hovered, rect_min) renderer_selected_path = self.renderer.selected_node_path() - if renderer_selected_path: + if renderer_selected_path and renderer_selected_path != self.selected_node_path: self.selected_node_path = renderer_selected_path + self._inspector_transform_edits.clear() + self._hierarchy_auto_expand_paths = self._ancestor_paths(renderer_selected_path) # if hovered: # imgui.set_tooltip("Alt+LMB orbit\nMMB pan\nAlt+RMB or Wheel dolly\nRMB look + WASD/QE fly\nF focus") else: @@ -260,65 +305,217 @@ class EditorApp: for index, node in enumerate(nodes): self._draw_node_tree(node, f"root-{index}") + self._hierarchy_auto_expand_paths.clear() def _draw_node_tree(self, node, node_key: str) -> None: - tree_flags = imgui.TreeNodeFlags_.span_full_width + tree_flags = imgui.TreeNodeFlags_.span_full_width | imgui.TreeNodeFlags_.open_on_arrow if not node.children: tree_flags |= imgui.TreeNodeFlags_.leaf if node.path == self.selected_node_path: tree_flags |= imgui.TreeNodeFlags_.selected + if node.path in self._hierarchy_auto_expand_paths: + imgui.set_next_item_open(True, imgui.Cond_.always) opened = imgui.tree_node_ex(f"{node.name}##{node_key}", tree_flags) - if imgui.is_item_clicked(): + if imgui.is_item_clicked() and not imgui.is_item_toggled_open(): self.selected_node_path = node.path + self._inspector_transform_edits.clear() self.renderer.select_node_path(node.path) + self._hierarchy_auto_expand_paths = self._ancestor_paths(node.path) if opened: for index, child in enumerate(node.children): self._draw_node_tree(child, f"{node_key}-{index}") imgui.tree_pop() + def _ancestor_paths(self, path: str) -> set[str]: + parts = path.split("/") + return {"/".join(parts[:index]) for index in range(2, len(parts))} + def show_inspector(self) -> None: node = self.renderer.node_snapshot(self.selected_node_path) if node is None: imgui.text_unformatted("Select a node from Hierarchy.") return - imgui.text(f"Path: {node.path}") - imgui.text(f"Children: {len(node.children)}") - imgui.separator() - imgui.text("Transform") - imgui.bullet_text(f"Position: ({node.pos[0]:.2f}, {node.pos[1]:.2f}, {node.pos[2]:.2f})") - imgui.bullet_text(f"Rotation: ({node.hpr[0]:.2f}, {node.hpr[1]:.2f}, {node.hpr[2]:.2f})") - imgui.bullet_text(f"Scale: ({node.scale[0]:.2f}, {node.scale[1]:.2f}, {node.scale[2]:.2f})") + imgui.text_unformatted(node.name) + imgui.text_colored((0.58, 0.62, 0.68, 1.0), node.path) + imgui.spacing() - camera = self.renderer.camera_state() - imgui.separator() - imgui.text("Camera") - imgui.bullet_text(f"Target: ({camera.target_x:.2f}, {camera.target_y:.2f}, {camera.target_z:.2f})") - imgui.bullet_text(f"Distance: {camera.distance:.2f}") - imgui.bullet_text(f"Heading: {camera.heading_deg:.2f}") - imgui.bullet_text(f"Pitch: {camera.pitch_deg:.2f}") + if imgui.collapsing_header("Transform", imgui.TreeNodeFlags_.default_open): + self._draw_transform_fields(node) - def show_stats(self) -> None: - ready, size, fps, last_error, _ = self.renderer.snapshot() - frame_shape = self.viewport_frame_shape - imgui.text(f"Renderer ready: {ready}") - if frame_shape is not None: - imgui.text(f"Frame shape: {frame_shape[1]} x {frame_shape[0]} x {frame_shape[2]}") - else: - imgui.text("Frame shape: n/a") - imgui.text(f"Requested size: {size[0]} x {size[1]}") - imgui.text(f"Panda worker FPS: {fps:.1f}") - _, self.render_scale = imgui.slider_float("Render Scale", self.render_scale, 0.4, 1.0, "%.2f") - imgui.separator() - imgui.text_wrapped( - "The original black screen came from rendering Panda3D inside the same " - "OpenGL GUI frame used by Hello ImGui. This version moves Panda3D rendering " - "to a worker thread, runs simplepbr on the offscreen buffer, uploads only " - "the latest RGBA frame to a GlTexture, and renders the viewport with a raw " - "ImGui draw list instead of ImmVision." + if imgui.collapsing_header("Node Info", imgui.TreeNodeFlags_.default_open): + self._draw_readonly_property("Children", str(len(node.children))) + + def _draw_transform_fields(self, node) -> None: + table_flags = ( + imgui.TableFlags_.sizing_stretch_prop + | imgui.TableFlags_.borders_inner_v + | imgui.TableFlags_.pad_outer_x ) + if not imgui.begin_table("InspectorTransformTable", 2, table_flags): + return + + imgui.table_setup_column("Property", imgui.TableColumnFlags_.width_fixed, 72.0) + imgui.table_setup_column("Value", imgui.TableColumnFlags_.width_stretch) + self._draw_transform_row("Position", "pos", node.pos, "%.3f") + self._draw_transform_row("Rotation", "hpr", node.hpr, "%.2f") + self._draw_transform_row("Scale", "scale", node.scale, "%.3f") + imgui.end_table() + + def _draw_transform_row( + self, + label: str, + field: str, + value: tuple[float, float, float], + value_format: str, + ) -> None: + imgui.table_next_row() + imgui.table_set_column_index(0) + imgui.align_text_to_frame_padding() + imgui.text_unformatted(label) + imgui.table_set_column_index(1) + edit_key = (self.selected_node_path, field) + current_value = self._inspector_transform_edits.get(edit_key, value) + changed, value_tuple, active = self._draw_axis_float_inputs(field, current_value, value_format) + if changed: + self._inspector_transform_edits[edit_key] = value_tuple + self.renderer.queue_input_event( + "set_transform", + path=self.selected_node_path, + field=field, + value=value_tuple, + ) + if not active: + self._inspector_transform_edits.pop(edit_key, None) + + def _draw_axis_float_inputs( + self, + field: str, + value: tuple[float, float, float], + value_format: str, + ) -> tuple[bool, tuple[float, float, float], bool]: + axis_specs = ( + ("X", (0.94, 0.30, 0.28, 1.0)), + ("Y", (0.32, 0.78, 0.36, 1.0)), + ("Z", (0.32, 0.52, 1.0, 1.0)), + ) + available_width = max(imgui.get_content_region_avail().x, 120.0) + label_width = 14.0 + spacing = 6.0 + input_width = max((available_width - (label_width + spacing) * 3.0) / 3.0, 44.0) + values = [float(component) for component in value] + changed = False + active = False + + imgui.push_style_var(imgui.StyleVar_.item_spacing, imgui.ImVec2(spacing, 0.0)) + try: + for index, (axis, color) in enumerate(axis_specs): + if index > 0: + imgui.same_line() + imgui.text_colored(color, axis) + imgui.same_line() + imgui.set_next_item_width(input_width) + axis_changed, axis_value = imgui.input_float( + f"##{field}_{axis}", + values[index], + 0.0, + 0.0, + value_format, + ) + if axis_changed: + values[index] = float(axis_value) + changed = True + active = active or imgui.is_item_active() + finally: + imgui.pop_style_var() + + return changed, (values[0], values[1], values[2]), active + + def _draw_readonly_property(self, label: str, value: str) -> None: + if imgui.begin_table(f"InspectorReadonly{label}", 2, imgui.TableFlags_.sizing_stretch_prop): + imgui.table_setup_column("Property", imgui.TableColumnFlags_.width_fixed, 72.0) + imgui.table_setup_column("Value", imgui.TableColumnFlags_.width_stretch) + imgui.table_next_row() + imgui.table_set_column_index(0) + imgui.text_colored((0.68, 0.72, 0.78, 1.0), label) + imgui.table_set_column_index(1) + imgui.text_unformatted(value) + imgui.end_table() + + def show_console(self) -> None: + _, _, _, last_error, _ = self.renderer.snapshot() + entries = list(self._console_entries) if last_error: - imgui.separator() - imgui.text_colored((1.0, 0.4, 0.4, 1.0), last_error) + entries.append(("Error", last_error, "Renderer")) + visible_entries = self._filtered_console_entries(entries) + if self._console_collapse: + visible_entries = self._collapsed_console_entries(visible_entries) + + if imgui.button("Clear"): + self._console_entries.clear() + imgui.same_line() + if imgui.button("Copy All"): + imgui.set_clipboard_text("\n".join(self._format_console_entry(entry) for entry in visible_entries)) + imgui.same_line() + _, self._console_collapse = imgui.checkbox("Collapse", self._console_collapse) + imgui.same_line() + _, self._console_show_info = imgui.checkbox( + f"Log ({self._console_count('Info')})", + self._console_show_info, + ) + imgui.same_line() + _, self._console_show_warnings = imgui.checkbox( + f"Warning ({self._console_count('Warning')})", + self._console_show_warnings, + ) + imgui.same_line() + _, self._console_show_errors = imgui.checkbox( + f"Error ({self._console_count('Error') + (1 if last_error else 0)})", + self._console_show_errors, + ) + + imgui.separator() + if imgui.begin_child("ConsoleLogList", (0.0, 0.0), imgui.ChildFlags_.borders): + console_text = "\n".join(self._format_console_entry(entry) for entry in visible_entries) + available = imgui.get_content_region_avail() + imgui.input_text_multiline( + "##ConsoleSelectableText", + console_text, + (available.x, available.y), + imgui.InputTextFlags_.read_only, + ) + imgui.end_child() + + def _console_count(self, level: str) -> int: + return sum(1 for entry_level, _, _ in self._console_entries if entry_level == level) + + def _filtered_console_entries(self, entries: list[tuple[str, str, str]]) -> list[tuple[str, str, str]]: + return [ + entry + for entry in entries + if ( + (entry[0] == "Info" and self._console_show_info) + or (entry[0] == "Warning" and self._console_show_warnings) + or (entry[0] == "Error" and self._console_show_errors) + ) + ] + + def _collapsed_console_entries(self, entries: list[tuple[str, str, str]]) -> list[tuple[str, str, str]]: + counts: dict[tuple[str, str, str], int] = {} + ordered_entries: list[tuple[str, str, str]] = [] + for entry in entries: + if entry not in counts: + ordered_entries.append(entry) + counts[entry] = 0 + counts[entry] += 1 + return [ + (level, f"{message} ({counts[entry]})" if counts[entry] > 1 else message, source) + for entry in ordered_entries + for level, message, source in [entry] + ] + + def _format_console_entry(self, entry: tuple[str, str, str]) -> str: + level, message, source = entry + return f"[{level}] [{source}] {message}" diff --git a/src/impanda3d/renderer.py b/src/impanda3d/renderer.py index e61bb41..3ae5ca5 100644 --- a/src/impanda3d/renderer.py +++ b/src/impanda3d/renderer.py @@ -23,7 +23,8 @@ from panda3d.core import ( from .config import apply_panda_runtime_config from .scene import build_preview_scene from .types import CameraState, SceneNodeSnapshot -from src.TransformGizmo.transform_gizmo import TransformGizmo, TransformGizmoMode +from src.transform_gizmo.transform_gizmo import TransformGizmo, TransformGizmoMode +from src.camera_view_gizmo import CameraViewGizmo, CameraViewGizmoConfig from src.tools.camera_orbit_controller import CameraOrbitController from src.tools.picker_ray import PickerRay @@ -121,6 +122,7 @@ class PandaRendererThread(threading.Thread): base: ShowBase | None = None camera_controller: CameraOrbitController | None = None transform_gizmo: TransformGizmo | None = None + camera_view_gizmo: CameraViewGizmo | None = None picker_ray: PickerRay | None = None previous_task_signal = PandaTaskModule.signal try: @@ -168,10 +170,10 @@ class PandaRendererThread(threading.Thread): use_emission_maps=True, use_occlusion_maps=True, enable_shadows=False, - exposure=0.35, + exposure=0.15, ) - scene_root = build_preview_scene(base) + scene_root = build_preview_scene(base, camera_np) scene_root.set_python_tag("scene_path", scene_root.get_name()) self._update_scene_snapshot(scene_root) camera_controller = CameraOrbitController( @@ -193,6 +195,16 @@ class PandaRendererThread(threading.Thread): self._sync_camera_snapshot(camera_controller) transform_gizmo = TransformGizmo(base, camera_np=camera_np, use_overlay=False) transform_gizmo.set_mode(TransformGizmoMode.MOVE) + camera_view_gizmo = CameraViewGizmo( + base, + camera_np=camera_np, + window=buffer, + get_pivot=camera_controller.get_pivot, + on_camera_changed=lambda _gizmo: camera_controller.sync_from_camera(camera_controller.get_pivot()), + interaction_blocker=lambda: False, + register_events=False, + config=CameraViewGizmoConfig(size_px=132, overlay_sort=20), + ) picker_ray = PickerRay(base, pick_root=scene_root, camera=camera_np) self._attach_gizmo_to_selected(transform_gizmo) @@ -218,7 +230,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, picker_ray) + self._drain_input_events(camera_controller, transform_gizmo, camera_view_gizmo, picker_ray) base.taskMgr.step() self._sync_camera_snapshot(camera_controller) @@ -260,6 +272,11 @@ class PandaRendererThread(threading.Thread): transform_gizmo.detach() except Exception: pass + if camera_view_gizmo is not None: + try: + camera_view_gizmo.destroy() + except Exception: + pass if base is not None: try: base.destroy() @@ -270,6 +287,7 @@ class PandaRendererThread(threading.Thread): self, camera_controller: CameraOrbitController, transform_gizmo: TransformGizmo | None, + camera_view_gizmo: CameraViewGizmo | None, picker_ray: PickerRay | None, ) -> None: while True: @@ -305,6 +323,19 @@ class PandaRendererThread(threading.Thread): x = payload.get("x") y = payload.get("y") ndc = self._event_ndc(payload) + if x is not None and y is not None and camera_view_gizmo is not None: + if button == "lmb" and down: + camera_view_gizmo.handle_mouse1_down(float(x), float(y)) + elif button == "lmb" and not down: + camera_view_gizmo.handle_mouse1_up(float(x), float(y)) + view_gizmo_captures_left = ( + camera_view_gizmo is not None + and button == "lmb" + and camera_view_gizmo.captures_mouse + ) + if view_gizmo_captures_left: + camera_controller.set_button(button, False) + continue event_payload = {"x": ndc[0], "y": ndc[1], "ndc": True} if ndc is not None else None if transform_gizmo is not None: if button == "lmb" and down: @@ -336,10 +367,18 @@ class PandaRendererThread(threading.Thread): elif kind == "mouse_move": x = payload.get("x") y = payload.get("y") - if x is not None and y is not None and not (transform_gizmo is not None and transform_gizmo.is_dragging): + if x is not None and y is not None and camera_view_gizmo is not None: + camera_view_gizmo.handle_mouse_move(float(x), float(y)) + view_gizmo_captures = camera_view_gizmo is not None and camera_view_gizmo.captures_mouse + if ( + x is not None + and y is not None + and not view_gizmo_captures + and not (transform_gizmo is not None and transform_gizmo.is_dragging) + ): camera_controller.move_pointer(float(x), float(y)) ndc = self._event_ndc(payload) - if transform_gizmo is not None and ndc is not None: + if transform_gizmo is not None and ndc is not None and not view_gizmo_captures: transform_gizmo.world.messenger.send("mouse-move", [{"x": ndc[0], "y": ndc[1], "ndc": True}]) elif kind == "wheel": delta = float(payload.get("delta", 0.0)) @@ -352,6 +391,16 @@ class PandaRendererThread(threading.Thread): camera_controller.set_key(key, bool(payload.get("down", False))) elif kind == "focus": camera_controller.focus_on_current_target() + elif kind == "undo_transform": + if transform_gizmo is not None: + transform_gizmo.undo_last() + elif kind == "set_transform": + path = str(payload.get("path", "")) + field = str(payload.get("field", "")) + value = payload.get("value") + self._set_node_transform(path, field, value) + if transform_gizmo is not None: + self._attach_gizmo_to_selected(transform_gizmo) elif kind == "select_path": path = str(payload.get("path", "")) self._set_selected_node_path(path) @@ -416,6 +465,22 @@ class PandaRendererThread(threading.Thread): with self._state_lock: self._selected_node_path = path + def _set_node_transform(self, path: str, field: str, value: object) -> None: + node = self._node_path_lookup.get(path) + if node is None or value is None: + return + try: + x, y, z = (float(component) for component in value) # type: ignore[operator] + except (TypeError, ValueError): + return + + if field == "pos": + node.set_pos(x, y, z) + elif field == "hpr": + node.set_hpr(x, y, z) + elif field == "scale": + node.set_scale(x, y, z) + 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/impanda3d/scene.py b/src/impanda3d/scene.py index 55fd083..397304d 100644 --- a/src/impanda3d/scene.py +++ b/src/impanda3d/scene.py @@ -7,45 +7,47 @@ from panda3d.core import ( AmbientLight, DirectionalLight, Material, - PointLight, Vec3, + NodePath ) from .types import CameraState -def build_preview_scene(base: ShowBase): +def _setup_editor_lighting(base: ShowBase, camera_np:NodePath) -> None: + ambient = AmbientLight("editorAmbient") + ambient.set_color((0.28, 0.30, 0.33, 1.0)) + base.render.set_light(base.render.attachNewNode(ambient)) + + head_light = DirectionalLight("editorHeadLight") + head_light.set_color((0.72, 0.75, 0.80, 1.0)) + head_np = camera_np.attachNewNode(head_light) + base.render.set_light(head_np) + + key_light = DirectionalLight("editorKeyLight") + key_light.set_color((0.62, 0.60, 0.54, 1.0)) + key_np = base.render.attachNewNode(key_light) + key_np.set_hpr(-35.0, -45.0, 0.0) + base.render.set_light(key_np) + + fill_light = DirectionalLight("editorFillLight") + fill_light.set_color((0.34, 0.39, 0.47, 1.0)) + fill_np = base.render.attachNewNode(fill_light) + fill_np.set_hpr(130.0, -20.0, 0.0) + base.render.set_light(fill_np) + + rim_light = DirectionalLight("editorRimLight") + rim_light.set_color((0.28, 0.30, 0.34, 1.0)) + rim_np = base.render.attachNewNode(rim_light) + rim_np.set_hpr(180.0, -10.0, 0.0) + base.render.set_light(rim_np) + + +def build_preview_scene(base: ShowBase, camera_np): base.render.set_shader_auto() base.setBackgroundColor(0.16, 0.17, 0.19, 1.0) base.disableMouse() - - ambient = AmbientLight("ambient") - ambient.set_color((0.10, 0.10, 0.11, 1.0)) - base.render.set_light(base.render.attachNewNode(ambient)) - - key_light = DirectionalLight("keyLight") - key_light.set_color((1.45, 1.40, 1.30, 1.0)) - key_np = base.render.attachNewNode(key_light) - key_np.set_hpr(-38.0, -34.0, 0.0) - base.render.set_light(key_np) - - fill_light = DirectionalLight("fillLight") - fill_light.set_color((0.38, 0.44, 0.52, 1.0)) - fill_np = base.render.attachNewNode(fill_light) - fill_np.set_hpr(118.0, -18.0, 0.0) - base.render.set_light(fill_np) - - rim_light = DirectionalLight("rimLight") - rim_light.set_color((0.30, 0.28, 0.26, 1.0)) - rim_np = base.render.attachNewNode(rim_light) - rim_np.set_hpr(180.0, -8.0, 0.0) - base.render.set_light(rim_np) - - bounce_light = PointLight("bounceLight") - bounce_light.set_color((0.22, 0.24, 0.28, 1.0)) - bounce_np = base.render.attachNewNode(bounce_light) - bounce_np.set_pos(0.0, -1.5, 4.0) - base.render.set_light(bounce_np) + _setup_editor_lighting(base, camera_np) scene_root = base.render.attachNewNode("SceneRoot") preview_root = scene_root.attachNewNode("PreviewRoot") diff --git a/src/TransformGizmo/docs/transform_gizmo_events.md b/src/transform_gizmo/docs/transform_gizmo_events.md similarity index 100% rename from src/TransformGizmo/docs/transform_gizmo_events.md rename to src/transform_gizmo/docs/transform_gizmo_events.md diff --git a/src/TransformGizmo/events.py b/src/transform_gizmo/events.py similarity index 100% rename from src/TransformGizmo/events.py rename to src/transform_gizmo/events.py diff --git a/src/TransformGizmo/move_gizmo.py b/src/transform_gizmo/move_gizmo.py similarity index 100% rename from src/TransformGizmo/move_gizmo.py rename to src/transform_gizmo/move_gizmo.py diff --git a/src/TransformGizmo/rotate_gizmo.py b/src/transform_gizmo/rotate_gizmo.py similarity index 100% rename from src/TransformGizmo/rotate_gizmo.py rename to src/transform_gizmo/rotate_gizmo.py diff --git a/src/TransformGizmo/scale_gizmo.py b/src/transform_gizmo/scale_gizmo.py similarity index 100% rename from src/TransformGizmo/scale_gizmo.py rename to src/transform_gizmo/scale_gizmo.py diff --git a/src/TransformGizmo/transform_gizmo.py b/src/transform_gizmo/transform_gizmo.py similarity index 100% rename from src/TransformGizmo/transform_gizmo.py rename to src/transform_gizmo/transform_gizmo.py