From ae0df4ca3bd2a28b856336b8ea4956ff0d31561b Mon Sep 17 00:00:00 2001 From: Hector <145347438+hudomn@users.noreply.github.com> Date: Wed, 25 Feb 2026 11:53:06 +0800 Subject: [PATCH] IMGui --- TransformGizmo/docs/transform_gizmo_events.md | 104 ++ TransformGizmo/events.py | 21 + TransformGizmo/move_gizmo.py | 1063 +++++++++++ TransformGizmo/rotate_gizmo.py | 1587 +++++++++++++++++ TransformGizmo/scale_gizmo.py | 976 ++++++++++ TransformGizmo/transform_gizmo.py | 509 ++++++ 6 files changed, 4260 insertions(+) create mode 100644 TransformGizmo/docs/transform_gizmo_events.md create mode 100644 TransformGizmo/events.py create mode 100644 TransformGizmo/move_gizmo.py create mode 100644 TransformGizmo/rotate_gizmo.py create mode 100644 TransformGizmo/scale_gizmo.py create mode 100644 TransformGizmo/transform_gizmo.py diff --git a/TransformGizmo/docs/transform_gizmo_events.md b/TransformGizmo/docs/transform_gizmo_events.md new file mode 100644 index 00000000..461cc6c7 --- /dev/null +++ b/TransformGizmo/docs/transform_gizmo_events.md @@ -0,0 +1,104 @@ +# TransformGizmo 事件钩子使用说明 + +本文档说明如何在 `TransformGizmo` 初始化时注入拖拽事件回调(类似 C# 委托),以及各事件的负载字段。 + +## 事件常量 + +所有事件名集中在 `TransformGizmo.events.GizmoEvent`,避免拼写错误: + +```python +from src.TransformGizmo.events import GizmoEvent + +GizmoEvent.DRAG_START # "drag_start" +GizmoEvent.DRAG_MOVE # "drag_move" +GizmoEvent.DRAG_END # "drag_end" +GizmoEvent.ALL # ("drag_start", "drag_move", "drag_end") +``` + +## 基础用法示例 + +在构造 `TransformGizmo` 时传入 `event_hooks`,按 handle 类型分组(`"move"`, `"rotate"`, `"scale"` 或对应 `TransformGizmoMode` 值),每组里是事件名到回调列表的映射: + +```python +from src.TransformGizmo.transform_gizmo import TransformGizmo, TransformGizmoMode +from src.TransformGizmo.events import GizmoEvent + + +def on_move_start(info): + print("[move] start", "axis", info["axis"], "plane", info["plane"]) + + +def on_move_drag(info): + print("[move] pos ->", info["new_pos"]) + + +def on_rotate_drag(info): + print("[rotate] delta_deg =", info["delta_deg"]) + + +def on_scale_end(info): + print("[scale] final_scale =", info["final_scale"]) + + +hooks = { + TransformGizmoMode.MOVE: { + GizmoEvent.DRAG_START: [on_move_start], + GizmoEvent.DRAG_MOVE: [on_move_drag], + }, + TransformGizmoMode.ROTATE: { + GizmoEvent.DRAG_MOVE: [on_rotate_drag], + }, + TransformGizmoMode.SCALE: { + GizmoEvent.DRAG_END: [on_scale_end], + }, +} + +# world: 你的 Panda3DWorld 实例 +gizmo = TransformGizmo(world, event_hooks=hooks) +``` + +### 运行时追加 / 移除 + +初始化后也可以直接操作子 gizmo 的钩子列表: + +```python +from src.TransformGizmo.events import GizmoEvent + +gizmo.move_gizmo._event_hooks[GizmoEvent.DRAG_MOVE].append(on_move_drag) +# 移除同理,用 list.remove(on_move_drag) +``` + +## 回调负载字段 + +各事件都会传入一个 `dict`,常见字段如下(不存在的字段将为 `None` 或缺省): + +### Move +- `axis`:0/1/2(X/Y/Z),平面拖拽时为 `None` +- `plane`:0/1/2(XY/YZ/ZX),轴拖拽时为 `None` +- `mouse`:`Point2`,拖拽开始/过程中鼠标 NDC 坐标 +- `start_pos` / `new_pos` / `final_pos`:世界坐标 `Vec3` +- `target`:当前绑定的 `NodePath` +- `gizmo`:字符串 `"move"` + +### Rotate +- `mode`:`"axis"` 或 `"trackball"` +- `axis`:0/1/2(X/Y/Z),轨迹球时为 `None` +- `delta_deg`:本次 `drag_move` 的角度增量(度) +- `start_quat`:拖拽开始时的世界四元数 +- `final_hpr`:拖拽结束时的世界 HPR(仅 `drag_end`) +- `center`:gizmo 中心点(世界坐标) +- `axis_world`:当前旋转轴的世界方向 +- `mouse`:`Point2` +- `target`,`gizmo` 同上 + +### Scale +- `axis`:0/1/2 对应 X/Y/Z,3 表示中心均匀缩放 +- `scale_factor`:本次 `drag_move` 计算出的缩放倍率 +- `start_scale` / `new_scale` / `final_scale`:`Vec3` +- `mouse`:`Point2` +- `target`,`gizmo` 同上 + +## 小贴士 +- 事件字典的键可以用字符串 `"move"`/`"rotate"`/`"scale"`,也可以用 `TransformGizmoMode` 的对应值,内部都会匹配。 +- 每个事件支持多个回调(列表),内部对单个回调异常做了 try/except,互不影响。 +- 如果只想监听部分事件,留空即可,内部会自动填充为空列表。 diff --git a/TransformGizmo/events.py b/TransformGizmo/events.py new file mode 100644 index 00000000..1e8b49b6 --- /dev/null +++ b/TransformGizmo/events.py @@ -0,0 +1,21 @@ +class GizmoEvent: + """String constants for gizmo event hook names.""" + + DRAG_START = "drag_start" + DRAG_MOVE = "drag_move" + DRAG_END = "drag_end" + + ALL = (DRAG_START, DRAG_MOVE, DRAG_END) + + +class TransformGizmoMode: + """Simple string constants for gizmo modes.""" + + NONE = "none" + MOVE = "move" + ROTATE = "rotate" + SCALE = "scale" # reserved for future implementation + ALL = "all" # move + rotate together (matches existing UI semantics) + + +__all__ = ["GizmoEvent", "TransformGizmoMode"] diff --git a/TransformGizmo/move_gizmo.py b/TransformGizmo/move_gizmo.py new file mode 100644 index 00000000..f91270c6 --- /dev/null +++ b/TransformGizmo/move_gizmo.py @@ -0,0 +1,1063 @@ +import math +from typing import Optional, Tuple, Callable, Dict, Any, List + +from panda3d.core import ( + NodePath, + Vec3, + Point2, + Vec4, + Quat, + CardMaker, + GeomNode, + GeomVertexFormat, + GeomVertexData, + GeomVertexWriter, + GeomTriangles, + Geom, + CollisionNode, + CollisionRay, + CollisionHandlerQueue, + CollisionTraverser, + CollisionTube, + CollisionSphere, + CollisionPolygon, + CollisionEntry, + Point3, + Lens, + PerspectiveLens +) +from direct.showbase.DirectObject import DirectObject +from direct.task import Task +from panda3d.core import BitMask32,LPoint2f +from direct.showbase.ShowBase import ShowBase + +from .events import GizmoEvent + + +class MoveGizmo(DirectObject): + """ + A Unity-like Move Gizmo for Panda3D. + Features: + - 3 Axes (X=Red, Y=Green, Z=Blue) with Cylinder + Cone. + - Renders on top of other geometry. + - Handles mouse interaction for dragging. + """ + + def __init__( + self, + world:ShowBase, + camera_np: NodePath = 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 = world + self.is_local = True + self.debug = False + self._debug_drag_counter = 0 + self._picker_added = False + self.is_hovering = False + # Optional callback to report completed move 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) + # If camera is not provided, try to find it in the world or base + self.camera = camera_np if camera_np else 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") + else: + self._log(f"Camera found: {self.camera}") + + self.root = NodePath("MoveGizmo") + self.color_higher = 0.05 + self.color_normal = 0.01 + + self.target_node: Optional[NodePath] = None + self.attached = False + + # 控制柄外观设置 + self.arrow_cylider_length = 0.45 # 圆柱长度 + self.arrow_cylider_radius = 0.005 # 圆柱粗细 + # 碰撞体半径,可独立于可视半径调整 + self.arrow_cylider_col_radius = max(0.02, self.arrow_cylider_radius * 5.0) + self.arrow_cone_height = 0.1 # 圆锥高度 + self.arrow_cone_radius = 0.03 # 圆锥半径 + self.arrow_transparency: float = 0.8 # 箭头透明度 + + self.panel_transparency: float = 0.65 + self.plane_size: float = 0.1 + self.plane_offset: float = self.plane_size * 0.5 + 0.01 + + # Collision setup for picking + self.picker_ray = CollisionRay() + self.picker_node = CollisionNode('gizmo_picker_ray') + self.picker_node.addSolid(self.picker_ray) + self.picker_node.setFromCollideMask(BitMask32.bit(20)) # Specific mask for gizmo + self.picker_node.setIntoCollideMask(BitMask32.allOff()) + self.picker_np = NodePath(self.picker_node) + + self.c_trav = CollisionTraverser() + self.c_queue = CollisionHandlerQueue() + + # Dragging state + self.dragging: bool = False + self.drag_axis: int = None # 0=X, 1=Y, 2=Z + self.start_mouse: Point2 = None # NDC Point2 + self.start_node_pos = None + # param value of closest point on axis at drag start + self._start_param_on_ray: float = 0.0 + # sign so that dragging along axis arrow matches mouse direction + self._axis_param_sign: float = 1.0 + self.last_mouse_pos: Point3 = None + self.drag_axis_dir: Vec3 = Vec3(0, 0, 0) # world-space axis dir + self.drag_axis_screen: Point2 = Point2(1, 0) # axis direction in screen space + self.drag_base_dist: float = 1.0 # camera distance at drag start + + # Visuals + self.axes: list[NodePath] = [] + self.planes: list[NodePath] = [] # plane handles: 0=XY,1=YZ,2=ZX + self.axes_base_colors: list[Tuple[NodePath,Vec4]] = [] + self.planes_base_colors: list[Tuple[NodePath,Vec4]] = [] + # Plane drag state + self.drag_plane_id: Optional[int] = None # 0=XY,1=YZ,2=ZX + self.drag_plane_normal: Vec3 = Vec3(0, 0, 0) + self.drag_plane_origin: Point3 = Point3(0, 0, 0) # point on plane at drag start + # target_pos - hit_point at drag start + self._plane_drag_offset: Vec3 = Vec3(0, 0, 0) + + # --- Undo stack for move operations --------------------------------- + # Each entry: (target_node, old_pos(world), new_pos(world)) + self._move_undo_stack: list[Tuple[NodePath, Vec3, Vec3]] = [] + # Minimal distance to consider as a real move (avoid float noise) + self._undo_pos_epsilon: float = 1e-4 + + 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() + + # Update gizmo scale every frame so that it + # stays roughly the same size on screen + # taskMgr.add(self._update_task, "MoveGizmoUpdateTask") + + def _log(self, msg: str): + if self.debug: + print(f"[MoveGizmo] {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): + """Builds the 3 axes geometry and plane handles.""" + # Axis params + + # X Axis (Red) + self.axes.append(self._create_axis(Vec3(1, 0, 0), Vec4( + 1, 0, 0, 1), self.arrow_cylider_length, self.arrow_cylider_radius, self.arrow_cylider_col_radius, self.arrow_cone_height, self.arrow_cone_radius, 0)) + # Y Axis (Green) + self.axes.append(self._create_axis(Vec3(0, 1, 0), Vec4( + 0, 1, 0, 1), self.arrow_cylider_length, self.arrow_cylider_radius, self.arrow_cylider_col_radius, self.arrow_cone_height, self.arrow_cone_radius, 1)) + # Z Axis (Blue) + self.axes.append(self._create_axis(Vec3(0, 0, 1), Vec4( + 0, 0, 1, 1), self.arrow_cylider_length, self.arrow_cylider_radius, self.arrow_cylider_col_radius, self.arrow_cone_height, self.arrow_cone_radius, 2)) + + # Plane handles: small squares between axes, slightly offset from origin. + # 0 = XY, 1 = YZ, 2 = ZX + half = self.plane_size * 0.5 + inner = self.plane_offset + half + # XY plane:贴近 +X/+Y 象限 + self.planes.append( + self._create_plane_handle( + name="plane_xy", + plane_id=0, + axis_a=0, + axis_b=1, + color=Vec4(1, 1, 0, self.panel_transparency), # 黄色 + size=self.plane_size, + # 以平面中心为轴心,让靠近原点的角距离原点 plane_offset + offset_vec=Vec3(inner, inner, 0.0), + hpr=Vec3(0, -90, 0), + ) + ) + # YZ plane (normal +X) + self.planes.append( + self._create_plane_handle( + name="plane_yz", + plane_id=1, + axis_a=1, + axis_b=2, + color=Vec4(0, 1, 1, self.panel_transparency), # 青色 + size=self.plane_size, + offset_vec=Vec3(0.0, inner, inner), + hpr=Vec3(90, 0, 0), # rotate XY card to YZ + ) + ) + # ZX plane (normal +Y) + self.planes.append( + self._create_plane_handle( + name="plane_zx", + plane_id=2, + axis_a=2, + axis_b=0, + color=Vec4(0.5, 0, 0, self.panel_transparency), # 深红 + size=self.plane_size, + offset_vec=Vec3(inner, 0.0, inner), + # Rotate XY card so it lies in the ZX plane (normal ±Y) + hpr=Vec3(0, 0, 0), + ) + ) + + def _create_axis(self, direction: Vec3, color: Vec4, length, radius_visual, radius_collision, cone_height, cone_radius, axis_id): + """Creates a single axis arrow.""" + axis_root:NodePath = self.root.attachNewNode(f"axis_{axis_id}") + + # Align axis (default geometry faces +Z) to the requested direction. + align_quat = self._quat_from_z(direction) + axis_root.setQuat(align_quat) + self._log( + f"axis {axis_id} dir={direction} quat={align_quat} " + f"world_dir={axis_root.getQuat(self.world.render).xform(Vec3(0, 0, 1))}" + ) + + # Visual Geometry + geom = self._create_arrow_geom(length, radius_visual, cone_height, cone_radius, color) + geom_np: NodePath = axis_root.attachNewNode(geom) + geom_np.setTransparency(True) + geom_np.setAlphaScale(self.arrow_transparency) + self._log(f"created geom_np -> {geom_np}") + + # Collision Geometry (Tube) + # Tube is defined by point A and B. + shaft_len = max(length - cone_height, 0.0) + shaft_radius = max(1e-6, radius_collision) + c_node = CollisionNode(f"axis_col_{axis_id}") + # Shaft collider + if shaft_len > 0.0: + c_node.addSolid(CollisionTube( + Point3(0, 0, 0), + Point3(0, 0, shaft_len), + shaft_radius)) + # Cone collider: a slightly wider tube plus a small sphere at the tip + if cone_height > 0.0: + cone_start = Point3(0, 0, shaft_len) + cone_tip = Point3(0, 0, length) + cone_radius_pad = max(cone_radius, shaft_radius) + c_node.addSolid(CollisionTube(cone_start, cone_tip, cone_radius_pad)) + c_node.addSolid(CollisionSphere(cone_tip, cone_radius_pad)) + c_node.setIntoCollideMask(BitMask32.bit(20)) + c_node.setFromCollideMask(BitMask32.allOff()) + c_node.setTag('gizmo_axis', str(axis_id)) + + col_np = axis_root.attachNewNode(c_node) + # col_np.show() # Debug collision + self._log(f"created col_np -> {col_np}") + + return axis_root + + def _create_plane_handle( + self, + name: str, + plane_id: int, + axis_a: int, + axis_b: int, + color: Vec4, + size: float, + offset_vec: Vec3, + hpr: Vec3 = Vec3(0, 0, 0), + ) -> NodePath: + """ + Create a small square plane handle between two axes. + plane_id: 0=XY, 1=YZ, 2=ZX + axis_a / axis_b: not currently used for math, kept for clarity. + """ + cm = CardMaker(name) + # Square centered at origin in local X/Z (pivot at center) + half = size * 0.5 + cm.setFrame(-half, half, -half, half) + card_np: NodePath = self.root.attachNewNode(cm.generate()) + card_np.setPos(offset_vec) + card_np.setHpr(hpr) + card_np.setColor(color) + card_np.setTransparency(True) + # Double-sided rendering so the plane looks like Unity handles + card_np.setTwoSided(True) + + # Collision polygon matching the card in local space + # Define square in the same local coordinates as the CardMaker frame. + p0 = Point3(-half, 0.0, -half) + p1 = Point3(half, 0.0, -half) + p2 = Point3(half, 0.0, half) + p3 = Point3(-half, 0.0, half) + # Create two polygons with opposite winding so the plane is hit + # from both sides by the picking ray. + poly_front = CollisionPolygon(p0, p1, p2, p3) + poly_back = CollisionPolygon(p3, p2, p1, p0) + + c_node = CollisionNode(f"plane_col_{plane_id}") + c_node.addSolid(poly_front) + c_node.addSolid(poly_back) + c_node.setIntoCollideMask(BitMask32.bit(20)) + c_node.setFromCollideMask(BitMask32.allOff()) + c_node.setTag("gizmo_plane", str(plane_id)) + + col_np = card_np.attachNewNode(c_node) + # col_np.show() + self._log(f"created plane {name} col_np -> {col_np}") + + return card_np + + def _create_arrow_geom(self, length, radius, cone_height, cone_radius, color): + """Creates the mesh for cylinder + cone.""" + # Using simple line or loading model is easier, but let's try procedural for "no external assets" requirement + # Actually, Panda has `loader.loadModel("models/misc/cylinder")` but let's make a simple one or use LineSegs for shaft? + # User requested "Cylinder + Cone". + + # Let's use a helper function to generate a cylinder and cone using GeomVertexWriter + # For simplicity in this script, I will use a very thin box or simple geometry. + # Or better, load a basic shape if available, but procedural is safer to avoid missing assets. + + # Simplified: Use Line for shaft (thick) and Triangle fan for cone? + # No, user wants "Cylinder". + + # Let's use CardMaker for a cross-section look or just build a simple mesh. + # To save code space, I'll implement a basic procedural cylinder/cone generator. + + vdata = GeomVertexData( + 'name', GeomVertexFormat.getV3c4(), Geom.UHStatic) + vertex = GeomVertexWriter(vdata, 'vertex') + vcolor = GeomVertexWriter(vdata, 'color') + + # Shaft (Cylinder) - Simplified as a prism with 8 sides + segments = 12 + shaft_len = length - cone_height + + def add_circle_verts(z, r): + start_idx = vertex.getWriteRow() + for i in range(segments): + angle = 2 * math.pi * i / segments + x = r * math.cos(angle) + y = r * math.sin(angle) + vertex.addData3(x, y, z) + vcolor.addData4(color) + return start_idx + + # Bottom circle + base_idx = add_circle_verts(0, radius) + # Top of shaft + top_shaft_idx = add_circle_verts(shaft_len, radius) + + prim = GeomTriangles(Geom.UHStatic) + for i in range(segments): + next_i = (i + 1) % segments + # Side quads (2 tris) + prim.addVertices(base_idx + i, base_idx + + next_i, top_shaft_idx + next_i) + prim.addVertices(base_idx + i, top_shaft_idx + + next_i, top_shaft_idx + i) + + # Cone base + cone_base_idx = add_circle_verts(shaft_len, cone_radius) + # Cone tip + vertex.addData3(0, 0, length) + vcolor.addData4(color) + tip_idx = vertex.getWriteRow() - 1 + + # Cone sides + for i in range(segments): + next_i = (i + 1) % segments + prim.addVertices(cone_base_idx + i, + cone_base_idx + next_i, tip_idx) + # Cone bottom cap (optional, usually hidden by shaft) + prim.addVertices(cone_base_idx + next_i, + cone_base_idx + i, top_shaft_idx + i) # Stitching? + + geom = Geom(vdata) + geom.addPrimitive(prim) + node = GeomNode('axis_geom') + node.addGeom(geom) + return node + + def _quat_from_z(self, direction: Vec3) -> Quat: + """Return a quaternion rotating +Z to the given (normalized) 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() # Already aligned + if abs(dot + 1.0) < 1e-6: + # Opposite; rotate 180° around any axis perpendicular to Z + 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 attach(self, node_path: NodePath): + """Attach gizmo to a node.""" + 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()} world_pos={self.root.getPos(self.world.render)} " + f"target_hpr={node_path.getHpr(self.world.render)}" + ) + self._register_events() + self._reset_planels() + + def detach(self): + """Detach gizmo.""" + self.target_node = None + self.root.hide() + self.attached = False + self.drag_plane_id = None + self.is_hovering = False + self._log("detached") + self._ignore_events() + + # --- Undo support ------------------------------------------------- + def _record_move_action(self, node: NodePath, old_pos: Vec3, new_pos: Vec3): + """ + Push a move operation to the undo stack if the position changed + by more than a small epsilon. + """ + if not node or node.isEmpty(): + return + if not old_pos or new_pos is None: + return + delta = new_pos - old_pos + if delta.length() < self._undo_pos_epsilon: + # Ignore tiny moves caused by float noise + return + # Store copies so later modifications don't affect history + old_copy = Vec3(old_pos) + new_copy = Vec3(new_pos) + self._move_undo_stack.append((node, old_copy, new_copy)) + self._log( + f"record move action: node={node.getName()} " + f"old_pos={old_copy} new_pos={new_copy} stack_size={len(self._move_undo_stack)}" + ) + # Report to external listener (e.g. TransformGizmo) to build a + # unified, cross-gizmo undo history. + if self.on_action_committed is not None: + try: + self.on_action_committed( + { + "kind": "move", + "node": node, + "old_pos": Vec3(old_copy), + "new_pos": Vec3(new_copy), + } + ) + except Exception as exc: + # Avoid breaking interaction if user callback fails. + self._log(f"on_action_committed(move) error: {exc}") + + def undo_last_move(self): + """ + Undo the last committed move. + Default hotkey (from Qt -> Panda3D translation): Ctrl+Z => 'control-z'. + """ + if not self._move_undo_stack: + self._log("undo_last_move: stack empty") + return + node, old_pos, new_pos = self._move_undo_stack.pop() + if node is None or node.isEmpty(): + self._log("undo_last_move: target node invalid") + return + node.setPos(self.world.render, old_pos) + self._log( + f"undo_last_move: node={node.getName()} " + f"old_pos={old_pos} new_pos={new_pos} remaining={len(self._move_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") + + # --- Mouse helpers ------------------------------------------------- + def _get_normalized_mouse(self, extra) -> Optional[Point3]: + """ + Convert Qt pixel coordinates (from QPanda3DWidget) to Panda's + normalized device coordinates (-1..1), or fall back to + mouseWatcherNode when available. + """ + if self.world.mouseWatcherNode.hasMouse(): + mouse = self.world.mouseWatcherNode.getMouse() + return Point3(mouse.x, mouse.y, 0) + + # 1) Extra payload from QPanda3DWidget (pixels) + 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 origin is top‑left, Panda origin is center with +Y up + ny = 1.0 - (extra["y"] / h) * 2.0 + return Point3(nx, ny, 0) + + return None + + def _on_mouse_down(self, extra=None): + if not self.attached or not self.camera: + return + + # Check for picking + mpos = self._get_normalized_mouse(extra) + + if mpos is None: + self._log("mouse_down ignored: no mouse pos") + return + + # Setup ray + 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)} entries={self.c_queue.getNumEntries()}" + ) + + num_entries = self.c_queue.getNumEntries() + if num_entries > 0: + self.c_queue.sortEntries() + + # Unity 风格:优先选择平面,其次才是轴。 + plane_entry: Optional[CollisionEntry] = None + axis_entry: Optional[CollisionEntry] = None + for i in range(num_entries): + e: CollisionEntry = self.c_queue.getEntry(i) + if not plane_entry and e.getIntoNode().getTag("gizmo_plane"): + plane_entry = e + if not axis_entry and e.getIntoNode().getTag("gizmo_axis"): + axis_entry = e + if plane_entry and axis_entry: + break + + # Prefer plane hit when available + if plane_entry is not None: + entry = plane_entry + plane_tag: str = entry.getIntoNode().getTag("gizmo_plane") + # --- Begin plane dragging --- + # --- Begin plane dragging --- + self.dragging = True + self.drag_plane_id = int(plane_tag) # 0=XY,1=YZ,2=ZX + # print(f"[Gizmo] drag_plane_id={self.drag_plane_id}") + self.drag_axis = None + + cam_pos = self.camera.getPos(self.world.render) + node_pos_w = self.target_node.getPos(self.world.render) + lens: PerspectiveLens = self.camera.node().getLens() + + # Determine plane normal in world space from the actual plane node, + # so that dragging follows the visual orientation even when the + # target node is rotated (local mode). + plane_np = self.planes[self.drag_plane_id] + plane_normal: Vec3 = Quat(plane_np.getQuat(self.world.render)).xform( + Vec3(0, 1, 0) + ) + plane_normal.normalize() + self.drag_plane_normal = plane_normal + self.drag_plane_origin = node_pos_w + self.start_node_pos = node_pos_w + self.start_mouse = Point2(mpos.x, mpos.y) + + # Build initial mouse ray and compute intersection with plane + p_from = Point3() + p_to = Point3() + hit_point = 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() + denom = ray_dir.dot(plane_normal) + if abs(denom) > 1e-6: + t = (self.drag_plane_origin - + ray_origin).dot(plane_normal) / denom + hit_point = ray_origin + ray_dir * t + + if hit_point is None: + # Fallback: use current node position as hit point + hit_point = node_pos_w + + self._plane_drag_offset = node_pos_w - hit_point + self._debug_drag_counter = 0 + self._highlight_plane(self.drag_plane_id) + self._log( + f"pick plane={self.drag_plane_id} target={self.target_node.getName()} " + f"plane_origin={self.drag_plane_origin} plane_normal={self.drag_plane_normal} " + f"hit_point={hit_point}" + ) + self._emit_event( + GizmoEvent.DRAG_START, + { + "gizmo": "move", + "target": self.target_node, + "axis": None, + "plane": self.drag_plane_id, + "mouse": Point2(mpos.x, mpos.y), + "start_pos": self.start_node_pos, + }, + ) + + elif axis_entry is not None: + entry = axis_entry + axis_tag: str = entry.getIntoNode().getTag("gizmo_axis") + # --- Begin axis dragging (existing behavior) --- + self.dragging = True + self.drag_axis = int(axis_tag) + self.drag_plane_id = None + + # --- Freeze axis direction & screen-space mapping at drag start --- + axis_index = int(axis_tag) + axis_np: NodePath = self.axes[axis_index] + axis_dir: Vec3 = axis_np.getQuat( + self.world.render + ).xform(Vec3(0, 0, 1)) + if axis_dir.length_squared() == 0: + axis_dir = Vec3(0, 0, 1) + axis_dir.normalize() + cam_pos = self.camera.getPos(self.world.render) + node_pos_w = self.target_node.getPos(self.world.render) + view_dir = node_pos_w - cam_pos + if view_dir.length_squared() != 0: + view_dir.normalize() + + self.drag_axis_dir = axis_dir + + lens: PerspectiveLens = self.camera.node().getLens() + self.drag_base_dist = (cam_pos - node_pos_w).length() + sample_len = max(self.drag_base_dist * 0.25, 0.25) + + p0_cam = self.camera.getRelativePoint( + self.world.render, node_pos_w + ) + p1_cam = self.camera.getRelativePoint( + self.world.render, node_pos_w + axis_dir * sample_len + ) + p0_2d = Point2() + p1_2d = Point2() + if lens.project(p0_cam, p0_2d) and lens.project(p1_cam, p1_2d): + axis_screen: Point2 = p1_2d - p0_2d + if axis_screen.length_squared() > 0: + axis_screen.normalize() + else: + axis_screen = Point2(1, 0) + else: + axis_screen = Point2(1, 0) + self.drag_axis_screen = axis_screen + + self.drag_base_dist = max(self.drag_base_dist, 0.5) + self.start_mouse = Point2(mpos.x, mpos.y) + self.start_node_pos = node_pos_w + self.last_mouse_pos = Point3(mpos.x, mpos.y, 0) + + p_from = Point3() + p_to = Point3() + if lens.extrude(Point2(mpos.x, mpos.y), p_from, p_to): + ray_origin: Point3 = self.world.render.getRelativePoint( + self.camera, p_from + ) + ray_to: Point3 = self.world.render.getRelativePoint( + self.camera, p_to + ) + ray_dir: Vec3 = ray_to - ray_origin + if ray_dir.length_squared() != 0: + ray_dir.normalize() + axis_origin = self.start_node_pos + a = self.drag_axis_dir + d = ray_dir + w0 = ray_origin - axis_origin + a_dot_a = a.dot(a) + d_dot_d = d.dot(d) + a_dot_d = a.dot(d) + a_dot_w0 = a.dot(w0) + d_dot_w0 = d.dot(w0) + denom = a_dot_a * d_dot_d - a_dot_d * a_dot_d + if abs(denom) >= 1e-6: + s0 = (a_dot_d * d_dot_w0 - + d_dot_d * a_dot_w0) / denom + self._start_param_on_ray = s0 + else: + self._start_param_on_ray = 0.0 + else: + self._start_param_on_ray = 0.0 + else: + self._start_param_on_ray = 0.0 + + self._debug_drag_counter = 0 + self._highlight_axis(self.drag_axis) + self._log( + f"pick axis={self.drag_axis} target={self.target_node.getName()} " + f"start_pos={self.start_node_pos} world_root={self.root.getPos(self.world.render)} " + f"axis_dir={axis_dir} axis_screen={self.drag_axis_screen}" + ) + self._emit_event( + GizmoEvent.DRAG_START, + { + "gizmo": "move", + "target": self.target_node, + "axis": self.drag_axis, + "plane": None, + "mouse": Point2(mpos.x, mpos.y), + "start_pos": self.start_node_pos, + }, + ) + else: + self._log( + "pick hit but no gizmo_axis or gizmo_plane tag on node") + else: + self._log("mouse_down: no collision entries on gizmo") + + def _on_mouse_up(self, extra=None): + if self.dragging and self.target_node is not None: + # Commit a move action to the undo stack if position changed + try: + final_pos = self.target_node.getPos(self.world.render) + except Exception: + final_pos = None + old_pos = self.start_node_pos + if old_pos is not None and final_pos is not None: + self._record_move_action(self.target_node, old_pos, final_pos) + + axis_id = self.drag_axis + plane_id = self.drag_plane_id + self._emit_event( + GizmoEvent.DRAG_END, + { + "gizmo": "move", + "target": self.target_node, + "axis": axis_id, + "plane": plane_id, + "final_pos": final_pos, + "start_pos": old_pos, + }, + ) + self.dragging = False + self.drag_axis = None + self.drag_plane_id = None + if not self.is_local: + self.root.setHpr(self.world.render, 0, 0, 0) + self._reset_highlights() + self._log("mouse_up -> stop dragging") + + self._reset_planels() + + def _reset_planels(self): + if not self.camera: + return + + # 摄像机在 gizmo 局部坐标系中的位置(会跟随 root 旋转) + local_cam_pos: Vec3 = self.camera.getPos(self.root) + + # 三个轴向的符号:摄像机在 gizmo 局部坐标系里的哪一侧 + sx = 1.0 if local_cam_pos.x >= 0.0 else -1.0 + sy = 1.0 if local_cam_pos.y >= 0.0 else -1.0 + sz = 1.0 if local_cam_pos.z >= 0.0 else -1.0 + + o = self.plane_offset + + # XY 平面:在 X/Y 方向选离摄像机最近的那一角,Z 固定 0 + self.planes[0].setPos(sx * o, sy * o, 0.0) + + # YZ 平面:在 Y/Z 方向选离摄像机最近的那一角,X 固定 0 + self.planes[1].setPos(0.0, sy * o, sz * o) + + # ZX 平面:在 Z/X 方向选离摄像机最近的那一角,Y 固定 0 + self.planes[2].setPos(sx * o, 0.0, sz * o) + + 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 dragging") + return + + # Hover highlight when not dragging. + if not self.dragging: + self._update_hover_highlight(mpos) + return + + if not self.target_node: + return + + # Build world-space mouse ray from current mouse position + lens:Lens = 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() + + # Plane dragging: move freely within the plane defined at drag start. + if self.drag_plane_id is not None: + n = self.drag_plane_normal + if n.length_squared() == 0: + return + denom = ray_dir.dot(n) + if abs(denom) < 1e-6: + # Ray nearly parallel to plane; skip this move to avoid jumps. + return + t = (self.drag_plane_origin - ray_origin).dot(n) / denom + hit_point = ray_origin + ray_dir * t + new_pos = hit_point + self._plane_drag_offset + self.target_node.setPos(self.world.render, new_pos) + + if self._debug_drag_counter % 4 == 0: + self._log( + f"drag plane={self.drag_plane_id} mpos={mpos} " + f"ray_origin={ray_origin} ray_dir={ray_dir} " + f"plane_origin={self.drag_plane_origin} plane_normal={n} " + f"hit_point={hit_point} new_pos={new_pos}" + ) + self._debug_drag_counter += 1 + self._emit_event( + GizmoEvent.DRAG_MOVE, + { + "gizmo": "move", + "target": self.target_node, + "axis": None, + "plane": self.drag_plane_id, + "mouse": Point2(mpos.x, mpos.y), + "new_pos": new_pos, + }, + ) + return + + # --- 3D-based drag along axis: use closest point between mouse ray and axis line --- + axis_dir = self.drag_axis_dir + if axis_dir.length_squared() == 0: + return + + axis_origin = self.start_node_pos + a = axis_dir + d = ray_dir + w0 = ray_origin - axis_origin + + a_dot_a = a.dot(a) + d_dot_d = d.dot(d) + a_dot_d = a.dot(d) + a_dot_w0 = a.dot(w0) + d_dot_w0 = d.dot(w0) + + denom = a_dot_a * d_dot_d - a_dot_d * a_dot_d + if abs(denom) < 1e-6: + mouse_ndc = Point2(mpos.x, mpos.y) + delta = mouse_ndc - self.start_mouse + s_scalar = delta.dot(self.drag_axis_screen) + move_amount = s_scalar * self.drag_base_dist * 0.5 + new_pos = axis_origin + a * move_amount + self._log( + f"drag fallback (parallel): s={s_scalar:.4f} move={move_amount:.4f}" + ) + else: + s = (a_dot_d * d_dot_w0 - d_dot_d * a_dot_w0) / denom + delta_s = self._start_param_on_ray - s + new_pos = axis_origin + a * delta_s + + self.target_node.setPos(self.world.render, new_pos) + + if self._debug_drag_counter % 4 == 0: + self._log( + f"drag axis={self.drag_axis} mpos={mpos} " + f"ray_origin={ray_origin} ray_dir={ray_dir} " + f"axis_origin={axis_origin} axis_dir={axis_dir} new_pos={new_pos}" + ) + self._debug_drag_counter += 1 + self._emit_event( + GizmoEvent.DRAG_MOVE, + { + "gizmo": "move", + "target": self.target_node, + "axis": self.drag_axis, + "plane": None, + "mouse": Point2(mpos.x, mpos.y), + "new_pos": new_pos, + }, + ) + + def _update_hover_highlight(self, mpos: Point3): + """Highlight the handle under cursor during hover (no 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() + + plane_entry: Optional[CollisionEntry] = None + axis_entry: Optional[CollisionEntry] = None + for i in range(num_entries): + e = self.c_queue.getEntry(i) + if plane_entry is None and e.getIntoNode().getTag("gizmo_plane"): + plane_entry = e + if axis_entry is None and e.getIntoNode().getTag("gizmo_axis"): + axis_entry = e + if plane_entry and axis_entry: + break + + if plane_entry is not None: + plane_tag = plane_entry.getIntoNode().getTag("gizmo_plane") + self._highlight_plane(int(plane_tag)) + self.is_hovering = True + elif axis_entry is not None: + axis_tag = axis_entry.getIntoNode().getTag("gizmo_axis") + self._highlight_axis(int(axis_tag)) + self.is_hovering = True + else: + self._reset_highlights() + self.is_hovering = False + + def _highlight_axis(self, axis_id): + """高亮被选中的轴,其他轴和平面变暗。""" + for i, ax in enumerate(self.axes): + if i == axis_id: + ax.setColor(1, 1, 0, 1) + ax.clearColorScale() + else: + ax.clearColor() + ax.setAlphaScale(0.3) + # 所有平面也变暗 + for plane in self.planes: + plane.setAlphaScale(0.3) + + def _reset_highlights(self): + """重置所有高亮效果。""" + for ax in self.axes: + ax.clearColor() + ax.clearColorScale() + ax.setAlphaScale(self.arrow_transparency) + for plane in self.planes: + plane.clearColorScale() + plane.setAlphaScale(self.panel_transparency) + + def _highlight_plane(self, plane_id: int): + """高亮被选中的平面,其他平面和轴变暗。""" + for i, plane in enumerate(self.planes): + if i == plane_id: + plane.setColorScale(1.6, 1.6, 1.6, 2.0) + else: + plane.setAlphaScale(0.3) + # 所有轴也变暗 + for ax in self.axes: + ax.clearColor() + ax.setAlphaScale(0.3) + + def update(self): + """Update gizmo scale based on camera distance.""" + if self.world.mouseWatcherNode.has_mouse(): + current_mouse = LPoint2f(self.world.mouseWatcherNode.get_mouse()) + if current_mouse != self.last_mouse_pos: + self._on_mouse_move() + self.last_mouse_pos = current_mouse + if self.attached and self.camera: + self._update_root_transform() + dist = (self.camera.getPos(self.world.render) - + self.root.getPos(self.world.render)).length() + scale = dist * 0.15 # Constant screen size factor + self.root.setScale(self.world.render, scale) + + def _update_task(self, task: Task): + """Panda3D task that keeps the gizmo updated every frame.""" + self.update() + return Task.cont + + # ------------------------------------------------------------------ # + # Internal transform sync + # ------------------------------------------------------------------ # + def _update_root_transform(self): + """Keep gizmo root aligned to target without inheriting its render state.""" + 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)) + if self.is_local: + self.root.setQuat(render, tgt.getQuat(render)) + else: + self.root.setQuat(render, Quat.identQuat()) diff --git a/TransformGizmo/rotate_gizmo.py b/TransformGizmo/rotate_gizmo.py new file mode 100644 index 00000000..8a20ed05 --- /dev/null +++ b/TransformGizmo/rotate_gizmo.py @@ -0,0 +1,1587 @@ +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"] diff --git a/TransformGizmo/scale_gizmo.py b/TransformGizmo/scale_gizmo.py new file mode 100644 index 00000000..9d015d1f --- /dev/null +++ b/TransformGizmo/scale_gizmo.py @@ -0,0 +1,976 @@ +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, + GeomTriangles, + GeomLines, + Geom, + CollisionNode, + CollisionRay, + CollisionHandlerQueue, + CollisionTraverser, + CollisionTube, + CollisionBox, + BitMask32, + PerspectiveLens, + 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 + + +""" +缩放控制柄(Scale Gizmo) +========================= + +功能概述: + - 模仿 Unity 的缩放控制柄,使用 3 个彩色轴线表示沿 X/Y/Z 轴缩放 + - X 轴:红色线条 + 方块(沿局部 X 轴缩放) + - Y 轴:绿色线条 + 方块(沿局部 Y 轴缩放) + - Z 轴:蓝色线条 + 方块(沿局部 Z 轴缩放) + - 中心有一个白色小方块,用于均匀缩放(沿所有轴同时缩放) + - 使用 Panda3D 的碰撞系统进行鼠标拾取与拖拽 + - 控件会根据摄像机距离自动缩放,屏幕大小保持近似不变 + +集成方式(示例): + from QPanda3D.Panda3DWorld import Panda3DWorld + from QPanda3DExamples.scale_gizmo import ScaleGizmo + + world = Panda3DWorld() + model_np = world.render.attachNewNode("Box") + # ... 在 model_np 下加载模型 + + gizmo = ScaleGizmo(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 ScaleGizmo(DirectObject): + """ + Unity 风格的缩放控制柄: + - 3 个轴线 + 方块分别代表沿局部 X/Y/Z 轴缩放 + - 中心方块代表均匀缩放 + - 左键点击并拖拽,可以缩放绑定的 NodePath + """ + + def __init__( + self, + world:ShowBase, + 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 = world + self.is_local = True + self.debug: bool = False + self.is_hovering = False + self._picker_added: bool = False + self._debug_drag_counter: int = 0 + self.on_action_committed = on_action_committed + # Event hooks: drag_start / drag_move / drag_end + self._event_hooks = self._normalize_event_hooks(event_hooks) + + 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 ScaleGizmo") + else: + self._log(f"Camera found for ScaleGizmo: {self.camera}") + + self.root: NodePath = NodePath("ScaleGizmo") + self.color_higher = 0.05 + self.color_normal = 0.01 + self.last_mouse_pos = None + # self.root.setBin("fixed", 40) + # self.root.setDepthTest(False) + # self.root.setDepthWrite(False) + # self.root.setLightOff() + + self.target_node: Optional[NodePath] = None + self.attached: bool = False + + # 控制柄视觉参数 + self.axis_length: float = 0.5 + self.axis_thickness: float = 0.02 + self.cube_size: float = 0.04 + self.center_cube_size: float = 0.06 + self.axis_alpha: float = 0.75 + # 记录基准尺寸,用于拖拽时的可视放缩 + self._axis_length0: float = self.axis_length + self._cube_size0: float = self.cube_size + self._center_cube_size0: float = self.center_cube_size + + # 拾取射线与碰撞系统 + self.picker_ray = CollisionRay() + self.picker_node = CollisionNode("scale_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, 3=Uniform + self.start_mouse: Optional[Point2] = None + self.start_scale: Optional[Vec3] = None + self.drag_axis_dir: Vec3 = Vec3(0, 0, 0) + self.drag_axis_screen: Point2 = Point2(1, 0) + self.drag_base_dist: float = 1.0 + self._start_param_on_ray: float = 0.0 + + # 撤销栈 + self._scale_undo_stack: list[Tuple[NodePath, Vec3, Vec3]] = [] + self._undo_scale_epsilon: float = 1e-4 + + # 轴和中心控制柄节点 + self.axes: list[NodePath] = [] + self.axes_colors: list[Tuple[NodePath,NodePath, Vec4]] = [] + # 可视节点(线与端点立方体),与碰撞体分离,便于单独放缩 + self.axis_visuals: list[Dict[str, NodePath]] = [] + self.axis_visual_colors: list[Tuple[NodePath, Vec4]] = [] + + self.center_handle: Optional[NodePath] = None + self.center_handle_color = Vec4(1,1,1,1) + + self._build_gizmo() + + 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() + + # taskMgr.add(self._update_task, "ScaleGizmoUpdateTask") + + def _log(self, msg: str): + if self.debug: + print(f"[ScaleGizmo] {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): + """构建三个轴控制柄和中心均匀缩放控制柄。""" + # X 轴(红) + axis_root, line_np, cube_np = self._create_axis( + axis_dir=Vec3(1, 0, 0), + color=Vec4(1, 0, 0, 1), + length=self.axis_length, + thickness=self.axis_thickness, + cube_size=self.cube_size, + axis_id=0, + ) + self.axes.append(axis_root) + self.axis_visuals.append( + {"line": line_np, "cube": cube_np, "length": self.axis_length, "cube_size": self.cube_size} + ) + # Y 轴(绿) + axis_root, line_np, cube_np = self._create_axis( + axis_dir=Vec3(0, 1, 0), + color=Vec4(0, 1, 0, 1), + length=self.axis_length, + thickness=self.axis_thickness, + cube_size=self.cube_size, + axis_id=1, + ) + self.axes.append(axis_root) + self.axis_visuals.append( + {"line": line_np, "cube": cube_np, "length": self.axis_length, "cube_size": self.cube_size} + ) + # Z 轴(蓝) + axis_root, line_np, cube_np = self._create_axis( + axis_dir=Vec3(0, 0, 1), + color=Vec4(0, 0, 1, 1), + length=self.axis_length, + thickness=self.axis_thickness, + cube_size=self.cube_size, + axis_id=2, + ) + self.axes.append(axis_root) + self.axis_visuals.append({"line": line_np, "cube": cube_np, "length": self.axis_length, "cube_size": self.cube_size}) + # 中心控制柄(白色方块,用于均匀缩放) + self.center_handle = self._create_center_handle( + size=self.center_cube_size, + color=Vec4(1, 1, 1, 1), + ) + + def _set_node_dept(self,node:NodePath,color:Vec4 = None): + if node is None or node.is_empty():return + mat = Material('default') + mat.set_emission((1,0,1,1)) + if color: mat.base_color = color * self.color_normal + node.set_material(mat) + node.set_bin('fixed',40) + node.set_depth_test(False) + node.set_depth_write(False) + + def _create_axis( + self, + axis_dir: Vec3, + color: Vec4, + length: float, + thickness: float, + cube_size: float, + axis_id: int, + ) -> Tuple[NodePath, NodePath, NodePath]: + """创建一个轴控制柄:线条 + 末端方块。""" + axis_root = self.root.attachNewNode(f"scale_axis_{axis_id}") + + align_quat = self._quat_from_z(axis_dir) + axis_root.setQuat(align_quat) + + # 几何:线条 + line_geom = self._create_line_geom(length, color) + line_np: NodePath = axis_root.attachNewNode(line_geom) + line_np.setTransparency(True) + line_np.setAlphaScale(self.axis_alpha) + line_np_copy = line_np.copyTo(line_np) + line_np_copy.set_pos(0,0,0) + line_np_copy.set_hpr(0,0,0) + + # 几何:末端方块 + cube_geom = self._create_cube_geom(cube_size, color) + cube_np: NodePath = axis_root.attachNewNode(cube_geom) + cube_np.setPos(0, 0, length) + cube_np.setTransparency(True) + cube_np.setAlphaScale(self.axis_alpha) + cube_np_copy = cube_np.copy_to(cube_np) + cube_np_copy.set_pos(0,0,0) + cube_np_copy.set_hpr(0,0,0) + + # 碰撞体:线条使用 Tube + c_node = CollisionNode(f"scale_axis_col_{axis_id}") + tube = CollisionTube(Point3(0, 0, 0), Point3(0, 0, length), thickness * 2) + c_node.addSolid(tube) + c_node.setIntoCollideMask(BitMask32.bit(20)) + c_node.setFromCollideMask(BitMask32.allOff()) + c_node.setTag("gizmo_axis", str(axis_id)) + axis_root.attachNewNode(c_node) + + # 碰撞体:末端方块 + c_cube_node = CollisionNode(f"scale_cube_col_{axis_id}") + half = cube_size * 0.5 + box = CollisionBox(Point3(0, 0, length), half, half, half) + c_cube_node.addSolid(box) + c_cube_node.setIntoCollideMask(BitMask32.bit(20)) + c_cube_node.setFromCollideMask(BitMask32.allOff()) + c_cube_node.setTag("gizmo_axis", str(axis_id)) + axis_root.attachNewNode(c_cube_node) + + return axis_root, line_np, cube_np + + def _create_center_handle(self, size: float, color: Vec4) -> NodePath: + """创建中心均匀缩放控制柄(方块)。""" + center_root = self.root.attachNewNode("scale_center") + + cube_geom = self._create_cube_geom(size, color) + cube_np: NodePath = center_root.attachNewNode(cube_geom) + cube_np.setTransparency(True) + cube_np.setAlphaScale(self.axis_alpha) + + c_node = CollisionNode("scale_center_col") + half = size * 0.5 + box = CollisionBox(Point3(0, 0, 0), half, half, half) + c_node.addSolid(box) + c_node.setIntoCollideMask(BitMask32.bit(20)) + c_node.setFromCollideMask(BitMask32.allOff()) + c_node.setTag("gizmo_center", "1") + center_root.attachNewNode(c_node) + + return center_root + + def _create_line_geom(self, length: float, color: Vec4) -> GeomNode: + """创建一条沿 +Z 方向的线段几何。""" + vdata = GeomVertexData( + "line", GeomVertexFormat.getV3c4(), Geom.UHStatic) + vertex = GeomVertexWriter(vdata, "vertex") + vcolor = GeomVertexWriter(vdata, "color") + + vertex.addData3(0, 0, 0) + vcolor.addData4(color) + vertex.addData3(0, 0, length) + vcolor.addData4(color) + + prim = GeomLines(Geom.UHStatic) + prim.addVertices(0, 1) + + geom = Geom(vdata) + geom.addPrimitive(prim) + node = GeomNode("line_geom") + node.addGeom(geom) + return node + + def _create_cube_geom(self, size: float, color: Vec4) -> GeomNode: + """创建一个以原点为中心的立方体几何。""" + vdata = GeomVertexData( + "cube", GeomVertexFormat.getV3c4(), Geom.UHStatic) + vertex = GeomVertexWriter(vdata, "vertex") + vcolor = GeomVertexWriter(vdata, "color") + + half = size * 0.5 + + # 8个顶点 + vertices = [ + Point3(-half, -half, -half), # 0 + Point3(half, -half, -half), # 1 + Point3(half, half, -half), # 2 + Point3(-half, half, -half), # 3 + Point3(-half, -half, half), # 4 + Point3(half, -half, half), # 5 + Point3(half, half, half), # 6 + Point3(-half, half, half), # 7 + ] + + for v in vertices: + vertex.addData3(v) + vcolor.addData4(color) + + prim = GeomTriangles(Geom.UHStatic) + + # 6个面,每个面2个三角形 + faces = [ + (0, 1, 2, 3), # 底面 -Z + (4, 7, 6, 5), # 顶面 +Z + (0, 4, 5, 1), # 前面 -Y + (2, 6, 7, 3), # 后面 +Y + (0, 3, 7, 4), # 左面 -X + (1, 5, 6, 2), # 右面 +X + ] + + for f in faces: + prim.addVertices(f[0], f[1], f[2]) + prim.addVertices(f[0], f[2], f[3]) + + geom = Geom(vdata) + geom.addPrimitive(prim) + node = GeomNode("cube_geom") + node.addGeom(geom) + return node + + def _quat_from_z(self, direction: Vec3) -> Quat: + """返回一个四元数,使本地 +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: + 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 attach(self, node_path: NodePath): + """绑定控制柄到指定 NodePath。""" + 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_scale={node_path.getScale()}" + ) + 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.is_hovering = False + self._reset_axis_visuals() + self._ignore_events() + self._log("detached") + + def _record_scale_action(self, node: NodePath, old_scale: Vec3, new_scale: Vec3): + """将一次缩放操作压入撤销栈。""" + if node is None or node.isEmpty(): + return + if old_scale is None or new_scale is None: + return + + delta = new_scale - old_scale + if delta.length() < self._undo_scale_epsilon: + return + + old_copy = Vec3(old_scale) + new_copy = Vec3(new_scale) + self._scale_undo_stack.append((node, old_copy, new_copy)) + self._log( + f"record scale action: node={node.getName()} " + f"old_scale={old_copy} new_scale={new_copy} stack_size={len(self._scale_undo_stack)}" + ) + + if self.on_action_committed is not None: + try: + self.on_action_committed( + { + "kind": "scale", + "node": node, + "old_scale": Vec3(old_copy), + "new_scale": Vec3(new_copy), + } + ) + except Exception as exc: + self._log(f"on_action_committed(scale) error: {exc}") + + def undo_last_scale(self): + """撤销最近一次缩放操作。""" + if not self._scale_undo_stack: + self._log("undo_last_scale: stack empty") + return + node, old_scale, new_scale = self._scale_undo_stack.pop() + if node is None or node.isEmpty(): + self._log("undo_last_scale: target node invalid") + return + node.setScale(old_scale) + self._log( + f"undo_last_scale: node={node.getName()} " + f"old_scale={old_scale} new_scale={new_scale} remaining={len(self._scale_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]: + if self.world.mouseWatcherNode.hasMouse(): + mouse = self.world.mouseWatcherNode.getMouse() + return Point3(mouse.x, mouse.y, 0) + + """将鼠标转换到 Panda3D 的标准化设备坐标 [-1, 1]。""" + 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 + ny = 1.0 - (extra["y"] / h) * 2.0 + return Point3(nx, ny, 0.0) + + return None + + 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"entries={self.c_queue.getNumEntries()}" + ) + + num_entries = self.c_queue.getNumEntries() + if num_entries <= 0: + self._log("mouse_down: no collision entries on scale gizmo") + return + + self.c_queue.sortEntries() + + axis_entry = None + center_entry = None + for i in range(num_entries): + e = self.c_queue.getEntry(i) + if not center_entry and e.getIntoNode().getTag("gizmo_center"): + center_entry = e + if not axis_entry and e.getIntoNode().getTag("gizmo_axis"): + axis_entry = e + if center_entry and axis_entry: + break + + # 优先选择中心控制柄 + if center_entry is not None: + self.dragging = True + self.drag_axis = 3 # Uniform scale + self.start_scale = self.target_node.getScale() + self.start_mouse = Point2(mpos.x, mpos.y) + node_pos_w = self.target_node.getPos(self.world.render) + cam_pos = self.camera.getPos(self.world.render) + self.drag_base_dist = max((cam_pos - node_pos_w).length(), 0.5) + self._debug_drag_counter = 0 + self._highlight_center() + self._log( + f"pick center (uniform scale) target={self.target_node.getName()} " + f"start_scale={self.start_scale}" + ) + self._emit_event( + GizmoEvent.DRAG_START, + { + "gizmo": "scale", + "target": self.target_node, + "axis": self.drag_axis, + "mouse": Point2(mpos.x, mpos.y), + "start_scale": self.start_scale, + }, + ) + elif axis_entry is not None: + axis_tag: str = axis_entry.getIntoNode().getTag("gizmo_axis") + self.dragging = True + self.drag_axis = int(axis_tag) + self.start_scale = self.target_node.getScale() + self.start_mouse = Point2(mpos.x, mpos.y) + + axis_index = int(axis_tag) + axis_np: NodePath = self.axes[axis_index] + axis_dir: Vec3 = axis_np.getQuat( + self.world.render).xform(Vec3(0, 0, 1)) + if axis_dir.length_squared() == 0: + axis_dir = Vec3(0, 0, 1) + axis_dir.normalize() + self.drag_axis_dir = axis_dir + + cam_pos = self.camera.getPos(self.world.render) + node_pos_w = self.target_node.getPos(self.world.render) + lens: PerspectiveLens = self.camera.node().getLens() + self.drag_base_dist = max((cam_pos - node_pos_w).length(), 0.5) + + sample_len = max(self.drag_base_dist * 0.25, 0.25) + p0_cam = self.camera.getRelativePoint( + self.world.render, node_pos_w) + p1_cam = self.camera.getRelativePoint( + self.world.render, node_pos_w + axis_dir * sample_len + ) + p0_2d = Point2() + p1_2d = Point2() + # 注意:即使 lens.project 返回 False(点可能超出裁剪范围), + # 它仍然会填充 p0_2d/p1_2d,我们可以使用这些值来计算方向 + proj_ok_0 = lens.project(p0_cam, p0_2d) + proj_ok_1 = lens.project(p1_cam, p1_2d) + + # 无论 project 返回值如何,都尝试计算屏幕方向 + axis_screen_raw: Point2 = p1_2d - p0_2d + axis_screen_len = axis_screen_raw.length() + if axis_screen_len > 1e-6: + axis_screen = axis_screen_raw / axis_screen_len + else: + # 只有当投影长度真的为0时才使用默认值 + axis_screen = Point2(1, 0) + self.drag_axis_screen = axis_screen + self._axis_screen_len = axis_screen_len # 保存用于诊断 + + # 计算摄像机视线方向与轴的夹角(用于诊断) + view_dir = node_pos_w - cam_pos + if view_dir.length_squared() > 0: + view_dir.normalize() + axis_view_dot = abs(axis_dir.dot(view_dir)) + + self._debug_drag_counter = 0 + self._highlight_axis(self.drag_axis) + + # 诊断打印 + strer: list[str] = [] + strer.append(f"[ScaleGizmo DEBUG] ========== AXIS PICK ==========") + strer.append( + f" axis_id: {self.drag_axis} ({'XYZ'[self.drag_axis]})") + strer.append(f" axis_dir (world): {axis_dir}") + strer.append(f" cam_pos: {cam_pos}") + strer.append(f" node_pos: {node_pos_w}") + strer.append(f" view_dir: {view_dir}") + strer.append(f" axis_view_dot (|axis·view|): {axis_view_dot:.4f}") + strer.append(f" p0_cam: {p0_cam}, p1_cam: {p1_cam}") + strer.append(f" proj_ok: p0={proj_ok_0}, p1={proj_ok_1}") + strer.append(f" p0_2d: {p0_2d}, p1_2d: {p1_2d}") + strer.append( + f" axis_screen_raw: ({axis_screen_raw.x:.4f}, {axis_screen_raw.y:.4f})") + strer.append( + f" axis_screen_len (before normalize): {axis_screen_len:.6f}") + strer.append( + f" axis_screen (normalized): ({axis_screen.x:.4f}, {axis_screen.y:.4f})") + if axis_view_dot > 0.9: + strer.append( + f" WARNING: Axis nearly parallel to view direction!") + if axis_screen_len < 0.01: + strer.append(f" WARNING: Axis screen projection very short!") + strer.append(f" ==========================================") + self._log("\n".join(strer)) + + self._log( + f"pick axis={self.drag_axis} target={self.target_node.getName()} " + f"start_scale={self.start_scale} axis_dir={axis_dir}" + ) + self._emit_event( + GizmoEvent.DRAG_START, + { + "gizmo": "scale", + "target": self.target_node, + "axis": self.drag_axis, + "mouse": Point2(mpos.x, mpos.y), + "start_scale": self.start_scale, + }, + ) + else: + self._log( + "mouse_down: hit something but no gizmo_axis or gizmo_center tag") + + def _on_mouse_up(self, extra=None): + if self.dragging and self.target_node is not None: + try: + final_scale = self.target_node.getScale() + except Exception: + final_scale = None + old_scale = self.start_scale + if old_scale is not None and final_scale is not None: + self._record_scale_action( + self.target_node, old_scale, final_scale) + + axis_id = self.drag_axis + self._emit_event( + GizmoEvent.DRAG_END, + { + "gizmo": "scale", + "target": self.target_node, + "axis": axis_id, + "start_scale": old_scale, + "final_scale": final_scale, + }, + ) + self.dragging = False + self.drag_axis = None + self._reset_axis_visuals() + self._reset_highlights() + self._log("mouse_up -> stop scaling") + + 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 scaling") + return + + # Hover highlight when idle. + if not self.dragging: + self._update_hover_highlight(mpos) + return + + if not self.target_node: + return + + mouse_ndc = Point2(mpos.x, mpos.y) + delta = mouse_ndc - self.start_mouse + + if self.drag_axis == 3: + # 均匀缩放:基于鼠标在屏幕上的垂直移动 + scale_factor = 1.0 + delta.y * 2.0 + scale_factor = max(scale_factor, 0.01) # 防止负缩放 + new_scale = self.start_scale * scale_factor + self.target_node.setScale(new_scale) + + if self._debug_drag_counter % 4 == 0: + self._log( + f"uniform scale factor={scale_factor:.4f} " + f"new_scale={new_scale}" + ) + self._debug_drag_counter += 1 + self._emit_event( + GizmoEvent.DRAG_MOVE, + { + "gizmo": "scale", + "target": self.target_node, + "axis": self.drag_axis, + "mouse": Point2(mpos.x, mpos.y), + "new_scale": new_scale, + "scale_factor": scale_factor, + }, + ) + self._update_axis_visuals_for_scale(new_scale) + else: + # 单轴缩放 + s_scalar = delta.dot(self.drag_axis_screen) + scale_factor = 1.0 + s_scalar * 2.0 + scale_factor = max(scale_factor, 0.01) + + new_scale = Vec3(self.start_scale) + if self.drag_axis == 0: + new_scale.x = self.start_scale.x * scale_factor + elif self.drag_axis == 1: + new_scale.y = self.start_scale.y * scale_factor + elif self.drag_axis == 2: + new_scale.z = self.start_scale.z * scale_factor + + self.target_node.setScale(new_scale) + + # 诊断打印(每16帧打印一次,避免刷屏) + # if self._debug_drag_counter % 16 == 0: + # axis_screen_len = getattr(self, '_axis_screen_len', 0.0) + # print(f"[ScaleGizmo DRAG] axis={self.drag_axis}({'XYZ'[self.drag_axis]}) " + # f"delta=({delta.x:.4f}, {delta.y:.4f}) " + # f"axis_screen=({self.drag_axis_screen.x:.4f}, {self.drag_axis_screen.y:.4f}) " + # f"axis_screen_len={axis_screen_len:.4f} " + # f"s_scalar={s_scalar:.4f} " + # f"scale_factor={scale_factor:.4f}") + self._debug_drag_counter += 1 + self._emit_event( + GizmoEvent.DRAG_MOVE, + { + "gizmo": "scale", + "target": self.target_node, + "axis": self.drag_axis, + "mouse": Point2(mpos.x, mpos.y), + "new_scale": new_scale, + "scale_factor": scale_factor, + }, + ) + self._update_axis_visuals_for_scale(new_scale) + + def _update_hover_highlight(self, mpos: Point3): + """Highlight hovered handle during mouse move without dragging.""" + 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 = None + center_entry = None + for i in range(num_entries): + e = self.c_queue.getEntry(i) + if center_entry is None and e.getIntoNode().getTag("gizmo_center"): + center_entry = e + if axis_entry is None and e.getIntoNode().getTag("gizmo_axis"): + axis_entry = e + if center_entry and axis_entry: + break + + if center_entry is not None: + self._highlight_center() + self.is_hovering = True + elif axis_entry is not None: + axis_tag: str = axis_entry.getIntoNode().getTag("gizmo_axis") + self._highlight_axis(int(axis_tag)) + self.is_hovering = True + else: + self._reset_highlights() + self.is_hovering = False + + def _highlight_axis(self, axis_id: Optional[int]): + """高亮被选中的轴。""" + for i, ax in enumerate(self.axes): + if i == axis_id: + ax.setColor(1, 1, 0, 1) + ax.clearColorScale() + else: + ax.clearColor() + ax.setAlphaScale(0.3) + if self.center_handle: + self.center_handle.setAlphaScale(0.3) + + def _highlight_center(self): + """高亮中心控制柄。""" + for ax in self.axes: + ax.clearColor() + ax.setAlphaScale(0.3) + if self.center_handle: + self.center_handle.setColor(1, 1, 0, 1) + self.center_handle.clearColorScale() + + def _reset_highlights(self): + """重置所有高亮。""" + for ax in self.axes: + ax.clearColor() + ax.clearColorScale() + ax.setAlphaScale(self.axis_alpha) + if self.center_handle: + self.center_handle.clearColor() + self.center_handle.clearColorScale() + self.center_handle.setAlphaScale(self.axis_alpha) + + # --- 可视轴动态长度 ------------------------------------------------- + def _set_axis_visual_factor(self, axis_id: int, factor: float): + """仅调整可视几何长度/末端大小,不改碰撞体。""" + if axis_id < 0 or axis_id >= len(self.axis_visuals): + return + info = self.axis_visuals[axis_id] + line = info.get("line") + cube = info.get("cube") + base_len = info.get("length", self._axis_length0) + base_cube = info.get("cube_size", self._cube_size0) + if line is None or cube is None: + return + factor = max(0.3, min(factor, 3.0)) + line.setScale(1, 1, factor) + cube.setZ(base_len * factor) + cube.setScale(factor) + + def _reset_axis_visuals(self): + """恢复所有轴的可视长度/端点大小。""" + for i in range(len(self.axis_visuals)): + self._set_axis_visual_factor(i, 1.0) + + def _update_axis_visuals_for_scale(self, new_scale: Vec3): + """根据当前缩放比例动态调整可视轴长度。""" + if self.start_scale is None: + return + + def safe_ratio(a: float, b: float) -> float: + return a / b if abs(b) > 1e-6 else 1.0 + + sx0, sy0, sz0 = self.start_scale + sx1, sy1, sz1 = new_scale + + if self.drag_axis == 3: + ratios = [] + if abs(sx0) > 1e-6: + ratios.append(sx1 / sx0) + if abs(sy0) > 1e-6: + ratios.append(sy1 / sy0) + if abs(sz0) > 1e-6: + ratios.append(sz1 / sz0) + if not ratios: + return + factor = sum(ratios) / len(ratios) + for i in range(3): + self._set_axis_visual_factor(i, factor) + elif self.drag_axis is not None and 0 <= self.drag_axis <= 2: + ratio = [safe_ratio(sx1, sx0), safe_ratio(sy1, sy0), safe_ratio(sz1, sz0)] + self._set_axis_visual_factor(self.drag_axis, ratio[self.drag_axis]) + + 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() + 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) + + 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)) + if self.is_local: + self.root.setQuat(render, tgt.getQuat(render)) + else: + self.root.setQuat(render, Quat.identQuat()) + + +__all__ = ["ScaleGizmo"] diff --git a/TransformGizmo/transform_gizmo.py b/TransformGizmo/transform_gizmo.py new file mode 100644 index 00000000..89ae8e8b --- /dev/null +++ b/TransformGizmo/transform_gizmo.py @@ -0,0 +1,509 @@ +from __future__ import annotations + +import importlib +import importlib.util +from typing import Callable, Optional, List, Dict, Any + +from direct.task import Task +from panda3d.core import NodePath +from direct.showbase.DirectObject import DirectObject +from direct.showbase.ShowBase import ShowBase + +HAS_IMGUI = False +if importlib.util.find_spec("imgui_bundle"): + from imgui_bundle import imgui + HAS_IMGUI = True + +from .move_gizmo import MoveGizmo +from .rotate_gizmo import RotateGizmo +from .scale_gizmo import ScaleGizmo +from .events import GizmoEvent +import panda3d.core as p3d + + +class TransformGizmoMode: + """Simple string constants for gizmo modes.""" + + NONE = "none" + MOVE = "move" + ROTATE = "rotate" + SCALE = "scale" # reserved for future implementation + ALL = "all" # move + rotate together (matches existing UI semantics) + + +class TransformGizmo(DirectObject): + """ + A unified Transform Gizmo wrapper for Panda3D. + + It internally manages: + - MoveGizmo (translation) + - RotateGizmo (rotation) + + and exposes a single API to: + - attach / detach to a target NodePath + - switch transform mode: move / rotate / all / none + - forward undo operations for move / rotate + + Typical usage: + + world = Panda3DWorld() + gizmo = TransformGizmo(world) + gizmo.set_mode(TransformGizmoMode.MOVE) + gizmo.attach(some_node) + + # Later: + gizmo.set_mode(TransformGizmoMode.ROTATE) + gizmo.attach(other_node) + """ + + def __init__( + self, + world: ShowBase = None, + camera_np: Optional[NodePath] = None, + on_action_handler_changed: Optional[Callable[[str], None]] = None, + event_hooks: Optional[Dict[str, Dict[str, List[Callable[[Dict[str, Any]], None]]]]] = None + ): + super().__init__() + + if not world: + from direct.showbase.ShowBaseGlobal import base + world = base + else: self.world = world + self.__debug: bool = False + self.enabled: bool = True + self.on_action_handler_changed = on_action_handler_changed + + # Try to reuse the same camera for all internal gizmos + self.camera: Optional[NodePath] = camera_np or getattr( + world, "cam", None) + if not self.camera and hasattr(world, "base"): + self.camera = world.base.cam + + # Global history stack of transform actions pushed by sub gizmos. + # Each entry is a dict, e.g.: + # {"kind": "move", "node": node, "old_pos": Vec3, "new_pos": Vec3} + # {"kind": "rotate", "node": node, "old_hpr": Vec3, "new_hpr": Vec3} + self._history: List[Dict[str, Any]] = [] + # Redo stack for transform actions (cleared on new action commit). + self._redo_history: List[Dict[str, Any]] = [] + self._event_hooks = event_hooks or {} + + # Internal gizmos – they report actions back via a callback so that + # TransformGizmo can build a unified, time-ordered undo stack. + self.overlay_cam = None + self.last_cam_transform: Optional[p3d.LMatrix4f] = None + self._setup_overlay_rendering() + + self.move_gizmo = MoveGizmo( + self.world, + self.overlay_cam_np, + on_action_committed=self._on_subgizmo_action, + event_hooks=self._extract_event_hooks(TransformGizmoMode.MOVE) + ) + self.rotate_gizmo = RotateGizmo( + self.world, + self.overlay_cam_np, + on_action_committed=self._on_subgizmo_action, + event_hooks=self._extract_event_hooks(TransformGizmoMode.ROTATE) + ) + self.scale_gizmo = ScaleGizmo( + self.world, + self.overlay_cam_np, + on_action_committed=self._on_subgizmo_action, + event_hooks=self._extract_event_hooks(TransformGizmoMode.SCALE) + ) + + self.move_gizmo.root.reparentTo(self.gizmo_render) + self.rotate_gizmo.root.reparentTo(self.gizmo_render) + self.scale_gizmo.root.reparentTo(self.gizmo_render) + + # Keep debug flag consistent across all gizmos + self.move_gizmo.debug = self.__debug + self.rotate_gizmo.debug = self.__debug + self.scale_gizmo.debug = self.__debug + + # Current attached target and mode + self.target_node: Optional[NodePath] = None + self.__mode: str = TransformGizmoMode.MOVE + # Fine-grained switch for each handle type; combined with mode below. + self._handle_enabled: Dict[str, bool] = { + TransformGizmoMode.MOVE: True, + TransformGizmoMode.ROTATE: True, + TransformGizmoMode.SCALE: True, + } + # Track right mouse button so that when RMB is held + # (used by CameraOrbitController for free-look + WASD fly), + # we ignore W/E/R transform hotkeys, matching Unity behaviour. + self._rmb_down: bool = False + + # Undo/redo shortcuts are handled by app-level action routing. + # Avoid double-trigger with main.py bindings. + # Mode switch hotkeys (Unity style), but disabled while RMB is pressed. + self.accept("w", self._on_key_w) + self.accept("e", self._on_key_e) + self.accept("r", self._on_key_r) + # Track right mouse button state. + self.accept("mouse2", self._on_mouse2_down) + self.accept("mouse2-up", self._on_mouse2_up) + self.world.task_mgr.add(self._update_task, "transform_gizmo_update") + + def _setup_overlay_rendering(self): + """ + 创建独立的3D渲染场景用于Gizmo,完全绕过RenderPipeline后处理。 + + 原理: + - 创建一个独立的render场景(gizmo_render),不属于主render场景 + - 创建一个Sort值极高的DisplayRegion,在RP完成后渲染 + - 由于Gizmo不在主render下,RenderPipeline完全不会处理它 + - overlay相机与主相机同步变换,保持透视效果一致 + """ + if not hasattr(self.world, 'win') or self.world.win is None: + raise RuntimeError("No world showbase.win found") + # 创建独立的Gizmo渲染场景(不在主render下,RP不会处理) + self.gizmo_render = NodePath("gizmo_render") + # 创建高Sort值的DisplayRegion + self.overlay_dr = self.world.win.makeDisplayRegion() + self.overlay_dr.setSort(8) # 极高值,确保在RP后处理之后 + + self.overlay_dr.setClearColorActive(False) # 透明背景,保留3D画面 + self.overlay_dr.setClearDepthActive(True) # 清除深度,Gizmo始终在前 + # 创建overlay专用相机 + self.overlay_cam = p3d.Camera("gizmo_overlay_cam") + self.overlay_cam.setLens(self.world.camLens) # 共享主相机镜头 + self.overlay_cam.setScene(self.gizmo_render) # 渲染gizmo_render场景 + # 创建相机节点(独立于主相机,但会同步变换) + self.overlay_cam_np = self.gizmo_render.attachNewNode(self.overlay_cam) + self.overlay_dr.setCamera(self.overlay_cam_np) + # 每帧同步overlay相机位置到主相机 + from direct.task.TaskManagerGlobal import taskMgr + taskMgr.add(self._sync_overlay_camera, "GizmoOverlayCameraSync") + + def _sync_overlay_camera(self, task:Task): + """每帧同步overlay相机变换到主相机。""" + curr = self.world.camera.getTransform(self.world.render) + if self.overlay_cam_np and self.last_cam_transform != curr: + # 从主相机获取世界坐标变换,应用到overlay相机 + self.overlay_cam_np.setTransform(curr) + self.last_cam_transform = curr + return task.cont + + def _check_node_empty(self,task:Task): + if self.target_node is None or self.target_node.parent is None or self.target_node.isEmpty(): + self.move_gizmo.detach() + self.rotate_gizmo.detach() + self.scale_gizmo.detach() + self.target_node = None + return task.done + return task.cont + + def _update_task(self,task:Task): + # if get_state().hover_imgui: return task.cont + self.move_gizmo.update() + self.rotate_gizmo.update() + self.scale_gizmo.update() + return task.cont + + @property + def is_hovering(self)->bool: + return self.move_gizmo.is_hovering or self.rotate_gizmo.is_hovering or self.scale_gizmo.is_hovering + + # ------------------------------------------------------------------ # + # Public API + # ------------------------------------------------------------------ # + def set_debug(self, enabled: bool) -> None: + """Enable / disable debug logs for all internal gizmos.""" + self.__debug = bool(enabled) + self.move_gizmo.debug = self.__debug + self.rotate_gizmo.debug = self.__debug + self.scale_gizmo.debug = self.__debug + + def attach(self, node_path: NodePath) -> None: + """ + Attach the transform gizmo to a target node. + + Only the gizmos corresponding to the current mode will be attached. + """ + if not node_path or not self.enabled: + return + + self.target_node = node_path + self._apply_mode_to_subgizmos() + self.world.task_mgr.remove("CheckGizmoNodeEmpty") + self.world.task_mgr.add(self._check_node_empty, "CheckGizmoNodeEmpty") + + def detach(self) -> None: + """ + Detach from the current target and hide all gizmos. + """ + self.target_node = None + # Detach all sub gizmos to ensure they stop listening to events + self.move_gizmo.detach() + self.rotate_gizmo.detach() + self.scale_gizmo.detach() + + def set_handles_enabled( + self, + *, + move: Optional[bool] = False, + rotate: Optional[bool] = False, + scale: Optional[bool] = False, + ) -> None: + """ + Enable / disable each handle type independently of the current mode. + + Args: + move: True/False to enable/disable move handle; None keeps current. + rotate: True/False to enable/disable rotate handle; None keeps current. + scale: True/False to enable/disable scale handle; None keeps current. + + Example: + gizmo.set_mode(TransformGizmoMode.ALL) + gizmo.set_handles_enabled(move=True, rotate=False, scale=False) + # => only move handle stays active even though mode is ALL. + """ + changed = False + if move is not None: + new_val = bool(move) + if self._handle_enabled[TransformGizmoMode.MOVE] != new_val: + self._handle_enabled[TransformGizmoMode.MOVE] = new_val + changed = True + if rotate is not None: + new_val = bool(rotate) + if self._handle_enabled[TransformGizmoMode.ROTATE] != new_val: + self._handle_enabled[TransformGizmoMode.ROTATE] = new_val + changed = True + if scale is not None: + new_val = bool(scale) + if self._handle_enabled[TransformGizmoMode.SCALE] != new_val: + self._handle_enabled[TransformGizmoMode.SCALE] = new_val + changed = True + + # Re-attach handles to reflect the new mask if we already have a target. + if changed and self.target_node is not None: + self._apply_mode_to_subgizmos() + + def get_handles_enabled(self) -> Dict[str, bool]: + """Return current per-handle enable states.""" + return dict(self._handle_enabled) + + def set_event_hooks(self, event_hooks: Dict[str, Dict[str, List[Callable[[Dict[str, Any]], None]]]], replace: bool = False) -> None: + """Set or merge runtime drag event hooks for move/rotate/scale sub gizmos.""" + if not isinstance(event_hooks, dict): + return + if replace or not isinstance(self._event_hooks, dict): + self._event_hooks = {} + for key, sub in event_hooks.items(): + if not isinstance(sub, dict): + continue + key_l = str(key).lower() + if replace or key_l not in self._event_hooks: + self._event_hooks[key_l] = {} + for event_name, callbacks in sub.items(): + if not callbacks: + continue + if replace: + self._event_hooks[key_l][event_name] = list(callbacks) + else: + existing = self._event_hooks[key_l].setdefault(event_name, []) + for cb in callbacks: + if cb not in existing: + existing.append(cb) + + # Apply updated hooks to existing sub gizmos immediately. + self.move_gizmo._event_hooks = self._extract_event_hooks(TransformGizmoMode.MOVE) + self.rotate_gizmo._event_hooks = self._extract_event_hooks(TransformGizmoMode.ROTATE) + self.scale_gizmo._event_hooks = self._extract_event_hooks(TransformGizmoMode.SCALE) + + def set_mode(self, mode: str) -> None: + """ + Set current transform mode. + + Supported values (see TransformGizmoMode): + - "move" + - "rotate" + - "all" (move + rotate) + - "none" + - "scale" (reserved, not implemented yet) + """ + mode = (mode or "").lower() + if mode not in { + TransformGizmoMode.NONE, + TransformGizmoMode.MOVE, + TransformGizmoMode.ROTATE, + TransformGizmoMode.SCALE, + TransformGizmoMode.ALL, + }: + raise ValueError(f"Unsupported gizmo mode: {mode}") + + if mode == self.__mode: + return + + self.__mode = mode + self._apply_mode_to_subgizmos() + if self.on_action_handler_changed is not None: + self.on_action_handler_changed(self.__mode) + + def get_mode(self) -> str: + """Return current mode as a string.""" + return self.__mode + + def undo_last(self) -> bool: + """ + Undo the most recent transform operation (move or rotate), + regardless of current mode. + """ + if not self._history: + return False + + action = self._history.pop() + kind = action.get("kind") + node: NodePath = action.get("node") + if node is None or node.isEmpty(): + return False + + if kind == "move": + old_pos = action.get("old_pos") + if old_pos is not None: + node.setPos(self.world.render, old_pos) + elif kind == "rotate": + old_hpr = action.get("old_hpr") + if old_hpr is not None: + node.setHpr(self.world.render, old_hpr) + elif kind == "scale": + old_scale = action.get("old_scale") + if old_scale is not None: + node.setScale(old_scale) + self._redo_history.append(action) + return True + + def redo_last(self) -> bool: + """ + Redo the most recently undone transform operation. + """ + if not self._redo_history: + return False + + action = self._redo_history.pop() + kind = action.get("kind") + node: NodePath = action.get("node") + if node is None or node.isEmpty(): + return False + + if kind == "move": + new_pos = action.get("new_pos") + if new_pos is not None: + node.setPos(self.world.render, new_pos) + elif kind == "rotate": + new_hpr = action.get("new_hpr") + if new_hpr is not None: + node.setHpr(self.world.render, new_hpr) + elif kind == "scale": + new_scale = action.get("new_scale") + if new_scale is not None: + node.setScale(new_scale) + + self._history.append(action) + return True + + def update(self) -> None: + """ + Forward update to internal gizmos. + + Both MoveGizmo and RotateGizmo already have their own Panda3D + tasks to keep screen-size scaling; this is provided mainly for + explicit/manual calls if needed. + """ + self.move_gizmo.update() + self.rotate_gizmo.update() + self.scale_gizmo.update() + + # ------------------------------------------------------------------ # + # Internal helpers + # ------------------------------------------------------------------ # + def _extract_event_hooks(self, key: str) -> Dict[str, List[Callable[[Dict[str, Any]], None]]]: + """Return per-gizmo event hooks (drag_start / drag_move / drag_end).""" + base = {name: [] for name in GizmoEvent.ALL} + if not self._event_hooks: + return base + sub = self._event_hooks.get(key) or self._event_hooks.get(key.lower()) + if not isinstance(sub, dict): + return base + for name in list(base.keys()): + cbs = sub.get(name) + if cbs: + base[name] = list(cbs) + return base + + def _apply_mode_to_subgizmos(self) -> None: + """ + Attach / detach internal gizmos based on current mode and target. + """ + # Always detach first to ensure we don't keep stale event listeners. + self.move_gizmo.detach() + self.rotate_gizmo.detach() + self.scale_gizmo.detach() + + if self.target_node is None: + return + + if self.__mode in (TransformGizmoMode.MOVE, TransformGizmoMode.ALL) and self._handle_enabled.get(TransformGizmoMode.MOVE, True): + self.move_gizmo.attach(self.target_node) + + if self.__mode in (TransformGizmoMode.ROTATE, TransformGizmoMode.ALL) and self._handle_enabled.get(TransformGizmoMode.ROTATE, True): + self.rotate_gizmo.attach(self.target_node) + + if self.__mode in (TransformGizmoMode.SCALE, TransformGizmoMode.ALL) and self._handle_enabled.get(TransformGizmoMode.SCALE, True): + self.scale_gizmo.attach(self.target_node) + + def _on_subgizmo_action(self, action: Dict[str, Any]) -> None: + """ + Callback used by MoveGizmo / RotateGizmo / ScaleGizmo to report that a + transform action (move/rotate/scale) has been committed. + """ + self._history.append(action) + # New user action invalidates redo chain. + self._redo_history.clear() + + # ------------------------------------------------------------------ # + # Input helpers (hotkeys / mouse states) + # ------------------------------------------------------------------ # + def _on_mouse2_down(self, extra=None) -> None: + """Right mouse button pressed: used by camera controller for fly mode.""" + self._rmb_down = True + + def _on_mouse2_up(self, extra=None) -> None: + """Right mouse button released.""" + self._rmb_down = False + + def _on_key_w(self, *args) -> None: + """Switch to Move mode unless RMB is held (camera fly).""" + if self._rmb_down: + return + # if HAS_IMGUI: + # io = imgui.get_io() + # if io.want_capture_mouse: return + # if get_state().hover_imgui:return + self.set_mode(TransformGizmoMode.MOVE) + + def _on_key_e(self, *args) -> None: + """Switch to Rotate mode unless RMB is held (camera fly).""" + if self._rmb_down: + return + # if HAS_IMGUI: + # io = imgui.get_io() + # if io.want_capture_mouse: return + # if get_state().hover_imgui:return + self.set_mode(TransformGizmoMode.ROTATE) + + def _on_key_r(self, *args) -> None: + """Switch to Scale mode unless RMB is held (camera fly).""" + if self._rmb_down: + return + # if HAS_IMGUI: + # io = imgui.get_io() + # if io.want_capture_mouse: return + # if get_state().hover_imgui:return + self.set_mode(TransformGizmoMode.SCALE) + + +__all__ = ["TransformGizmo", "TransformGizmoMode"]