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"]
