977 lines
36 KiB
Python
977 lines
36 KiB
Python
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 direct.showbase.ShowBase import ShowBase
|
||
from TransformGizmo.scale_gizmo import ScaleGizmo
|
||
|
||
world = ShowBase()
|
||
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" 事件
|
||
- 如果从外部 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"]
|