EG/TransformGizmo/rotate_gizmo.py
2026-02-25 11:53:06 +08:00

1588 lines
64 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import math
from typing import Optional, Tuple, Callable, Dict, Any, List
from panda3d.core import (
NodePath,
Vec3,
Point3,
Point2,
Vec4,
Quat,
GeomNode,
GeomVertexFormat,
GeomVertexData,
GeomVertexWriter,
GeomLines,
GeomTriangles,
Geom,
CollisionNode,
CollisionRay,
CollisionHandlerQueue,
CollisionTraverser,
CollisionTube,
CollisionSphere,
CollisionEntry,
BitMask32,
PerspectiveLens,
TransparencyAttrib,
Material,
LPoint2f
)
from direct.showbase.DirectObject import DirectObject
from direct.task import Task
from direct.task.TaskManagerGlobal import taskMgr
from direct.showbase.ShowBase import ShowBase
from .events import GizmoEvent
"""
旋转控制柄Rotate Gizmo
=========================
功能概述:
- 模仿 Unity 的旋转控制柄,使用 3 个彩色圆环表示绕 X/Y/Z 轴旋转
- X 轴:红色圆环(绕局部 X 轴旋转)
- Y 轴:绿色圆环(绕局部 Y 轴旋转)
- Z 轴:蓝色圆环(绕局部 Z 轴旋转)
- 额外提供屏幕对齐的外圈view handle与轨迹球trackball方便视图方向与自由旋转
- 拖拽时展示半透明角度扇形与箭头,辅助观察旋转量
- 中心轴线辅助观察当前坐标系朝向
- 使用 Panda3D 的碰撞系统进行鼠标拾取与拖拽
- 控件跟随目标节点的局部坐标系(即“局部模式”),
当目标有旋转时,三个圆环会随之旋转
- 控件会根据摄像机距离自动缩放,屏幕大小保持近似不变
集成方式(示例):
from QPanda3D.Panda3DWorld import Panda3DWorld
from QPanda3DExamples.rotate_gizmo import RotateGizmo
world = Panda3DWorld()
model_np = world.render.attachNewNode("Box")
# ... 在 model_np 下加载模型
gizmo = RotateGizmo(world) # 创建旋转 Gizmo
gizmo.attach(model_np) # 绑定到某个模型
# 当需要切换目标时:
gizmo.attach(another_np)
# 当不需要时,可隐藏:
gizmo.detach()
鼠标事件要求:
- 本类默认监听 Panda3D 的 "mouse1" / "mouse1-up" / "mouse-move" 事件
- 如果从 Qt / 自定义 UI 传递鼠标像素坐标,可以在发送事件时传入 extra 字典:
messenger.send("mouse1", [{"x": mouse_x, "y": mouse_y}])
本类会自动将像素坐标转换到 [-1, 1] 的标准化设备坐标
"""
class RotateGizmo(DirectObject):
"""
Unity 风格的旋转控制柄:
- 3 个圆环分别代表绕局部 X/Y/Z 轴旋转
- 额外的视图对齐圆环与屏幕轨迹球,便于自由旋转
- 左键点击圆环并拖拽,可以绕对应轴旋转绑定的 NodePath
"""
def __init__(
self,
world,
camera_np: NodePath | None = None,
on_action_committed: Optional[Callable[[Dict[str, Any]], None]] = None,
event_hooks: Optional[Dict[str, List[Callable[[Dict[str, Any]], None]]]] = None
):
super().__init__()
self.world:ShowBase = world
self.is_local: bool = True
self.debug: bool = False
self.is_hovering = False
self._picker_added: bool = False
# Optional callback to report completed rotate actions to a higher-level
# manager (e.g. TransformGizmo) so it can build a global undo stack.
self.on_action_committed = on_action_committed
# Event hooks: drag_start / drag_move / drag_end
self._event_hooks = self._normalize_event_hooks(event_hooks)
# 摄像机节点,如果未手动传入,则尝试从 world/base 中自动获取
self.camera: Optional[NodePath] = camera_np or getattr(world, "cam", None)
if not self.camera and hasattr(world, "base"):
self.camera = world.base.cam
if not self.camera:
self._log("No camera found for RotateGizmo")
else:
self._log(f"Camera found for RotateGizmo: {self.camera}")
# 控制柄根节点
self.root: NodePath = NodePath("RotateGizmo")
self.color_higher = 0.05
self.color_normal = 0.01
# 当前绑定的目标节点
self.target_node: Optional[NodePath] = None
self.attached: bool = False
self.last_mouse_pos = None
# 控制柄视觉参数
self.ring_radius: float = 0.45 # 圆环半径(局部空间)
self.ring_thickness: float = 0.04 # 圆环粗细(碰撞体半径)
self.ring_alpha: float = 0.9 # 圆环透明度
self.trackball_radius: float = self.ring_radius * 1.0 # 轨迹球半径(略小,避免遮挡轴拾取)
self.trackball_color: Vec4 = Vec4(0.25, 0.25, 0.25, 0.45)
self.trackball_edge_color: Vec4 = Vec4(1, 1, 1, 0.75)
self.highlight_color: Vec4 = Vec4(1.0, 1.0, 1.0, 1.0)
self.arrow_length_factor: float = 0.35 # 箭头长度相对圆环半径的倍数
self.arrow_head_width_factor: float = 0.25 # 箭头头部宽度相对箭长的倍数
# 拾取射线与碰撞系统
self.picker_ray = CollisionRay()
self.picker_node = CollisionNode("rotate_gizmo_picker_ray")
self.picker_node.addSolid(self.picker_ray)
self.picker_node.setFromCollideMask(BitMask32.bit(20))
self.picker_node.setIntoCollideMask(BitMask32.allOff())
self.picker_np: NodePath = NodePath(self.picker_node)
self.c_trav = CollisionTraverser()
self.c_queue = CollisionHandlerQueue()
# 拖拽状态
self.dragging: bool = False
self.drag_axis: Optional[int] = None # 0 = X, 1 = Y, 2 = Z
self.drag_mode: Optional[str] = None # "axis" / "view" / "trackball"
self.drag_local_axis: Optional[Vec3] = None
self.start_mouse: Optional[Point2] = None
self._axis_screen_dir: Point2 | None = None
# 起始姿态:世界与父坐标系两份,方便选用一致的参考系进行旋转
self.start_quat: Optional[Quat] = None # 世界四元数
self.start_quat_local: Optional[Quat] = None # 父坐标系四元数
self.start_root_axis_dir: Optional[Vec3] = None
self.rotate_axis_world: Vec3 = Vec3(0, 0, 0) # 当前轴的世界方向
self.start_vec_on_plane: Vec3 = Vec3(0, 0, 0) # 旋转平面上的起始向量
self.center_world: Point3 = Point3(0, 0, 0) # Gizmo 中心(世界坐标)
self._trackball_start_vec: Optional[Vec3] = None # 屏幕空间轨迹球起始向量
self._angle_start_offset: float = 0.0 # 扇形起点(弧度)
self._drag_root_quat_world: Optional[Quat] = None # 拖拽时保持的 Gizmo 世界朝向
self._arrow_tangent_angle_deg: float = 0.0 # 箭头朝向(在扇形平面内的角度)
self._start_vec_local: Optional[Vec3] = None # 起始向量(扇形局部坐标)
# 撤销栈:每项为 (node, old_hpr(world), new_hpr(world))
self._rotate_undo_stack: list[Tuple[NodePath, Vec3, Vec3]] = []
self._undo_angle_epsilon: float = 0.01 # 角度变化阈值(防止浮点抖动)
# 边缘视角grazing angle时使用屏幕空间旋转的参数
self._grazing_threshold: float = 0.15 # denom 小于此值时启用屏幕空间计算
self._center_screen: Optional[Point2] = None # Gizmo 中心的屏幕投影
self._start_screen_angle: float = 0.0 # 起始鼠标相对于屏幕中心的角度
self._axis_screen_sign: float = 1.0 # 屏幕空间旋转方向符号
# 三个圆环节点0=X, 1=Y, 2=Z
self.rings: list[NodePath] = []
self.ring_colors: list[Tuple[NodePath, Vec4]] = []
# (front_geom_np, back_geom_np, front_col_np, back_col_np)
self.ring_halves: list[Tuple[NodePath,NodePath, NodePath, NodePath]] = []
self.center_axes: list[NodePath] = []
self.center_axes_colors: list[Tuple[NodePath, Vec4]] = []
self.view_ring: Optional[NodePath] = None
self.trackball_np: Optional[NodePath] = None
self.angle_disc_root: Optional[NodePath] = None
self.angle_fill_np: Optional[NodePath] = None
self.angle_fill_np_copy: Optional[NodePath] = None
self.angle_arrow_np: Optional[NodePath] = None
# 构建几何与碰撞体
self._build_gizmo()
# 初始挂在 world.render后续通过更新同步到目标
self.root.reparentTo(self.world.render)
self.root.setBin('fixed',40)
self.root.setDepthTest(False)
self.root.setDepthWrite(False)
self.root.setLightOff()
self.root.hide()
# 每帧更新 Gizmo 缩放
# taskMgr.add(self._update_task, "RotateGizmoUpdateTask")
# ------------------------------------------------------------------ #
# 内部工具
# ------------------------------------------------------------------ #
def _log(self, msg: str):
if self.debug:
print(f"[RotateGizmo] {msg}")
def _normalize_event_hooks(self, hooks):
base = {name: [] for name in GizmoEvent.ALL}
if not hooks:
return base
for name in list(base.keys()):
cbs = hooks.get(name)
if cbs:
base[name] = list(cbs)
return base
def _emit_event(self, name: str, payload: Dict[str, Any]):
handlers = self._event_hooks.get(name, [])
for cb in handlers:
try:
cb(payload)
except Exception as exc:
self._log(f"event hook '{name}' error: {exc}")
def _build_gizmo(self):
"""构建旋转 Gizmo 的可视化元素(轴环、屏幕外圈、轨迹球、角度扇形等)。"""
# 屏幕对齐的轨迹球,用于自由旋转
# 轨迹球:默认隐藏可视几何,仅在选中时显示
self.trackball_np = self._create_trackball_disc(
radius=self.trackball_radius,
color=self.trackball_color,
visual=True,
)
if self.trackball_np:
trackball_geom = self.trackball_np.find("**/trackball_geom")
if not trackball_geom.isEmpty():
trackball_geom.hide()
# X 轴(红)—— 绕局部 X 轴旋转
self.rings.append(
self._create_ring(
axis_dir=Vec3(1, 0, 0),
color=Vec4(1, 0, 0, 1),
radius=self.ring_radius,
thickness=self.ring_thickness,
axis_id=0,
)
)
# Y 轴(绿)—— 绕局部 Y 轴旋转
self.rings.append(
self._create_ring(
axis_dir=Vec3(0, 1, 0),
color=Vec4(0, 1, 0, 1),
radius=self.ring_radius,
thickness=self.ring_thickness,
axis_id=1,
)
)
# Z 轴(蓝)—— 绕局部 Z 轴旋转
self.rings.append(
self._create_ring(
axis_dir=Vec3(0, 0, 1),
color=Vec4(0, 0, 1, 1),
radius=self.ring_radius,
thickness=self.ring_thickness,
axis_id=2,
)
)
# 轨迹球边缘辅助圆,标示球的尺寸(无碰撞)
self.trackball_edge = self._create_trackball_edge(
radius=self.trackball_radius,
color=self.trackball_edge_color,
)
# 中心轴线,帮助观察旋转轴朝向
self.center_axes = [
self._create_center_axis(Vec3(1, 0, 0), Vec4(0.3, 0.0, 0.0, 1.0)),
self._create_center_axis(Vec3(0, 1, 0), Vec4(0.0, 0.3, 0.0, 1.0)),
self._create_center_axis(Vec3(0, 0, 1), Vec4(0.0, 0.0, 0.3, 1.0)),
]
# 角度可视化扇形
self._build_angle_disc()
def _create_ring(
self,
axis_dir: Vec3,
color: Vec4,
radius: float,
thickness: float,
axis_id: int,
) -> NodePath:
"""
构建一个圆环:
- 几何:使用 GeomLines 画出若干线段组成的圆
- 碰撞体:使用多段 CollisionTube 近似圆环
默认圆环在局部 XY 平面,法线为 +Z通过四元数旋转到指定 axis_dir。
"""
ring_root = self.root.attachNewNode(f"ring_{axis_id}")
# 本地 +Z 对齐到目标“旋转轴方向”(即圆环法线)
align_quat = self._quat_from_z(axis_dir)
ring_root.setQuat(align_quat)
# -------------------- 几何圆环 --------------------
vdata = GeomVertexData("ring", GeomVertexFormat.getV3c4(), Geom.UHStatic)
vertex = GeomVertexWriter(vdata, "vertex")
vcolor = GeomVertexWriter(vdata, "color")
segments = 64
for i in range(segments + 1):
angle = 2 * math.pi * i / segments
x = radius * math.cos(angle)
y = radius * math.sin(angle)
vertex.addData3(x, y, 0.0)
vcolor.addData4(color)
# 将圆拆分为两个半圆环,方便根据摄像机方向隐藏背面的半圆。
half_segments = segments // 2
# 前半圆:索引 [0, half_segments]
prim_front = GeomLines(Geom.UHStatic)
for i in range(half_segments):
prim_front.addVertices(i, i + 1)
geom_front = Geom(vdata)
geom_front.addPrimitive(prim_front)
node_front = GeomNode("ring_geom_front")
node_front.addGeom(geom_front)
np_front = ring_root.attachNewNode(node_front)
np_front.setTransparency(True)
np_front.setColorScale(1, 1, 1, self.ring_alpha)
# 后半圆:索引 [half_segments, segments]
prim_back = GeomLines(Geom.UHStatic)
for i in range(half_segments, segments):
prim_back.addVertices(i, i + 1)
geom_back = Geom(vdata)
geom_back.addPrimitive(prim_back)
node_back = GeomNode("ring_geom_back")
node_back.addGeom(geom_back)
np_back = ring_root.attachNewNode(node_back)
np_back.setTransparency(True)
np_back.setColorScale(1, 1, 1, self.ring_alpha)
# -------------------- 碰撞圆环拆分为两半 --------------------
col_segments = 24
half_col = col_segments // 2
c_node_front = CollisionNode(f"ring_col_front_{axis_id}")
c_node_front.setIntoCollideMask(BitMask32.bit(20))
c_node_front.setFromCollideMask(BitMask32.allOff())
c_node_front.setTag("gizmo_axis", str(axis_id))
for i in range(half_col):
a0 = 2 * math.pi * i / col_segments
a1 = 2 * math.pi * (i + 1) / col_segments
p0 = Point3(radius * math.cos(a0), radius * math.sin(a0), 0.0)
p1 = Point3(radius * math.cos(a1), radius * math.sin(a1), 0.0)
c_node_front.addSolid(CollisionTube(p0, p1, thickness))
col_front_np = ring_root.attachNewNode(c_node_front)
c_node_back = CollisionNode(f"ring_col_back_{axis_id}")
c_node_back.setIntoCollideMask(BitMask32.bit(20))
c_node_back.setFromCollideMask(BitMask32.allOff())
c_node_back.setTag("gizmo_axis", str(axis_id))
for i in range(half_col, col_segments):
a0 = 2 * math.pi * i / col_segments
a1 = 2 * math.pi * (i + 1) / col_segments
p0 = Point3(radius * math.cos(a0), radius * math.sin(a0), 0.0)
p1 = Point3(radius * math.cos(a1), radius * math.sin(a1), 0.0)
c_node_back.addSolid(CollisionTube(p0, p1, thickness))
col_back_np = ring_root.attachNewNode(c_node_back)
# 默认先都显示,后续在 update() 中根据摄像机位置只显示靠近摄像机的一半(含碰撞)
self.ring_halves.append((np_front, np_back, col_front_np, col_back_np))
return ring_root
def _create_center_axis(self, axis_dir: Vec3, color: Vec4) -> NodePath:
"""创建中心轴线,方便观察轴向。"""
vdata = GeomVertexData(
"center_axis", GeomVertexFormat.getV3c4(), Geom.UHStatic)
vertex = GeomVertexWriter(vdata, "vertex")
vcolor = GeomVertexWriter(vdata, "color")
vertex.addData3(0.0, 0.0, 0.0)
vcolor.addData4(color)
tip = axis_dir * (self.ring_radius * 0.8)
vertex.addData3(tip)
vcolor.addData4(color)
lines = GeomLines(Geom.UHStatic)
lines.addVertices(0, 1)
geom = Geom(vdata)
geom.addPrimitive(lines)
node = GeomNode("center_axis_geom")
node.addGeom(geom)
np = self.root.attachNewNode(node)
np.setTransparency(True)
np.setColorScale(1, 1, 1, 0.9)
return np
def _create_trackball_edge(self, radius: float, color: Vec4) -> NodePath:
"""创建轨迹球尺寸辅助圆(无碰撞,仅显示)。"""
edge_root = self.root.attachNewNode("trackball_edge")
vdata = GeomVertexData("trackball_edge", GeomVertexFormat.getV3c4(), Geom.UHStatic)
vertex = GeomVertexWriter(vdata, "vertex")
vcolor = GeomVertexWriter(vdata, "color")
segments = 96
for i in range(segments + 1):
ang = 2 * math.pi * i / segments
x = radius * math.cos(ang)
z = radius * math.sin(ang)
vertex.addData3(x, 0.0, z)
vcolor.addData4(color)
prim = GeomLines(Geom.UHStatic)
for i in range(segments):
prim.addVertices(i, i + 1)
geom = Geom(vdata)
geom.addPrimitive(prim)
node = GeomNode("trackball_edge_geom")
node.addGeom(geom)
np_geom = edge_root.attachNewNode(node)
np_geom.setRenderModeThickness(1.5)
np_geom.setBillboardPointEye()
np_geom.setTransparency(True)
np_geom.setColorScale(color)
return edge_root
def _create_trackball_disc(self, radius: float, color: Vec4, visual: bool = True) -> NodePath:
"""创建屏幕对齐的轨迹球圆盘。visual=False 时仅保留拾取体。"""
disc_root = self.root.attachNewNode("trackball_disc")
if visual:
np_geom = self.make_sphere(radius=radius,color=color)
np_geom.set_name("trackball_geom")
np_geom.wrt_reparent_to(disc_root)
np_geom.setTransparency(True)
# 拾取使用球体,方便捕获整个圆盘区域
c_node = CollisionNode("trackball_col")
c_node.setIntoCollideMask(BitMask32.bit(20))
c_node.setFromCollideMask(BitMask32.allOff())
c_node.setTag("gizmo_handle", "trackball")
c_node.addSolid(CollisionSphere(0, 0, 0, radius + self.ring_thickness * 0.25))
disc_root.attachNewNode(c_node)
return disc_root
def make_sphere(self,radius=0.5, lat_steps=32, lon_steps=64, color=(1,1,1,0.5)) -> NodePath:
fmt = GeomVertexFormat.getV3c4()
vdata = GeomVertexData("sphere", fmt, Geom.UHStatic)
vw = GeomVertexWriter(vdata, "vertex")
cw = GeomVertexWriter(vdata, "color")
tris = GeomTriangles(Geom.UHStatic)
# 顶点
for i in range(lat_steps + 1): # 纬度 0..pi
theta = math.pi * i / lat_steps
st, ct = math.sin(theta), math.cos(theta)
for j in range(lon_steps): # 经度 0..2pi
phi = 2 * math.pi * j / lon_steps
sp, cp = math.sin(phi), math.cos(phi)
x, y, z = radius * cp * st, radius * sp * st, radius * ct
vw.addData3(x, y, z)
cw.addData4(*color)
# 索引
def vid(i, j): # i:0..lat_steps, j:0..lon_steps-1
return i * lon_steps + (j % lon_steps)
for i in range(lat_steps):
for j in range(lon_steps):
a = vid(i, j)
b = vid(i+1, j)
c = vid(i+1, j+1)
d = vid(i, j+1)
tris.addVertices(a, b, c)
tris.addVertices(a, c, d)
geom = Geom(vdata); geom.addPrimitive(tris)
node = GeomNode("hi_sphere"); node.addGeom(geom)
np = NodePath(node)
return np
def _build_angle_disc(self):
"""搭建旋转角度的可视化扇形。"""
self.angle_disc_root = self.root.attachNewNode("angle_disc_root")
self.angle_disc_root.setTransparency(TransparencyAttrib.M_alpha)
self.angle_disc_root.setTwoSided(True)
self.angle_disc_root.hide()
self.angle_fill_np = self.angle_disc_root.attachNewNode(GeomNode("angle_sector"))
self.angle_fill_np.setTransparency(TransparencyAttrib.M_alpha)
self.angle_fill_np.setColor(0.9, 0.9, 0.9, 0.5)
# 末端箭头
self.angle_arrow_np = self._create_angle_arrow(
self.angle_disc_root,
self.ring_radius * self.arrow_length_factor,
self.arrow_head_width_factor,
)
self.angle_arrow_np.setColor(1.0, 1.0, 1.0, 1.0)
self.angle_arrow_np.hide()
def _create_angle_disc_half(self, parent: NodePath, mirror: bool = False) -> NodePath:
"""创建角度盘的一半,方便做基础背景。"""
segments = 50
angle = math.pi / segments
angle = -angle if mirror else angle
offset = math.pi * 0.5
vdata = GeomVertexData("angle_disc_half", GeomVertexFormat.getV3(), Geom.UHStatic)
vertex = GeomVertexWriter(vdata, "vertex")
for i in range(segments + 1):
x = math.cos(angle * i - offset)
y = math.sin(angle * i - offset)
vertex.addData3(x, y, 0.0)
prim = GeomTriangles(Geom.UHStatic)
for i in range(1, segments):
prim.addVertices(0, i, i + 1)
geom = Geom(vdata)
geom.addPrimitive(prim)
node = GeomNode("angle_disc_half_node")
node.addGeom(geom)
half_np = parent.attachNewNode(node)
half_np.setTransparency(TransparencyAttrib.M_alpha)
half_np.setColor(1, 1, 1, 0.1)
return half_np
def _create_angle_arrow(self, parent: NodePath, length: float, head_width_factor: float) -> NodePath:
"""创建显示末端角度的箭头。"""
vdata = GeomVertexData(
"angle_arrow", GeomVertexFormat.getV3(), Geom.UHStatic)
vertex = GeomVertexWriter(vdata, "vertex")
# 箭头沿 +X 方向,从原点出发,便于通过平面内旋转对齐切线
head_w = max(length * head_width_factor, length * 0.02)
vertex.addData3(0.0, 0.0, 0.0)
vertex.addData3(length, 0.0, 0.0)
vertex.addData3(length * 0.7, head_w, 0.0)
vertex.addData3(length * 0.7, -head_w, 0.0)
prim = GeomLines(Geom.UHStatic)
prim.addVertices(0, 1)
prim.addVertices(1, 2)
prim.addVertices(1, 3)
geom = Geom(vdata)
geom.addPrimitive(prim)
node = GeomNode("angle_arrow_geom")
node.addGeom(geom)
np = parent.attachNewNode(node)
np.setTransparency(TransparencyAttrib.M_alpha)
np.setTwoSided(True)
return np
def _build_sector_geom(self, start_rad: float, end_rad: float) -> Geom:
"""根据起止弧度创建扇形几何。"""
radius = self.ring_radius
span = end_rad - start_rad
direction = 1.0 if span >= 0.0 else -1.0
span = abs(span)
segments = max(8, int(48 * (span / (2 * math.pi))))
vdata = GeomVertexData("angle_sector_data", GeomVertexFormat.getV3(), Geom.UHStatic)
vertex = GeomVertexWriter(vdata, "vertex")
# 中心点
vertex.addData3(0.0, 0.0, 0.0)
for i in range(segments + 1):
a = start_rad + direction * (span * i / segments)
x = radius * math.cos(a)
y = radius * math.sin(a)
vertex.addData3(x, y, 0.0)
prim = GeomTriangles(Geom.UHStatic)
for i in range(1, segments + 1):
prim.addVertices(0, i, i + 1)
geom = Geom(vdata)
geom.addPrimitive(prim)
return geom
def _quat_from_z(self, direction: Vec3) -> Quat:
"""
与 MoveGizmo 保持一致:
返回一个四元数,使本地 +Z 旋转到指定 direction自动归一化
"""
dir_norm = Vec3(direction)
if dir_norm.length_squared() == 0:
return Quat.identQuat()
dir_norm.normalize()
z_axis = Vec3(0, 0, 1)
dot = z_axis.dot(dir_norm)
if abs(dot - 1.0) < 1e-6:
return Quat.identQuat() # 已经对齐
if abs(dot + 1.0) < 1e-6:
# 反向,绕任意垂直于 Z 的轴旋转 180°
ortho = Vec3(1, 0, 0) if abs(z_axis.dot(
Vec3(1, 0, 0))) < 0.9 else Vec3(0, 1, 0)
axis = z_axis.cross(ortho)
axis.normalize()
q = Quat()
q.setFromAxisAngle(180.0, axis)
return q
axis = z_axis.cross(dir_norm)
axis_len = axis.length()
if axis_len == 0:
return Quat.identQuat()
axis.normalize()
angle_rad = math.atan2(axis_len, dot)
q = Quat()
q.setFromAxisAngle(math.degrees(angle_rad), axis)
return q
def _begin_angle_visual(self, axis_world: Vec3):
"""开始显示角度扇形,基于当前旋转轴和起始向量。"""
if self.angle_disc_root is None:
return
if axis_world.length_squared() == 0:
return
align_q = self._quat_from_z(axis_world)
self.angle_disc_root.setQuat(self.world.render, align_q)
self.angle_disc_root.setPos(self.root, 0, 0, 0)
# 计算起始向量在扇形平面上的角度,作为扇形起点
if self.start_vec_on_plane.length_squared() > 0:
q_inv = Quat(self.angle_disc_root.getQuat(self.world.render))
q_inv.invertInPlace()
local_vec = q_inv.xform(self.start_vec_on_plane)
if local_vec.length_squared() == 0:
local_vec = Vec3(1, 0, 0)
local_vec.normalize()
self._start_vec_local = local_vec
self._angle_start_offset = math.atan2(local_vec.y, local_vec.x)
# 箭头朝向:起始向量的切线方向(轴 x start_vec并记录为局部平面角度
axis_world_norm = Vec3(axis_world)
axis_world_norm.normalize()
tangent_world = axis_world_norm.cross(self.start_vec_on_plane)
if tangent_world.length_squared() == 0:
tangent_world = Vec3(-self.start_vec_on_plane.y,
self.start_vec_on_plane.x, 0)
tangent_world.normalize()
tangent_local = q_inv.xform(tangent_world)
self._arrow_tangent_angle_deg = math.degrees(
math.atan2(tangent_local.y, tangent_local.x))
# 将箭头放在起始点位置,面向相机
if self.angle_arrow_np:
self.angle_arrow_np.setPos(
self.angle_disc_root,
Vec3(local_vec.x * self.ring_radius,
local_vec.y * self.ring_radius,
0.0),
)
# 初始朝向即切线方向
self.angle_arrow_np.setH(self._arrow_tangent_angle_deg)
else:
self._start_vec_local = Vec3(1, 0, 0)
self._angle_start_offset = 0.0
self._arrow_tangent_angle_deg = 90.0
# 初始化扇形(零度)
self._update_angle_sector(
self._angle_start_offset, self._angle_start_offset)
self.angle_disc_root.show()
if self.angle_arrow_np:
self.angle_arrow_np.hide()
def _update_angle_visual(self, delta_deg: float):
"""根据当前旋转角度更新扇形与箭头。"""
if self.angle_disc_root is None or self.angle_fill_np is None:
return
start_rad = self._angle_start_offset
end_rad = start_rad + math.radians(delta_deg)
self._update_angle_sector(start_rad, end_rad)
if self.angle_arrow_np:
arrow_angle = self._arrow_tangent_angle_deg
if delta_deg < 0.0:
arrow_angle += 180.0
self.angle_arrow_np.setH(arrow_angle)
if self.angle_arrow_np.isHidden():
self.angle_arrow_np.show()
def _update_angle_sector(self, start_rad: float, end_rad: float):
"""生成并替换当前的角度扇形几何。"""
if self.angle_fill_np is None:
return
geom = self._build_sector_geom(start_rad, end_rad)
node = self.angle_fill_np.node()
node.removeAllGeoms()
node.addGeom(geom)
self.angle_fill_np.setTwoSided(True)
def _hide_angle_disc(self):
"""结束旋转时隐藏角度扇形。"""
if self.angle_disc_root:
self.angle_disc_root.hide()
if self.angle_fill_np:
self.angle_fill_np.node().removeAllGeoms()
if self.angle_arrow_np:
self.angle_arrow_np.hide()
self._angle_start_offset = 0.0
def _map_to_trackball(self, mpos: Point3) -> Vec3:
"""将屏幕坐标映射到轨迹球上的向量。"""
x = mpos.x
y = mpos.y
length_sq = x * x + y * y
if length_sq <= 1.0:
z = math.sqrt(max(0.0, 1.0 - length_sq))
v = Vec3(x, y, z)
else:
v = Vec3(x, y, 0.0)
v.normalize()
return v
def _apply_trackball_rotation(self, mpos: Point3):
"""轨迹球旋转计算:基于屏幕向量的弧球算法。"""
if self._trackball_start_vec is None or self.start_quat is None:
return
curr_vec = self._map_to_trackball(mpos)
axis_cam = self._trackball_start_vec.cross(curr_vec)
axis_len_sq = axis_cam.length_squared()
if axis_len_sq < 1e-8:
return
axis_cam.normalize()
dot = max(min(self._trackball_start_vec.dot(curr_vec), 1.0), -1.0)
angle_rad = math.acos(dot)
axis_world = self.camera.getQuat(self.world.render).xform(axis_cam)
q_delta = Quat()
q_delta.setFromAxisAngle(math.degrees(angle_rad), axis_world)
new_quat_world = q_delta * self.start_quat
self.target_node.setQuat(self.world.render, new_quat_world)
self._emit_event(
GizmoEvent.DRAG_MOVE,
{
"gizmo": "rotate",
"mode": "trackball",
"axis": None,
"target": self.target_node,
"mouse": Point2(mpos.x, mpos.y),
"delta_deg": math.degrees(angle_rad),
},
)
# ------------------------------------------------------------------ #
# 公共接口:绑定 / 解绑
# ------------------------------------------------------------------ #
def attach(self, node_path: NodePath):
"""
绑定控制柄到指定 NodePath。
当前实现采用 Unity 类似的“世界模式Global
- Gizmo 的三个旋转轴始终对齐世界坐标系的 X/Y/Z 方向。
"""
if not node_path:
return
self.target_node = node_path
self.attached = True
self._update_root_transform()
self.root.show()
self._log(
f"attached to {node_path.getName()} "
f"world_pos={self.root.getPos(self.world.render)} "
f"target_hpr={node_path.getHpr(self.world.render)}"
)
# 根据当前相机位置初始化半圆环的正反显示
self._update_ring_halves_visibility()
self._register_events()
def detach(self):
"""解绑并隐藏 Gizmo。"""
self.target_node = None
self.root.hide()
self.attached = False
self.dragging = False
self.drag_axis = None
self.drag_mode = None
self._trackball_start_vec = None
self._drag_root_quat_world = None
self.is_hovering = False
self._hide_angle_disc()
self._reset_highlights()
self._ignore_events()
self._log("detached")
def _update_ring_halves_visibility(self):
"""根据摄像机与 Gizmo 相对位置,切换每个轴的前/后半圆显示。"""
if not self.camera:
return
cam_pos = self.camera.getPos(self.world.render)
center = self.root.getPos(self.world.render)
view_vec = cam_pos - center
for axis_id, ring_root in enumerate(self.rings):
if axis_id >= len(self.ring_halves):
continue
front_np, back_np, col_front_np, col_back_np = self.ring_halves[axis_id]
if front_np.isEmpty() or back_np.isEmpty():
continue
# 圆环所在平面的法线(世界坐标)
plane_normal = ring_root.getQuat(self.world.render).xform(Vec3(0, 0, 1))
if plane_normal.length_squared() == 0:
continue
plane_normal.normalize()
# 将摄像机方向投影到圆环平面内,确定“近半圆”切分线的朝向
d = view_vec - plane_normal * view_vec.dot(plane_normal)
if d.length_squared() == 0:
# 摄像机几乎正对法线,保持显示近侧半圆
front_np.show()
back_np.hide()
if not col_front_np.isEmpty():
col_front_np.node().setIntoCollideMask(BitMask32.bit(20))
if not col_back_np.isEmpty():
col_back_np.node().setIntoCollideMask(BitMask32.allOff())
front_np.setHpr(ring_root, 0, 0, 0)
back_np.setHpr(ring_root, 0, 0, 0)
continue
q_world = ring_root.getQuat(self.world.render)
q_inv = Quat(q_world)
q_inv.invertInPlace()
d_local = q_inv.xform(d)
if d_local.length_squared() == 0:
front_np.show()
back_np.hide()
if not col_front_np.isEmpty():
col_front_np.node().setIntoCollideMask(BitMask32.bit(20))
if not col_back_np.isEmpty():
col_back_np.node().setIntoCollideMask(BitMask32.allOff())
if not col_front_np.isEmpty():
col_front_np.setHpr(ring_root, 0, 0, 0)
if not col_back_np.isEmpty():
col_back_np.setHpr(ring_root, 0, 0, 0)
continue
d_local.normalize()
# 将半圆几何整体绕轴旋转,让切分线对齐相机投影方向
angle = math.atan2(d_local.y, d_local.x)
# 将近侧半圆的中心对准相机投影方向(边界垂直于视线投影)
angle_deg = math.degrees(angle) - 90.0
front_np.setHpr(ring_root, angle_deg, 0, 0)
back_np.setHpr(ring_root, angle_deg, 0, 0)
if not col_front_np.isEmpty():
col_front_np.setHpr(ring_root, angle_deg, 0, 0)
if not col_back_np.isEmpty():
col_back_np.setHpr(ring_root, angle_deg, 0, 0)
# 近侧半圆显示,远侧隐藏
front_np.show()
back_np.hide()
if not col_front_np.isEmpty():
col_front_np.node().setIntoCollideMask(BitMask32.bit(20))
if not col_back_np.isEmpty():
col_back_np.node().setIntoCollideMask(BitMask32.allOff())
# ------------------------------------------------------------------ #
# 撤销支持(可选使用)
# ------------------------------------------------------------------ #
def _record_rotate_action(self, node: NodePath, old_hpr: Vec3, new_hpr: Vec3):
"""将一次旋转操作压入撤销栈,并在需要时通知外部监听者。"""
if node is None or node.isEmpty():
return
if old_hpr is None or new_hpr is None:
return
delta = new_hpr - old_hpr
if (
abs(delta.x) < self._undo_angle_epsilon
and abs(delta.y) < self._undo_angle_epsilon
and abs(delta.z) < self._undo_angle_epsilon
):
return
old_copy = Vec3(old_hpr)
new_copy = Vec3(new_hpr)
self._rotate_undo_stack.append((node, old_copy, new_copy))
self._log(
f"record rotate action: node={node.getName()} "
f"old_hpr={old_copy} new_hpr={new_copy} stack_size={len(self._rotate_undo_stack)}"
)
# 把这次旋转操作上报给 TransformGizmo用于全局撤销栈
if self.on_action_committed is not None:
try:
self.on_action_committed(
{
"kind": "rotate",
"node": node,
"old_hpr": Vec3(old_copy),
"new_hpr": Vec3(new_copy),
}
)
except Exception as exc:
self._log(f"on_action_committed(rotate) error: {exc}")
def undo_last_rotate(self):
"""
撤销最近一次旋转操作。
可在 UI 中绑定快捷键调用。
"""
if not self._rotate_undo_stack:
self._log("undo_last_rotate: stack empty")
return
node, old_hpr, new_hpr = self._rotate_undo_stack.pop()
if node is None or node.isEmpty():
self._log("undo_last_rotate: target node invalid")
return
node.setHpr(self.world.render, old_hpr)
self._log(
f"undo_last_rotate: node={node.getName()} "
f"old_hpr={old_hpr} new_hpr={new_hpr} remaining={len(self._rotate_undo_stack)}"
)
# ------------------------------------------------------------------ #
# 事件注册 / 取消
# ------------------------------------------------------------------ #
def _register_events(self):
"""注册鼠标事件监听。"""
self.accept("mouse1", self._on_mouse_down)
self.accept("mouse1-up", self._on_mouse_up)
self.accept("mouse2-up", self._on_mouse_up)
self.accept("mouse3-up", self._on_mouse_up)
self.accept("mouse-move", self._on_mouse_move)
self._log("event listeners registered (mouse)")
def _ignore_events(self):
"""取消事件监听。"""
self.ignore("mouse1")
self.ignore("mouse1-up")
self.ignore("mouse2-up")
self.ignore("mouse3-up")
self.ignore("mouse-move")
self._log("event listeners removed")
# ------------------------------------------------------------------ #
# 鼠标坐标工具
# ------------------------------------------------------------------ #
def _get_normalized_mouse(self, extra) -> Optional[Point3]:
"""
将鼠标转换到 Panda3D 的标准化设备坐标 [-1, 1]。
优先使用 Qt / 外部 UI 传入的像素坐标extra 字典),
如果没有,则回退到 Panda3D 的 mouseWatcherNode。
"""
if self.world.mouseWatcherNode.hasMouse():
mouse = self.world.mouseWatcherNode.getMouse()
return Point3(mouse.x, mouse.y, 0)
# 1) 外部 UI通过 messenger 传入像素坐标
if isinstance(extra, dict) and "x" in extra and "y" in extra:
parent = getattr(self.world, "parent", None)
if parent is not None:
w = max(parent.width(), 1)
h = max(parent.height(), 1)
nx = (extra["x"] / w) * 2.0 - 1.0
# Qt 原点在左上Panda3D 原点在中心Y 轴向上
ny = 1.0 - (extra["y"] / h) * 2.0
return Point3(nx, ny, 0.0)
return None
# ------------------------------------------------------------------ #
# 屏幕空间旋转辅助方法
# ------------------------------------------------------------------ #
def _compute_screen_space_params(self, mpos: Point3):
"""
计算屏幕空间旋转所需的参数:
- Gizmo 中心在屏幕上的投影位置
- 旋转轴在屏幕上的投影方向2D向量
- 起始鼠标位置
"""
if not self.camera:
return
lens = self.camera.node().getLens()
# 将 Gizmo 中心投影到屏幕空间
center_cam = self.camera.getRelativePoint(
self.world.render, self.center_world)
center_2d = Point2()
if lens.project(center_cam, center_2d):
self._center_screen = center_2d
else:
self._center_screen = Point2(0, 0)
# 将旋转轴末端投影到屏幕空间,计算轴在屏幕上的方向
axis_end_world = self.center_world + self.rotate_axis_world
axis_end_cam = self.camera.getRelativePoint(
self.world.render, axis_end_world)
axis_end_2d = Point2()
if lens.project(axis_end_cam, axis_end_2d):
# 屏幕上的轴方向向量
self._axis_screen_dir = Point2(
axis_end_2d.x - self._center_screen.x,
axis_end_2d.y - self._center_screen.y
)
# 归一化
axis_len = math.sqrt(
self._axis_screen_dir.x ** 2 + self._axis_screen_dir.y ** 2)
if axis_len > 1e-6:
self._axis_screen_dir = Point2(
self._axis_screen_dir.x / axis_len,
self._axis_screen_dir.y / axis_len
)
else:
self._axis_screen_dir = Point2(0, 1)
else:
self._axis_screen_dir = Point2(0, 1)
# 记录起始鼠标位置
self._start_mouse_screen = Point2(mpos.x, mpos.y)
# 旋转灵敏度(屏幕单位到角度的转换系数)
self._screen_sensitivity = 180.0 # 移动1个屏幕单位 = 180度
self._log(
f"screen params: center={self._center_screen} "
f"axis_dir={self._axis_screen_dir} "
f"start_mouse={self._start_mouse_screen}"
)
def _calc_screen_space_angle(self, mpos: Point3) -> float:
"""
使用屏幕空间计算旋转角度(度)。
基于鼠标移动在旋转轴屏幕投影的垂直方向上的分量。
"""
if self._center_screen is None or self._axis_screen_dir is None:
self._log("screen params not ready")
return 0.0
# 鼠标移动向量(从起始位置)
move_x = mpos.x - self._start_mouse_screen.x
move_y = mpos.y - self._start_mouse_screen.y
# 轴的垂直方向屏幕空间将轴方向旋转90度
# 原方向 (ax, ay),垂直方向 (-ay, ax)
perp_x = -self._axis_screen_dir.y
perp_y = self._axis_screen_dir.x
# 鼠标移动在垂直方向上的投影(点积)
perp_component = move_x * perp_x + move_y * perp_y
# 转换为角度
delta_deg = perp_component * self._screen_sensitivity
self._log(
f"screen calc: mouse=({mpos.x:.3f},{mpos.y:.3f}) "
f"move=({move_x:.3f},{move_y:.3f}) "
f"perp=({perp_x:.3f},{perp_y:.3f}) "
f"perp_comp={perp_component:.4f} delta={delta_deg:.2f}"
)
# print(f"screen calc: mouse=({mpos.x:.3f},{mpos.y:.3f}) ")
# print(f"move=({move_x:.3f},{move_y:.3f}) ")
# print(f"perp=({perp_x:.3f},{perp_y:.3f}) ")
# print(f"perp_comp={perp_component:.4f} delta={delta_deg:.2f}")
return -delta_deg
# ------------------------------------------------------------------ #
# 鼠标事件:按下 / 弹起 / 移动
# ------------------------------------------------------------------ #
def _on_mouse_down(self, extra=None):
if not self.attached or not self.camera or self.target_node is None:
return
mpos = self._get_normalized_mouse(extra)
if mpos is None:
self._log("mouse_down ignored: no mouse pos")
return
# 设置拾取射线
self.picker_np.reparentTo(self.camera)
if not self._picker_added:
self.c_trav.addCollider(self.picker_np, self.c_queue)
self._picker_added = True
self.picker_ray.setFromLens(self.camera.node(), mpos.x, mpos.y)
self.c_queue.clearEntries()
self.c_trav.traverse(self.root)
self._log(
f"mouse_down mpos={mpos} cam_pos={self.camera.getPos(self.world.render)} "
f"cam_hpr={self.camera.getHpr(self.world.render)} "
f"entries={self.c_queue.getNumEntries()}"
)
num_entries = self.c_queue.getNumEntries()
if num_entries <= 0:
self._log("mouse_down: no collision entries on rotate gizmo")
return
self.c_queue.sortEntries()
axis_entry: Optional[CollisionEntry] = None
trackball_entry: Optional[CollisionEntry] = None
picked_kind: Optional[str] = None # "axis" / "view" / "trackball"
picked_axis_id: Optional[int] = None
for i in range(num_entries):
e = self.c_queue.getEntry(i)
axis_tag = e.getIntoNode().getTag("gizmo_axis")
handle_tag = e.getIntoNode().getTag("gizmo_handle")
if axis_tag:
axis_entry = e
picked_axis_id = int(axis_tag)
break
if handle_tag == "view":
# 外圈已移除,忽略
continue
elif handle_tag == "trackball" and trackball_entry is None:
trackball_entry = e
if axis_entry is not None:
picked_kind = "axis"
elif trackball_entry is not None:
axis_entry = trackball_entry
picked_kind = "trackball"
if axis_entry is None or picked_kind is None:
self._log("mouse_down: hit something but no gizmo handle tag")
return
self.dragging = True
self.drag_mode = "axis" if picked_kind == "axis" else picked_kind
self.drag_axis = picked_axis_id if self.drag_mode == "axis" else None
self.center_world = self.root.getPos(self.world.render)
self.start_quat = self.target_node.getQuat(self.world.render)
self.start_quat_local = self.target_node.getQuat()
self.start_mouse = Point2(mpos.x, mpos.y)
self._trackball_start_vec = None
self._drag_root_quat_world = self.root.getQuat(self.world.render)
self._hide_angle_disc()
# 轨迹球模式:屏幕空间自由旋转,不需要射线平面求交
if self.drag_mode == "trackball":
self._trackball_start_vec = self._map_to_trackball(mpos)
self._highlight_axis("trackball")
self._emit_event(
GizmoEvent.DRAG_START,
{
"gizmo": "rotate",
"mode": self.drag_mode,
"axis": self.drag_axis,
"target": self.target_node,
"mouse": Point2(mpos.x, mpos.y),
"start_quat": self.start_quat,
},
)
return
# 以“可见圆环”的朝向为准,避免目标节点已有旋转时出现视觉/数学轴不一致
if self.drag_mode == "axis" and self.drag_axis is not None:
ring_np = self.rings[self.drag_axis]
# 环所在平面法线:本地 +Z
ring_axis_local = ring_np.getQuat().xform(Vec3(0, 0, 1))
if ring_axis_local.length_squared() == 0:
ring_axis_local = Vec3(0, 0, 1)
ring_axis_local.normalize()
self.drag_local_axis = ring_axis_local # 父坐标系下的轴(与控件视觉一致)
if self.drag_axis == 0:
self.start_root_axis_dir = Quat(self.root.getQuat()).getRight()
elif self.drag_axis == 1:
self.start_root_axis_dir = Quat(
self.root.getQuat()).getForward()
elif self.drag_axis == 2:
self.start_root_axis_dir = Quat(self.root.getQuat()).getUp()
# 使用圆环本身的朝向来定义平面法线(可见轴与数学轴一致)
ring_np_world_quat = ring_np.getQuat(self.world.render)
axis_dir: Vec3 = ring_np_world_quat.xform(Vec3(0, 0, 1))
else:
# 视图对齐模式:轴朝向摄像机前向
axis_dir = self.camera.getQuat(self.world.render).getForward()
self.drag_local_axis = None
self.start_root_axis_dir = Vec3(axis_dir)
if axis_dir.length_squared() == 0:
axis_dir = Vec3(0, 0, 1)
axis_dir.normalize()
self.rotate_axis_world = axis_dir
# 计算当前鼠标射线与“旋转平面”(法线为当前轴,过 center_world之间的交点
lens: PerspectiveLens = self.camera.node().getLens()
p_from = Point3()
p_to = Point3()
hit_point: Optional[Point3] = None
if lens.extrude(Point2(mpos.x, mpos.y), p_from, p_to):
ray_origin = self.world.render.getRelativePoint(
self.camera, p_from)
ray_to = self.world.render.getRelativePoint(self.camera, p_to)
ray_dir = ray_to - ray_origin
if ray_dir.length_squared() != 0:
ray_dir.normalize()
n = self.rotate_axis_world
denom = ray_dir.dot(n)
if abs(denom) > 1e-6:
t = (self.center_world - ray_origin).dot(n) / denom
hit_point = ray_origin + ray_dir * t
# 如果无法求交(几乎平行),回退到“摄像机投影向量”
if hit_point is None:
cam_pos = self.camera.getPos(self.world.render)
v = cam_pos - self.center_world
n = self.rotate_axis_world
proj = v - n * v.dot(n)
if proj.length_squared() == 0:
proj = Vec3(1, 0, 0) if abs(n.x) < 0.9 else Vec3(0, 1, 0)
hit_point = self.center_world + proj
start_vec = hit_point - self.center_world
if start_vec.length_squared() == 0:
start_vec = Vec3(1, 0, 0)
start_vec.normalize()
self.start_vec_on_plane = start_vec
# 计算屏幕空间参数,用于边缘视角时的备用旋转计算
self._compute_screen_space_params(mpos)
# 初始化角度扇形
self._begin_angle_visual(self.rotate_axis_world)
self._highlight_axis(
self.drag_axis if self.drag_mode == "axis" else self.drag_mode)
if self.debug:
t_quat_world = self.target_node.getQuat(self.world.render)
t_quat_local = self.target_node.getQuat()
self._log(
" / ".join(
[
f"pick handle={self.drag_mode} axis={self.drag_axis} target={self.target_node.getName()}",
f"center={self.center_world}",
f"axis_world(from ring)={self.rotate_axis_world}",
f"axis_local(from ring)={self.drag_local_axis}",
f"start_vec={self.start_vec_on_plane}",
f"t_quat_world={t_quat_world}",
f"t_quat_local={t_quat_local}",
]
)
)
self._emit_event(
GizmoEvent.DRAG_START,
{
"gizmo": "rotate",
"mode": self.drag_mode,
"axis": self.drag_axis,
"target": self.target_node,
"mouse": Point2(mpos.x, mpos.y),
"start_quat": self.start_quat,
"center": Point3(self.center_world),
"axis_world": Vec3(self.rotate_axis_world),
},
)
def _on_mouse_up(self, extra=None):
self._update_ring_halves_visibility()
if self.dragging and self.target_node is not None:
try:
final_hpr = self.target_node.getHpr(self.world.render)
except Exception:
final_hpr = None
old_hpr = None
if self.start_quat is not None:
# 将起始四元数转换为 HPR世界坐标系
tmp_np = NodePath("tmp_quat_to_hpr")
tmp_np.reparentTo(self.world.render)
tmp_np.setQuat(self.world.render, self.start_quat)
old_hpr = tmp_np.getHpr(self.world.render)
tmp_np.removeNode()
if old_hpr is not None and final_hpr is not None:
self._record_rotate_action(
self.target_node, old_hpr, final_hpr)
mode = self.drag_mode
axis_id = self.drag_axis
self._emit_event(
GizmoEvent.DRAG_END,
{
"gizmo": "rotate",
"mode": mode,
"axis": axis_id,
"target": self.target_node,
"start_quat": self.start_quat,
"final_hpr": final_hpr if "final_hpr" in locals() else None,
},
)
self.dragging = False
self.drag_axis = None
self.drag_mode = None
self._trackball_start_vec = None
self._drag_root_quat_world = None
self._hide_angle_disc()
# 结束拖拽后恢复 Gizmo 与目标的对齐方式
if self.is_local:
self.root.setHpr(self.root.parent, 0, 0, 0)
else:
self.root.setHpr(self.world.render, 0, 0, 0)
self._reset_highlights()
self._log("mouse_up -> stop rotating")
def _on_mouse_move(self, extra=None):
if not self.attached or not self.camera:
return
mpos = self._get_normalized_mouse(extra)
if mpos is None:
if self.dragging:
self._log("mouse_move ignored: no mouse pos while rotating")
return
# Hover highlight when idle (not dragging).
if not self.dragging:
self._update_ring_halves_visibility()
self._update_hover_highlight(mpos)
return
if not self.target_node:
return
# 拖拽过程中持续更新半圆可见性与分割线朝向
self._update_ring_halves_visibility()
# 轨迹球模式:直接基于屏幕向量计算旋转
if self.drag_mode == "trackball":
self._apply_trackball_rotation(mpos)
return
lens: PerspectiveLens = self.camera.node().getLens()
p_from = Point3()
p_to = Point3()
if not lens.extrude(Point2(mpos.x, mpos.y), p_from, p_to):
self._log("mouse_move: lens.extrude failed")
return
ray_origin = self.world.render.getRelativePoint(self.camera, p_from)
ray_to = self.world.render.getRelativePoint(self.camera, p_to)
ray_dir = ray_to - ray_origin
if ray_dir.length_squared() == 0:
return
ray_dir.normalize()
# 与旋转平面求交:平面法线为当前轴向,过 Gizmo 中心
axis_world = Vec3(self.rotate_axis_world)
if axis_world.length_squared() == 0:
return
axis_world.normalize()
denom = ray_dir.dot(axis_world)
# 判断是否处于边缘视角grazing angle
use_screen_space = abs(denom) < self._grazing_threshold
if use_screen_space:
# 边缘视角:使用屏幕空间计算旋转角度
delta_deg = self._calc_screen_space_angle(mpos)
curr_vec = None
if self.debug:
self._log(
f"grazing angle detected: denom={denom:.4f}, "
f"using screen space, delta_deg={delta_deg:.2f}"
)
else:
# 正常视角:使用射线-平面交点计算
t = (self.center_world - ray_origin).dot(axis_world) / denom
hit_point = ray_origin + ray_dir * t
curr_vec = hit_point - self.center_world
if curr_vec.length_squared() == 0:
return
curr_vec.normalize()
# 计算 start_vec -> curr_vec 的有符号夹角(使用 atan2 提高数值稳定性)
s = self.start_vec_on_plane
dot = max(min(s.dot(curr_vec), 1.0), -1.0)
cross = s.cross(curr_vec)
angle_rad = math.atan2(cross.dot(axis_world), dot)
delta_deg = math.degrees(angle_rad)
# 视图圈使用世界模式,轴向旋转也可根据 is_local 切换
if self.drag_mode == "axis" and self.is_local:
# 本地模式:绕父坐标系下的轴旋转,保持与可见圆环一致。
if self.drag_local_axis is None or self.start_quat_local is None:
return
q_delta = Quat()
q_delta.setFromAxisAngle(delta_deg, self.drag_local_axis)
if self.debug:
self._log(
f"[RotateGizmo] local q_delta={q_delta} delta_deg={delta_deg} axis_world={axis_world} axis_local={self.drag_local_axis}"
)
new_quat_local = q_delta * self.start_quat_local
self.target_node.setQuat(new_quat_local)
else:
# 世界模式:绕固定的世界轴旋转,直接在世界坐标系应用。
q_delta = Quat()
axis_for_world = self.start_root_axis_dir or axis_world
if axis_for_world.length_squared() == 0:
axis_for_world = axis_world
else:
axis_for_world.normalize()
q_delta.setFromAxisAngle(delta_deg, axis_for_world)
if self.debug:
self._log(
f"[RotateGizmo] world q_delta={q_delta} delta_deg={delta_deg} axis_world={axis_world}"
)
new_quat_world = q_delta * self.start_quat
self.target_node.setQuat(self.world.render, new_quat_world)
# 更新角度扇形可视化
self._update_angle_visual(delta_deg)
# 拖拽时锁定 Gizmo 的世界朝向,避免扇形跟随目标旋转
if self._drag_root_quat_world is not None:
self.root.setQuat(self.world.render, self._drag_root_quat_world)
self._emit_event(
GizmoEvent.DRAG_MOVE,
{
"gizmo": "rotate",
"mode": self.drag_mode,
"axis": self.drag_axis,
"target": self.target_node,
"mouse": Point2(mpos.x, mpos.y),
"delta_deg": delta_deg,
},
)
if self.debug:
if use_screen_space:
self._log(
f"rotate handle={self.drag_mode} angle={delta_deg:.2f} center={self.center_world} n={axis_world} (screen space)"
)
else:
self._log(
f"rotate handle={self.drag_mode} axis={self.drag_axis} angle={delta_deg:.2f} "
f"center={self.center_world} n={axis_world} "
f"start_vec={self.start_vec_on_plane} curr_vec={curr_vec}"
)
def _update_hover_highlight(self, mpos: Point3):
"""Highlight the hovered rotate handle without starting a drag."""
self.picker_np.reparentTo(self.camera)
if not self._picker_added:
self.c_trav.addCollider(self.picker_np, self.c_queue)
self._picker_added = True
self.picker_ray.setFromLens(self.camera.node(), mpos.x, mpos.y)
self.c_queue.clearEntries()
self.c_trav.traverse(self.root)
num_entries = self.c_queue.getNumEntries()
if num_entries <= 0:
self._reset_highlights()
self.is_hovering = False
return
self.c_queue.sortEntries()
axis_entry: Optional[CollisionEntry] = None
trackball_entry: Optional[CollisionEntry] = None
for i in range(num_entries):
e = self.c_queue.getEntry(i)
axis_tag = e.getIntoNode().getTag("gizmo_axis")
handle_tag = e.getIntoNode().getTag("gizmo_handle")
if axis_tag:
axis_entry = e
break
if handle_tag == "trackball" and trackball_entry is None:
trackball_entry = e
if axis_entry is not None:
self._highlight_axis(int(axis_entry.getIntoNode().getTag("gizmo_axis")))
self.is_hovering = True
elif trackball_entry is not None:
self._highlight_axis("trackball")
self.is_hovering = True
else:
self._reset_highlights()
self.is_hovering = False
# ------------------------------------------------------------------ #
# 高亮与更新
# ------------------------------------------------------------------ #
def _highlight_axis(self, axis_id: Optional[int | str]):
"""高亮指定的旋转手柄(轴 / 视图圈 / 轨迹球)。"""
for i, ring in enumerate(self.rings):
if i == axis_id:
ring.setColorScale(2, 2, 2, 1.0)
ring.setAlphaScale(1.0)
else:
ring.clearColorScale()
ring.setAlphaScale(0.3)
for idx, axis_np in enumerate(self.center_axes):
if axis_id == idx:
axis_np.setColorScale(1.5, 1.5, 1.5, 1.0)
else:
axis_np.clearColorScale()
if self.trackball_np:
geom_np = self.trackball_np.find("**/trackball_geom")
if not geom_np.isEmpty():
if axis_id == "trackball":
geom_np.show()
geom_np.setColorScale(self.highlight_color.x, self.highlight_color.y,self.highlight_color.z, 0.4)
if self.trackball_edge:
self.trackball_edge.setColorScale(self.highlight_color.x, self.highlight_color.y, self.highlight_color.z, 0.6)
else:
geom_np.hide()
if self.trackball_edge:
self.trackball_edge.setColorScale(self.trackball_edge_color)
def _reset_highlights(self):
for ring in self.rings:
ring.clearColorScale()
ring.setAlphaScale(self.ring_alpha)
if self.trackball_edge:
self.trackball_edge.setColorScale(self.trackball_edge_color)
for axis_np in self.center_axes:
axis_np.clearColorScale()
if self.trackball_np:
geom_np = self.trackball_np.find("**/trackball_geom")
if not geom_np.isEmpty():
geom_np.hide()
def update(self):
"""根据摄像机距离自动缩放 Gizmo。"""
if self.world.mouseWatcherNode.has_mouse():
current_mouse = LPoint2f(self.world.mouseWatcherNode.get_mouse())
if current_mouse != self.last_mouse_pos or self.last_mouse_pos is None:
self._on_mouse_move()
self.last_mouse_pos = current_mouse
if self.attached and self.camera:
self._update_root_transform()
# 持续调整半圆的可见性与分割线朝向
self._update_ring_halves_visibility()
dist = (
self.camera.getPos(self.world.render)
- self.root.getPos(self.world.render)
).length()
scale = dist * 0.15
self.root.setScale(self.world.render, scale)
# print(f"target_node_hpr -> {self.target_node.getHpr()}")
def _update_task(self, task: Task):
"""每帧调用,用于持续更新 Gizmo 的缩放。"""
self.update()
return Task.cont
# ------------------------------------------------------------------ #
# 内部:跟随目标更新根节点变换
# ------------------------------------------------------------------ #
def _update_root_transform(self):
if not self.attached or not self.target_node:
return
render = self.world.render
tgt = self.target_node
self.root.setPos(render, tgt.getPos(render))
# 拖拽过程中保持根节点朝向锁定,避免 gizmo 轴随目标旋转而跳动
if self.dragging and self._drag_root_quat_world is not None:
self.root.setQuat(render, self._drag_root_quat_world)
else:
if self.is_local:
self.root.setQuat(render, tgt.getQuat(render))
else:
self.root.setQuat(render, Quat.identQuat())
__all__ = ["RotateGizmo"]