优化编辑器场景灯光效果;层级树选中时父级递归展开保证能看到选中项;更新并美化底部控制台形式;修复TransformGizmo的ctrl+z撤回功能;接入中英文字体;升级并美化Inspector检查器面板;集成右上角场景相机旋转变换小组件
This commit is contained in:
parent
4f5eaeb92b
commit
785aafb094
BIN
fonts/FontAwesome7Free-Solid-900.otf
Normal file
BIN
fonts/FontAwesome7Free-Solid-900.otf
Normal file
Binary file not shown.
BIN
fonts/monaco.ttf
Normal file
BIN
fonts/monaco.ttf
Normal file
Binary file not shown.
BIN
fonts/msyh.ttc
Normal file
BIN
fonts/msyh.ttc
Normal file
Binary file not shown.
27
src/camera_view_gizmo/README.md
Normal file
27
src/camera_view_gizmo/README.md
Normal file
@ -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.
|
||||||
9
src/camera_view_gizmo/__init__.py
Normal file
9
src/camera_view_gizmo/__init__.py
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
from .camera_view_gizmo import CameraViewGizmo, ProjectionMode, ViewFace
|
||||||
|
from .config import CameraViewGizmoConfig
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CameraViewGizmo",
|
||||||
|
"CameraViewGizmoConfig",
|
||||||
|
"ProjectionMode",
|
||||||
|
"ViewFace",
|
||||||
|
]
|
||||||
777
src/camera_view_gizmo/camera_view_gizmo.py
Normal file
777
src/camera_view_gizmo/camera_view_gizmo.py
Normal file
@ -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
|
||||||
63
src/camera_view_gizmo/config.py
Normal file
63
src/camera_view_gizmo/config.py
Normal file
@ -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)
|
||||||
34
src/camera_view_gizmo/demo_basic.py
Normal file
34
src/camera_view_gizmo/demo_basic.py
Normal file
@ -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()
|
||||||
@ -1,5 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from imgui_bundle import hello_imgui, imgui, immapp, immvision # type: ignore
|
from imgui_bundle import hello_imgui, imgui, immapp, immvision # type: ignore
|
||||||
|
|
||||||
from .config import INI_FILENAME, WINDOW_TITLE
|
from .config import INI_FILENAME, WINDOW_TITLE
|
||||||
@ -13,9 +15,12 @@ class EditorApp:
|
|||||||
|
|
||||||
self.selected_node_path = "SceneRoot/PreviewRoot/Teapot"
|
self.selected_node_path = "SceneRoot/PreviewRoot/Teapot"
|
||||||
self.renderer.select_node_path(self.selected_node_path)
|
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_texture: immvision.GlTexture | None = None
|
||||||
self.viewport_frame_version = -1
|
self.viewport_frame_version = -1
|
||||||
self.viewport_frame_shape: tuple[int, int, int] | None = None
|
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.render_scale = 1.0
|
||||||
self._viewport_mouse_buttons = {"lmb": False, "mmb": False, "rmb": False}
|
self._viewport_mouse_buttons = {"lmb": False, "mmb": False, "rmb": False}
|
||||||
self._viewport_keys = {
|
self._viewport_keys = {
|
||||||
@ -28,6 +33,17 @@ class EditorApp:
|
|||||||
"r": False,
|
"r": False,
|
||||||
"space": 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]:
|
def build_runner_params(self) -> tuple[hello_imgui.RunnerParams, immapp.AddOnsParams]:
|
||||||
runner = hello_imgui.RunnerParams()
|
runner = hello_imgui.RunnerParams()
|
||||||
@ -38,19 +54,43 @@ class EditorApp:
|
|||||||
|
|
||||||
runner.imgui_window_params.menu_app_title = "RobotMetaCore"
|
runner.imgui_window_params.menu_app_title = "RobotMetaCore"
|
||||||
runner.imgui_window_params.show_menu_bar = True
|
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 = (
|
runner.imgui_window_params.default_imgui_window_type = (
|
||||||
hello_imgui.DefaultImGuiWindowType.provide_full_screen_dock_space
|
hello_imgui.DefaultImGuiWindowType.provide_full_screen_dock_space
|
||||||
)
|
)
|
||||||
|
|
||||||
runner.callbacks.show_status = self._show_status_bar
|
|
||||||
runner.callbacks.before_exit = self._shutdown
|
runner.callbacks.before_exit = self._shutdown
|
||||||
|
runner.callbacks.load_additional_fonts = self._load_fonts
|
||||||
runner.docking_params = self._build_docking_layout()
|
runner.docking_params = self._build_docking_layout()
|
||||||
runner.docking_params.layout_condition = hello_imgui.DockingLayoutCondition.application_start
|
runner.docking_params.layout_condition = hello_imgui.DockingLayoutCondition.application_start
|
||||||
|
|
||||||
addons = immapp.AddOnsParams()
|
addons = immapp.AddOnsParams()
|
||||||
return runner, addons
|
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:
|
def _build_docking_layout(self) -> hello_imgui.DockingParams:
|
||||||
layout = hello_imgui.DockingParams()
|
layout = hello_imgui.DockingParams()
|
||||||
|
|
||||||
@ -90,9 +130,9 @@ class EditorApp:
|
|||||||
gui_function_=self.show_inspector,
|
gui_function_=self.show_inspector,
|
||||||
),
|
),
|
||||||
hello_imgui.DockableWindow(
|
hello_imgui.DockableWindow(
|
||||||
label_="Stats",
|
label_="Console",
|
||||||
dock_space_name_="BottomSpace",
|
dock_space_name_="BottomSpace",
|
||||||
gui_function_=self.show_stats,
|
gui_function_=self.show_console,
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
return layout
|
return layout
|
||||||
@ -171,6 +211,9 @@ class EditorApp:
|
|||||||
if hovered and io.mouse_wheel != 0.0:
|
if hovered and io.mouse_wheel != 0.0:
|
||||||
self.renderer.queue_input_event("wheel", delta=io.mouse_wheel)
|
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"]:
|
if hovered or self._viewport_mouse_buttons["rmb"]:
|
||||||
key_states = {
|
key_states = {
|
||||||
"w": imgui.is_key_down(imgui.Key.w),
|
"w": imgui.is_key_down(imgui.Key.w),
|
||||||
@ -241,8 +284,10 @@ class EditorApp:
|
|||||||
|
|
||||||
self._handle_viewport_input(hovered, rect_min)
|
self._handle_viewport_input(hovered, rect_min)
|
||||||
renderer_selected_path = self.renderer.selected_node_path()
|
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.selected_node_path = renderer_selected_path
|
||||||
|
self._inspector_transform_edits.clear()
|
||||||
|
self._hierarchy_auto_expand_paths = self._ancestor_paths(renderer_selected_path)
|
||||||
# if hovered:
|
# if hovered:
|
||||||
# imgui.set_tooltip("Alt+LMB orbit\nMMB pan\nAlt+RMB or Wheel dolly\nRMB look + WASD/QE fly\nF focus")
|
# imgui.set_tooltip("Alt+LMB orbit\nMMB pan\nAlt+RMB or Wheel dolly\nRMB look + WASD/QE fly\nF focus")
|
||||||
else:
|
else:
|
||||||
@ -260,65 +305,217 @@ class EditorApp:
|
|||||||
|
|
||||||
for index, node in enumerate(nodes):
|
for index, node in enumerate(nodes):
|
||||||
self._draw_node_tree(node, f"root-{index}")
|
self._draw_node_tree(node, f"root-{index}")
|
||||||
|
self._hierarchy_auto_expand_paths.clear()
|
||||||
|
|
||||||
def _draw_node_tree(self, node, node_key: str) -> None:
|
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:
|
if not node.children:
|
||||||
tree_flags |= imgui.TreeNodeFlags_.leaf
|
tree_flags |= imgui.TreeNodeFlags_.leaf
|
||||||
if node.path == self.selected_node_path:
|
if node.path == self.selected_node_path:
|
||||||
tree_flags |= imgui.TreeNodeFlags_.selected
|
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)
|
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.selected_node_path = node.path
|
||||||
|
self._inspector_transform_edits.clear()
|
||||||
self.renderer.select_node_path(node.path)
|
self.renderer.select_node_path(node.path)
|
||||||
|
self._hierarchy_auto_expand_paths = self._ancestor_paths(node.path)
|
||||||
|
|
||||||
if opened:
|
if opened:
|
||||||
for index, child in enumerate(node.children):
|
for index, child in enumerate(node.children):
|
||||||
self._draw_node_tree(child, f"{node_key}-{index}")
|
self._draw_node_tree(child, f"{node_key}-{index}")
|
||||||
imgui.tree_pop()
|
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:
|
def show_inspector(self) -> None:
|
||||||
node = self.renderer.node_snapshot(self.selected_node_path)
|
node = self.renderer.node_snapshot(self.selected_node_path)
|
||||||
if node is None:
|
if node is None:
|
||||||
imgui.text_unformatted("Select a node from Hierarchy.")
|
imgui.text_unformatted("Select a node from Hierarchy.")
|
||||||
return
|
return
|
||||||
|
|
||||||
imgui.text(f"Path: {node.path}")
|
imgui.text_unformatted(node.name)
|
||||||
imgui.text(f"Children: {len(node.children)}")
|
imgui.text_colored((0.58, 0.62, 0.68, 1.0), node.path)
|
||||||
imgui.separator()
|
imgui.spacing()
|
||||||
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})")
|
|
||||||
|
|
||||||
camera = self.renderer.camera_state()
|
if imgui.collapsing_header("Transform", imgui.TreeNodeFlags_.default_open):
|
||||||
imgui.separator()
|
self._draw_transform_fields(node)
|
||||||
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}")
|
|
||||||
|
|
||||||
def show_stats(self) -> None:
|
if imgui.collapsing_header("Node Info", imgui.TreeNodeFlags_.default_open):
|
||||||
ready, size, fps, last_error, _ = self.renderer.snapshot()
|
self._draw_readonly_property("Children", str(len(node.children)))
|
||||||
frame_shape = self.viewport_frame_shape
|
|
||||||
imgui.text(f"Renderer ready: {ready}")
|
def _draw_transform_fields(self, node) -> None:
|
||||||
if frame_shape is not None:
|
table_flags = (
|
||||||
imgui.text(f"Frame shape: {frame_shape[1]} x {frame_shape[0]} x {frame_shape[2]}")
|
imgui.TableFlags_.sizing_stretch_prop
|
||||||
else:
|
| imgui.TableFlags_.borders_inner_v
|
||||||
imgui.text("Frame shape: n/a")
|
| imgui.TableFlags_.pad_outer_x
|
||||||
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 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:
|
if last_error:
|
||||||
imgui.separator()
|
entries.append(("Error", last_error, "Renderer"))
|
||||||
imgui.text_colored((1.0, 0.4, 0.4, 1.0), last_error)
|
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}"
|
||||||
|
|||||||
@ -23,7 +23,8 @@ from panda3d.core import (
|
|||||||
from .config import apply_panda_runtime_config
|
from .config import apply_panda_runtime_config
|
||||||
from .scene import build_preview_scene
|
from .scene import build_preview_scene
|
||||||
from .types import CameraState, SceneNodeSnapshot
|
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.camera_orbit_controller import CameraOrbitController
|
||||||
from src.tools.picker_ray import PickerRay
|
from src.tools.picker_ray import PickerRay
|
||||||
|
|
||||||
@ -121,6 +122,7 @@ class PandaRendererThread(threading.Thread):
|
|||||||
base: ShowBase | None = None
|
base: ShowBase | None = None
|
||||||
camera_controller: CameraOrbitController | None = None
|
camera_controller: CameraOrbitController | None = None
|
||||||
transform_gizmo: TransformGizmo | None = None
|
transform_gizmo: TransformGizmo | None = None
|
||||||
|
camera_view_gizmo: CameraViewGizmo | None = None
|
||||||
picker_ray: PickerRay | None = None
|
picker_ray: PickerRay | None = None
|
||||||
previous_task_signal = PandaTaskModule.signal
|
previous_task_signal = PandaTaskModule.signal
|
||||||
try:
|
try:
|
||||||
@ -168,10 +170,10 @@ class PandaRendererThread(threading.Thread):
|
|||||||
use_emission_maps=True,
|
use_emission_maps=True,
|
||||||
use_occlusion_maps=True,
|
use_occlusion_maps=True,
|
||||||
enable_shadows=False,
|
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())
|
scene_root.set_python_tag("scene_path", scene_root.get_name())
|
||||||
self._update_scene_snapshot(scene_root)
|
self._update_scene_snapshot(scene_root)
|
||||||
camera_controller = CameraOrbitController(
|
camera_controller = CameraOrbitController(
|
||||||
@ -193,6 +195,16 @@ class PandaRendererThread(threading.Thread):
|
|||||||
self._sync_camera_snapshot(camera_controller)
|
self._sync_camera_snapshot(camera_controller)
|
||||||
transform_gizmo = TransformGizmo(base, camera_np=camera_np, use_overlay=False)
|
transform_gizmo = TransformGizmo(base, camera_np=camera_np, use_overlay=False)
|
||||||
transform_gizmo.set_mode(TransformGizmoMode.MOVE)
|
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)
|
picker_ray = PickerRay(base, pick_root=scene_root, camera=camera_np)
|
||||||
self._attach_gizmo_to_selected(transform_gizmo)
|
self._attach_gizmo_to_selected(transform_gizmo)
|
||||||
|
|
||||||
@ -218,7 +230,7 @@ class PandaRendererThread(threading.Thread):
|
|||||||
last_time = now
|
last_time = now
|
||||||
smoothed_dt = smoothed_dt * 0.9 + dt * 0.1
|
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()
|
base.taskMgr.step()
|
||||||
self._sync_camera_snapshot(camera_controller)
|
self._sync_camera_snapshot(camera_controller)
|
||||||
|
|
||||||
@ -260,6 +272,11 @@ class PandaRendererThread(threading.Thread):
|
|||||||
transform_gizmo.detach()
|
transform_gizmo.detach()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
if camera_view_gizmo is not None:
|
||||||
|
try:
|
||||||
|
camera_view_gizmo.destroy()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
if base is not None:
|
if base is not None:
|
||||||
try:
|
try:
|
||||||
base.destroy()
|
base.destroy()
|
||||||
@ -270,6 +287,7 @@ class PandaRendererThread(threading.Thread):
|
|||||||
self,
|
self,
|
||||||
camera_controller: CameraOrbitController,
|
camera_controller: CameraOrbitController,
|
||||||
transform_gizmo: TransformGizmo | None,
|
transform_gizmo: TransformGizmo | None,
|
||||||
|
camera_view_gizmo: CameraViewGizmo | None,
|
||||||
picker_ray: PickerRay | None,
|
picker_ray: PickerRay | None,
|
||||||
) -> None:
|
) -> None:
|
||||||
while True:
|
while True:
|
||||||
@ -305,6 +323,19 @@ class PandaRendererThread(threading.Thread):
|
|||||||
x = payload.get("x")
|
x = payload.get("x")
|
||||||
y = payload.get("y")
|
y = payload.get("y")
|
||||||
ndc = self._event_ndc(payload)
|
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
|
event_payload = {"x": ndc[0], "y": ndc[1], "ndc": True} if ndc is not None else None
|
||||||
if transform_gizmo is not None:
|
if transform_gizmo is not None:
|
||||||
if button == "lmb" and down:
|
if button == "lmb" and down:
|
||||||
@ -336,10 +367,18 @@ class PandaRendererThread(threading.Thread):
|
|||||||
elif kind == "mouse_move":
|
elif kind == "mouse_move":
|
||||||
x = payload.get("x")
|
x = payload.get("x")
|
||||||
y = payload.get("y")
|
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))
|
camera_controller.move_pointer(float(x), float(y))
|
||||||
ndc = self._event_ndc(payload)
|
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}])
|
transform_gizmo.world.messenger.send("mouse-move", [{"x": ndc[0], "y": ndc[1], "ndc": True}])
|
||||||
elif kind == "wheel":
|
elif kind == "wheel":
|
||||||
delta = float(payload.get("delta", 0.0))
|
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)))
|
camera_controller.set_key(key, bool(payload.get("down", False)))
|
||||||
elif kind == "focus":
|
elif kind == "focus":
|
||||||
camera_controller.focus_on_current_target()
|
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":
|
elif kind == "select_path":
|
||||||
path = str(payload.get("path", ""))
|
path = str(payload.get("path", ""))
|
||||||
self._set_selected_node_path(path)
|
self._set_selected_node_path(path)
|
||||||
@ -416,6 +465,22 @@ class PandaRendererThread(threading.Thread):
|
|||||||
with self._state_lock:
|
with self._state_lock:
|
||||||
self._selected_node_path = path
|
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:
|
def _sync_camera_snapshot(self, camera_controller: CameraOrbitController) -> None:
|
||||||
target_x, target_y, target_z, distance, yaw, pitch = camera_controller.camera_state_tuple()
|
target_x, target_y, target_z, distance, yaw, pitch = camera_controller.camera_state_tuple()
|
||||||
with self._state_lock:
|
with self._state_lock:
|
||||||
|
|||||||
@ -7,45 +7,47 @@ from panda3d.core import (
|
|||||||
AmbientLight,
|
AmbientLight,
|
||||||
DirectionalLight,
|
DirectionalLight,
|
||||||
Material,
|
Material,
|
||||||
PointLight,
|
|
||||||
Vec3,
|
Vec3,
|
||||||
|
NodePath
|
||||||
)
|
)
|
||||||
|
|
||||||
from .types import CameraState
|
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.render.set_shader_auto()
|
||||||
base.setBackgroundColor(0.16, 0.17, 0.19, 1.0)
|
base.setBackgroundColor(0.16, 0.17, 0.19, 1.0)
|
||||||
base.disableMouse()
|
base.disableMouse()
|
||||||
|
_setup_editor_lighting(base, camera_np)
|
||||||
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)
|
|
||||||
|
|
||||||
scene_root = base.render.attachNewNode("SceneRoot")
|
scene_root = base.render.attachNewNode("SceneRoot")
|
||||||
preview_root = scene_root.attachNewNode("PreviewRoot")
|
preview_root = scene_root.attachNewNode("PreviewRoot")
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user