优化编辑器场景灯光效果;层级树选中时父级递归展开保证能看到选中项;更新并美化底部控制台形式;修复TransformGizmo的ctrl+z撤回功能;接入中英文字体;升级并美化Inspector检查器面板;集成右上角场景相机旋转变换小组件

This commit is contained in:
zhaohao 2026-04-24 17:22:38 +08:00
parent 4f5eaeb92b
commit 785aafb094
17 changed files with 1251 additions and 77 deletions

Binary file not shown.

BIN
fonts/monaco.ttf Normal file

Binary file not shown.

BIN
fonts/msyh.ttc Normal file

Binary file not shown.

View 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.

View File

@ -0,0 +1,9 @@
from .camera_view_gizmo import CameraViewGizmo, ProjectionMode, ViewFace
from .config import CameraViewGizmoConfig
__all__ = [
"CameraViewGizmo",
"CameraViewGizmoConfig",
"ProjectionMode",
"ViewFace",
]

View 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

View 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)

View 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()

View File

@ -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}"

View File

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

View File

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