IMGui
This commit is contained in:
parent
62fe0628d8
commit
ae0df4ca3b
104
TransformGizmo/docs/transform_gizmo_events.md
Normal file
104
TransformGizmo/docs/transform_gizmo_events.md
Normal file
@ -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,互不影响。
|
||||
- 如果只想监听部分事件,留空即可,内部会自动填充为空列表。
|
||||
21
TransformGizmo/events.py
Normal file
21
TransformGizmo/events.py
Normal file
@ -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"]
|
||||
1063
TransformGizmo/move_gizmo.py
Normal file
1063
TransformGizmo/move_gizmo.py
Normal file
File diff suppressed because it is too large
Load Diff
1587
TransformGizmo/rotate_gizmo.py
Normal file
1587
TransformGizmo/rotate_gizmo.py
Normal file
File diff suppressed because it is too large
Load Diff
976
TransformGizmo/scale_gizmo.py
Normal file
976
TransformGizmo/scale_gizmo.py
Normal file
@ -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"]
|
||||
509
TransformGizmo/transform_gizmo.py
Normal file
509
TransformGizmo/transform_gizmo.py
Normal file
@ -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"]
|
||||
Loading…
Reference in New Issue
Block a user