编辑器中集成编辑器漫游相机,点选物体和Gizmo拖动变换;docs(TransformGizmo): 添加事件钩子使用文档和事件常量定义
--- - 新增 transform_gizmo_events.md 文档,详细说明如何使用事件回调钩子 - 定义 GizmoEvent 和 TransformGizmoMode 常量类 - 提供完整的事件钩子使用示例和回调负载字段说明 - 支持运行时动态添加/移除事件回调 Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
parent
69fb0c21b5
commit
4f5eaeb92b
104
src/TransformGizmo/docs/transform_gizmo_events.md
Normal file
104
src/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
src/TransformGizmo/events.py
Normal file
21
src/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"]
|
||||
1074
src/TransformGizmo/move_gizmo.py
Normal file
1074
src/TransformGizmo/move_gizmo.py
Normal file
File diff suppressed because it is too large
Load Diff
1692
src/TransformGizmo/rotate_gizmo.py
Normal file
1692
src/TransformGizmo/rotate_gizmo.py
Normal file
File diff suppressed because it is too large
Load Diff
987
src/TransformGizmo/scale_gizmo.py
Normal file
987
src/TransformGizmo/scale_gizmo.py
Normal file
@ -0,0 +1,987 @@
|
||||
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 isinstance(extra, dict) and extra.get("ndc") and "x" in extra and "y" in extra:
|
||||
return Point3(float(extra["x"]), float(extra["y"]), 0.0)
|
||||
|
||||
mouse_watcher = getattr(self.world, "mouseWatcherNode", None)
|
||||
if mouse_watcher is not None and mouse_watcher.hasMouse():
|
||||
mouse = mouse_watcher.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)
|
||||
if "width" in extra and "height" in extra:
|
||||
w = max(float(extra["width"]), 1.0)
|
||||
h = max(float(extra["height"]), 1.0)
|
||||
nx = (float(extra["x"]) / w) * 2.0 - 1.0
|
||||
ny = 1.0 - (float(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。"""
|
||||
mouse_watcher = getattr(self.world, "mouseWatcherNode", None)
|
||||
if mouse_watcher is not None and mouse_watcher.has_mouse():
|
||||
current_mouse = LPoint2f(mouse_watcher.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"]
|
||||
458
src/TransformGizmo/transform_gizmo.py
Normal file
458
src/TransformGizmo/transform_gizmo.py
Normal file
@ -0,0 +1,458 @@
|
||||
from __future__ import annotations
|
||||
|
||||
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
|
||||
from .move_gizmo import MoveGizmo
|
||||
from .rotate_gizmo import RotateGizmo
|
||||
from .scale_gizmo import ScaleGizmo
|
||||
from .events import GizmoEvent
|
||||
import panda3d.core as p3d
|
||||
|
||||
|
||||
def _input_blocked_by_imgui() -> bool:
|
||||
return False
|
||||
|
||||
|
||||
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,
|
||||
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,
|
||||
use_overlay: bool = True,
|
||||
):
|
||||
super().__init__()
|
||||
|
||||
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]] = []
|
||||
self._event_hooks = event_hooks or {}
|
||||
self.use_overlay = use_overlay
|
||||
|
||||
# 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
|
||||
|
||||
# Register global undo shortcut: Ctrl+Z (Qt) -> 'control-z' (Panda3D)
|
||||
self.accept("control-z", self.undo_last)
|
||||
# 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("mouse3", self._on_mouse2_down)
|
||||
self.accept("mouse3-up", self._on_mouse2_up)
|
||||
self.world.taskMgr.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 self.use_overlay:
|
||||
self.gizmo_render = self.world.render
|
||||
self.overlay_dr = None
|
||||
self.overlay_cam = None
|
||||
self.overlay_cam_np = self.camera
|
||||
return
|
||||
|
||||
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")
|
||||
lens = self.camera.node().getLens() if self.camera is not None else self.world.camLens
|
||||
self.overlay_cam.setLens(lens) # 共享主相机镜头
|
||||
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相机位置到主相机
|
||||
self.world.taskMgr.add(self._sync_overlay_camera, "GizmoOverlayCameraSync")
|
||||
|
||||
def _sync_overlay_camera(self, task:Task):
|
||||
"""每帧同步overlay相机变换到主相机。"""
|
||||
if self.camera is None:
|
||||
return task.cont
|
||||
curr = self.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 _input_blocked_by_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
|
||||
|
||||
@property
|
||||
def is_dragging(self) -> bool:
|
||||
return self.move_gizmo.dragging or self.rotate_gizmo.dragging or self.scale_gizmo.dragging
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# 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.taskMgr.remove("CheckGizmoNodeEmpty")
|
||||
self.world.taskMgr.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_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) -> None:
|
||||
"""
|
||||
Undo the most recent transform operation (move or rotate),
|
||||
regardless of current mode.
|
||||
"""
|
||||
if not self._history:
|
||||
return
|
||||
|
||||
action = self._history.pop()
|
||||
kind = action.get("kind")
|
||||
node: NodePath = action.get("node")
|
||||
if node is None or node.isEmpty():
|
||||
return
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# 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 _input_blocked_by_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 _input_blocked_by_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 _input_blocked_by_imgui():
|
||||
return
|
||||
self.set_mode(TransformGizmoMode.SCALE)
|
||||
|
||||
|
||||
__all__ = ["TransformGizmo", "TransformGizmoMode"]
|
||||
@ -1,4 +1,4 @@
|
||||
from imgui_bundle import immapp, immvision
|
||||
from imgui_bundle import immapp, immvision # type: ignore
|
||||
|
||||
from .editor import EditorApp
|
||||
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
from panda3d.core import loadPrcFileData
|
||||
|
||||
|
||||
WINDOW_TITLE = "ImPanda3D Editor Prototype"
|
||||
INI_FILENAME = "ImPanda3D_Editor_Prototype_threaded.ini"
|
||||
WINDOW_TITLE = "Robot Meta Core Editor Prototype"
|
||||
INI_FILENAME = "RobotMetaCore_Editor_Prototype_threaded.ini"
|
||||
|
||||
|
||||
def apply_panda_runtime_config() -> None:
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from imgui_bundle import hello_imgui, imgui, immapp, immvision
|
||||
from imgui_bundle import hello_imgui, imgui, immapp, immvision # type: ignore
|
||||
|
||||
from .config import INI_FILENAME, WINDOW_TITLE
|
||||
from .renderer import PandaRendererThread
|
||||
@ -12,10 +12,22 @@ class EditorApp:
|
||||
self.renderer.start()
|
||||
|
||||
self.selected_node_path = "SceneRoot/PreviewRoot/Teapot"
|
||||
self.renderer.select_node_path(self.selected_node_path)
|
||||
self.viewport_texture: immvision.GlTexture | None = None
|
||||
self.viewport_frame_version = -1
|
||||
self.viewport_frame_shape: tuple[int, int, int] | None = None
|
||||
self.render_scale = 1.0
|
||||
self._viewport_mouse_buttons = {"lmb": False, "mmb": False, "rmb": False}
|
||||
self._viewport_keys = {
|
||||
"w": False,
|
||||
"a": False,
|
||||
"s": False,
|
||||
"d": False,
|
||||
"q": False,
|
||||
"e": False,
|
||||
"r": False,
|
||||
"space": False,
|
||||
}
|
||||
|
||||
def build_runner_params(self) -> tuple[hello_imgui.RunnerParams, immapp.AddOnsParams]:
|
||||
runner = hello_imgui.RunnerParams()
|
||||
@ -24,7 +36,7 @@ class EditorApp:
|
||||
runner.app_window_params.restore_previous_geometry = True
|
||||
runner.ini_filename = INI_FILENAME
|
||||
|
||||
runner.imgui_window_params.menu_app_title = "ImPanda3D"
|
||||
runner.imgui_window_params.menu_app_title = "RobotMetaCore"
|
||||
runner.imgui_window_params.show_menu_bar = True
|
||||
runner.imgui_window_params.show_status_bar = True
|
||||
runner.imgui_window_params.default_imgui_window_type = (
|
||||
@ -111,18 +123,85 @@ class EditorApp:
|
||||
self.renderer.stop()
|
||||
self.renderer.join(timeout=2.0)
|
||||
|
||||
def _handle_viewport_input(self, hovered: bool) -> None:
|
||||
if not hovered:
|
||||
def _handle_viewport_input(self, hovered: bool, rect_min) -> None:
|
||||
active = hovered or any(self._viewport_mouse_buttons.values())
|
||||
if not active:
|
||||
self._release_viewport_input()
|
||||
return
|
||||
|
||||
io = imgui.get_io()
|
||||
if imgui.is_mouse_down(imgui.MouseButton_.right):
|
||||
self.renderer.orbit(io.mouse_delta.x, io.mouse_delta.y)
|
||||
elif imgui.is_mouse_down(imgui.MouseButton_.middle):
|
||||
self.renderer.pan(io.mouse_delta.x, io.mouse_delta.y)
|
||||
mouse_pos = io.mouse_pos
|
||||
local_x = mouse_pos.x - rect_min.x
|
||||
local_y = mouse_pos.y - rect_min.y
|
||||
viewport_width = max(float(imgui.get_item_rect_size().x), 1.0)
|
||||
viewport_height = max(float(imgui.get_item_rect_size().y), 1.0)
|
||||
|
||||
if io.mouse_wheel != 0.0:
|
||||
self.renderer.dolly(io.mouse_wheel)
|
||||
alt_down = self._is_any_key_down(imgui.Key.left_alt, imgui.Key.right_alt, imgui.Key.mod_alt)
|
||||
shift_down = self._is_any_key_down(imgui.Key.left_shift, imgui.Key.right_shift)
|
||||
ctrl_down = self._is_any_key_down(imgui.Key.left_ctrl, imgui.Key.right_ctrl)
|
||||
self.renderer.queue_input_event(
|
||||
"modifiers",
|
||||
alt=alt_down,
|
||||
shift=shift_down,
|
||||
ctrl=ctrl_down,
|
||||
)
|
||||
self.renderer.queue_input_event("mouse_move", x=local_x, y=local_y, width=viewport_width, height=viewport_height)
|
||||
|
||||
button_states = {
|
||||
"lmb": imgui.is_mouse_down(imgui.MouseButton_.left),
|
||||
"mmb": imgui.is_mouse_down(imgui.MouseButton_.middle),
|
||||
"rmb": imgui.is_mouse_down(imgui.MouseButton_.right),
|
||||
}
|
||||
for button, is_down in button_states.items():
|
||||
if is_down != self._viewport_mouse_buttons[button]:
|
||||
self.renderer.queue_input_event(
|
||||
"mouse_button",
|
||||
button=button,
|
||||
down=is_down,
|
||||
x=local_x,
|
||||
y=local_y,
|
||||
width=viewport_width,
|
||||
height=viewport_height,
|
||||
alt=alt_down,
|
||||
shift=shift_down,
|
||||
ctrl=ctrl_down,
|
||||
)
|
||||
self._viewport_mouse_buttons[button] = is_down
|
||||
|
||||
if hovered and io.mouse_wheel != 0.0:
|
||||
self.renderer.queue_input_event("wheel", delta=io.mouse_wheel)
|
||||
|
||||
if hovered or self._viewport_mouse_buttons["rmb"]:
|
||||
key_states = {
|
||||
"w": imgui.is_key_down(imgui.Key.w),
|
||||
"a": imgui.is_key_down(imgui.Key.a),
|
||||
"s": imgui.is_key_down(imgui.Key.s),
|
||||
"d": imgui.is_key_down(imgui.Key.d),
|
||||
"q": imgui.is_key_down(imgui.Key.q),
|
||||
"e": imgui.is_key_down(imgui.Key.e),
|
||||
"r": imgui.is_key_down(imgui.Key.r),
|
||||
"space": imgui.is_key_down(imgui.Key.space),
|
||||
}
|
||||
for key, is_down in key_states.items():
|
||||
if is_down != self._viewport_keys[key]:
|
||||
self.renderer.queue_input_event("key", key=key, down=is_down)
|
||||
self._viewport_keys[key] = is_down
|
||||
|
||||
if imgui.is_key_pressed(imgui.Key.f):
|
||||
self.renderer.queue_input_event("focus")
|
||||
|
||||
def _release_viewport_input(self) -> None:
|
||||
for button, is_down in list(self._viewport_mouse_buttons.items()):
|
||||
if is_down:
|
||||
self.renderer.queue_input_event("mouse_button", button=button, down=False)
|
||||
self._viewport_mouse_buttons[button] = False
|
||||
for key, is_down in list(self._viewport_keys.items()):
|
||||
if is_down:
|
||||
self.renderer.queue_input_event("key", key=key, down=False)
|
||||
self._viewport_keys[key] = False
|
||||
|
||||
def _is_any_key_down(self, *keys) -> bool:
|
||||
return any(imgui.is_key_down(key) for key in keys)
|
||||
|
||||
def show_viewport(self) -> None:
|
||||
available = imgui.get_content_region_avail()
|
||||
@ -160,9 +239,12 @@ class EditorApp:
|
||||
(1.0, 0.0),
|
||||
)
|
||||
|
||||
self._handle_viewport_input(hovered)
|
||||
if hovered:
|
||||
imgui.set_tooltip("RMB orbit\nMMB pan\nWheel dolly")
|
||||
self._handle_viewport_input(hovered, rect_min)
|
||||
renderer_selected_path = self.renderer.selected_node_path()
|
||||
if renderer_selected_path:
|
||||
self.selected_node_path = renderer_selected_path
|
||||
# if hovered:
|
||||
# imgui.set_tooltip("Alt+LMB orbit\nMMB pan\nAlt+RMB or Wheel dolly\nRMB look + WASD/QE fly\nF focus")
|
||||
else:
|
||||
imgui.text_wrapped("Panda3D renderer is starting. The first frame has not arrived yet.")
|
||||
|
||||
@ -189,6 +271,7 @@ class EditorApp:
|
||||
opened = imgui.tree_node_ex(f"{node.name}##{node_key}", tree_flags)
|
||||
if imgui.is_item_clicked():
|
||||
self.selected_node_path = node.path
|
||||
self.renderer.select_node_path(node.path)
|
||||
|
||||
if opened:
|
||||
for index, child in enumerate(node.children):
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import queue
|
||||
import threading
|
||||
import time
|
||||
|
||||
@ -8,19 +8,24 @@ import direct.task.Task as PandaTaskModule
|
||||
import numpy as np
|
||||
import simplepbr
|
||||
from direct.showbase.ShowBase import ShowBase
|
||||
from imgui_bundle import immvision
|
||||
from imgui_bundle import immvision # type: ignore
|
||||
from panda3d.core import (
|
||||
NodePath,
|
||||
FrameBufferProperties,
|
||||
GraphicsOutput,
|
||||
GraphicsPipe,
|
||||
LPoint3,
|
||||
LVecBase4f,
|
||||
Texture,
|
||||
WindowProperties,
|
||||
)
|
||||
|
||||
from .config import apply_panda_runtime_config
|
||||
from .scene import apply_camera_state, build_preview_scene
|
||||
from .scene import build_preview_scene
|
||||
from .types import CameraState, SceneNodeSnapshot
|
||||
from src.TransformGizmo.transform_gizmo import TransformGizmo, TransformGizmoMode
|
||||
from src.tools.camera_orbit_controller import CameraOrbitController
|
||||
from src.tools.picker_ray import PickerRay
|
||||
|
||||
|
||||
class PandaRendererThread(threading.Thread):
|
||||
@ -32,12 +37,17 @@ class PandaRendererThread(threading.Thread):
|
||||
|
||||
self._requested_size = (width, height)
|
||||
self._camera_state = CameraState()
|
||||
self._input_events: queue.Queue[tuple[str, dict[str, object]]] = queue.Queue()
|
||||
self._input_modifiers = {"alt": False, "shift": False, "ctrl": False}
|
||||
self._input_buttons = {"lmb": False, "mmb": False, "rmb": False}
|
||||
self._latest_frame = np.zeros((height, width, 4), dtype=np.uint8)
|
||||
self._spare_frame = np.zeros((height, width, 4), dtype=np.uint8)
|
||||
self._frame_ready = False
|
||||
self._frame_version = 0
|
||||
self._scene_snapshot: list[SceneNodeSnapshot] = []
|
||||
self._node_lookup: dict[str, SceneNodeSnapshot] = {}
|
||||
self._node_path_lookup: dict[str, object] = {}
|
||||
self._selected_node_path = "SceneRoot/PreviewRoot/Teapot"
|
||||
self._last_error: str | None = None
|
||||
self._fps: float = 0.0
|
||||
self._target_frame_time = 1.0 / 120.0
|
||||
@ -83,29 +93,15 @@ class PandaRendererThread(threading.Thread):
|
||||
with self._state_lock:
|
||||
self._requested_size = (max(64, int(width)), max(64, int(height)))
|
||||
|
||||
def orbit(self, delta_x: float, delta_y: float) -> None:
|
||||
with self._state_lock:
|
||||
self._camera_state.heading_deg -= delta_x * 0.25
|
||||
self._camera_state.pitch_deg = max(
|
||||
-85.0,
|
||||
min(85.0, self._camera_state.pitch_deg - delta_y * 0.20),
|
||||
)
|
||||
def queue_input_event(self, kind: str, **payload: object) -> None:
|
||||
self._input_events.put((kind, payload))
|
||||
|
||||
def pan(self, delta_x: float, delta_y: float) -> None:
|
||||
with self._state_lock:
|
||||
heading_rad = math.radians(self._camera_state.heading_deg)
|
||||
distance_scale = max(self._camera_state.distance * 0.01, 0.01)
|
||||
self._camera_state.target_x -= math.cos(heading_rad) * delta_x * distance_scale
|
||||
self._camera_state.target_y -= math.sin(heading_rad) * delta_x * distance_scale
|
||||
self._camera_state.target_z += delta_y * distance_scale
|
||||
def select_node_path(self, path: str) -> None:
|
||||
self.queue_input_event("select_path", path=path)
|
||||
|
||||
def dolly(self, wheel_delta: float) -> None:
|
||||
def selected_node_path(self) -> str:
|
||||
with self._state_lock:
|
||||
zoom_factor = math.pow(0.9, wheel_delta)
|
||||
self._camera_state.distance = max(
|
||||
3.0,
|
||||
min(200.0, self._camera_state.distance * zoom_factor),
|
||||
)
|
||||
return self._selected_node_path
|
||||
|
||||
def camera_state(self) -> CameraState:
|
||||
with self._state_lock:
|
||||
@ -123,6 +119,9 @@ class PandaRendererThread(threading.Thread):
|
||||
apply_panda_runtime_config()
|
||||
|
||||
base: ShowBase | None = None
|
||||
camera_controller: CameraOrbitController | None = None
|
||||
transform_gizmo: TransformGizmo | None = None
|
||||
picker_ray: PickerRay | None = None
|
||||
previous_task_signal = PandaTaskModule.signal
|
||||
try:
|
||||
base = ShowBase(windowType="offscreen")
|
||||
@ -173,7 +172,29 @@ class PandaRendererThread(threading.Thread):
|
||||
)
|
||||
|
||||
scene_root = build_preview_scene(base)
|
||||
scene_root.set_python_tag("scene_path", scene_root.get_name())
|
||||
self._update_scene_snapshot(scene_root)
|
||||
camera_controller = CameraOrbitController(
|
||||
base,
|
||||
camera=camera_np,
|
||||
target=LPoint3(
|
||||
self._camera_state.target_x,
|
||||
self._camera_state.target_y,
|
||||
self._camera_state.target_z,
|
||||
),
|
||||
distance=self._camera_state.distance,
|
||||
yaw=-self._camera_state.heading_deg,
|
||||
pitch=-self._camera_state.pitch_deg,
|
||||
orbit_requires_alt=True,
|
||||
use_mouse_watcher=False,
|
||||
register_events=False,
|
||||
)
|
||||
camera_controller.set_focus_target(scene_root)
|
||||
self._sync_camera_snapshot(camera_controller)
|
||||
transform_gizmo = TransformGizmo(base, camera_np=camera_np, use_overlay=False)
|
||||
transform_gizmo.set_mode(TransformGizmoMode.MOVE)
|
||||
picker_ray = PickerRay(base, pick_root=scene_root, camera=camera_np)
|
||||
self._attach_gizmo_to_selected(transform_gizmo)
|
||||
|
||||
last_time = time.perf_counter()
|
||||
smoothed_dt = 1.0 / 60.0
|
||||
@ -187,18 +208,19 @@ class PandaRendererThread(threading.Thread):
|
||||
frame_start = time.perf_counter()
|
||||
with self._state_lock:
|
||||
width, height = self._requested_size
|
||||
camera = self._camera_state
|
||||
|
||||
buffer.set_size(width, height)
|
||||
lens.set_aspect_ratio(width / height)
|
||||
apply_camera_state(camera_np, camera)
|
||||
current_lens = camera_np.node().get_lens()
|
||||
current_lens.set_aspect_ratio(width / height)
|
||||
|
||||
now = time.perf_counter()
|
||||
dt = now - last_time
|
||||
last_time = now
|
||||
smoothed_dt = smoothed_dt * 0.9 + dt * 0.1
|
||||
|
||||
self._drain_input_events(camera_controller, transform_gizmo, picker_ray)
|
||||
base.taskMgr.step()
|
||||
self._sync_camera_snapshot(camera_controller)
|
||||
|
||||
if texture.has_ram_image() and texture.get_x_size() > 0 and texture.get_y_size() > 0:
|
||||
rgba = texture.get_ram_image_as("RGBA")
|
||||
@ -228,26 +250,205 @@ class PandaRendererThread(threading.Thread):
|
||||
self._last_error = repr(exc)
|
||||
finally:
|
||||
PandaTaskModule.signal = previous_task_signal
|
||||
if camera_controller is not None:
|
||||
try:
|
||||
camera_controller.destroy()
|
||||
except Exception:
|
||||
pass
|
||||
if transform_gizmo is not None:
|
||||
try:
|
||||
transform_gizmo.detach()
|
||||
except Exception:
|
||||
pass
|
||||
if base is not None:
|
||||
try:
|
||||
base.destroy()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _update_scene_snapshot(self, scene_root) -> None:
|
||||
children = [self._snapshot_node(child, scene_root.get_name()) for child in scene_root.get_children()]
|
||||
def _drain_input_events(
|
||||
self,
|
||||
camera_controller: CameraOrbitController,
|
||||
transform_gizmo: TransformGizmo | None,
|
||||
picker_ray: PickerRay | None,
|
||||
) -> None:
|
||||
while True:
|
||||
try:
|
||||
kind, payload = self._input_events.get_nowait()
|
||||
except queue.Empty:
|
||||
return
|
||||
|
||||
if kind == "modifiers":
|
||||
self._input_modifiers = {
|
||||
"alt": bool(payload.get("alt", False)),
|
||||
"shift": bool(payload.get("shift", False)),
|
||||
"ctrl": bool(payload.get("ctrl", False)),
|
||||
}
|
||||
camera_controller.set_modifiers(
|
||||
alt=self._input_modifiers["alt"],
|
||||
shift=self._input_modifiers["shift"],
|
||||
ctrl=self._input_modifiers["ctrl"],
|
||||
)
|
||||
elif kind == "mouse_button":
|
||||
button = str(payload.get("button", ""))
|
||||
down = bool(payload.get("down", False))
|
||||
for modifier in self._input_modifiers:
|
||||
if modifier in payload:
|
||||
self._input_modifiers[modifier] = bool(payload.get(modifier, False))
|
||||
camera_controller.set_modifiers(
|
||||
alt=self._input_modifiers["alt"],
|
||||
shift=self._input_modifiers["shift"],
|
||||
ctrl=self._input_modifiers["ctrl"],
|
||||
)
|
||||
if button in self._input_buttons:
|
||||
self._input_buttons[button] = down
|
||||
x = payload.get("x")
|
||||
y = payload.get("y")
|
||||
ndc = self._event_ndc(payload)
|
||||
event_payload = {"x": ndc[0], "y": ndc[1], "ndc": True} if ndc is not None else None
|
||||
if transform_gizmo is not None:
|
||||
if button == "lmb" and down:
|
||||
transform_gizmo.world.messenger.send("mouse1", [event_payload])
|
||||
if (
|
||||
picker_ray is not None
|
||||
and ndc is not None
|
||||
and self._can_pick_on_left_down(transform_gizmo)
|
||||
):
|
||||
self._pick_and_attach_gizmo(picker_ray, transform_gizmo, ndc[0], ndc[1])
|
||||
elif button == "lmb" and not down:
|
||||
transform_gizmo.world.messenger.send("mouse1-up", [event_payload])
|
||||
elif button == "mmb" and not down:
|
||||
transform_gizmo.world.messenger.send("mouse2-up", [event_payload])
|
||||
elif button == "rmb":
|
||||
transform_gizmo.world.messenger.send("mouse3" if down else "mouse3-up", [event_payload])
|
||||
gizmo_captures_left = (
|
||||
transform_gizmo is not None
|
||||
and button == "lmb"
|
||||
and (transform_gizmo.is_hovering or transform_gizmo.is_dragging)
|
||||
)
|
||||
if not gizmo_captures_left:
|
||||
camera_controller.set_button(
|
||||
button,
|
||||
down,
|
||||
float(x) if x is not None else None,
|
||||
float(y) if y is not None else None,
|
||||
)
|
||||
elif kind == "mouse_move":
|
||||
x = payload.get("x")
|
||||
y = payload.get("y")
|
||||
if x is not None and y is not None and not (transform_gizmo is not None and transform_gizmo.is_dragging):
|
||||
camera_controller.move_pointer(float(x), float(y))
|
||||
ndc = self._event_ndc(payload)
|
||||
if transform_gizmo is not None and ndc is not None:
|
||||
transform_gizmo.world.messenger.send("mouse-move", [{"x": ndc[0], "y": ndc[1], "ndc": True}])
|
||||
elif kind == "wheel":
|
||||
delta = float(payload.get("delta", 0.0))
|
||||
if delta != 0.0:
|
||||
camera_controller.wheel(delta * 120.0)
|
||||
elif kind == "key":
|
||||
key = str(payload.get("key", ""))
|
||||
if transform_gizmo is not None and key in {"w", "e", "r"} and bool(payload.get("down", False)):
|
||||
transform_gizmo.world.messenger.send(key)
|
||||
camera_controller.set_key(key, bool(payload.get("down", False)))
|
||||
elif kind == "focus":
|
||||
camera_controller.focus_on_current_target()
|
||||
elif kind == "select_path":
|
||||
path = str(payload.get("path", ""))
|
||||
self._set_selected_node_path(path)
|
||||
if transform_gizmo is not None:
|
||||
self._attach_gizmo_to_selected(transform_gizmo)
|
||||
|
||||
def _can_pick_on_left_down(self, transform_gizmo: TransformGizmo) -> bool:
|
||||
camera_modifier_active = (
|
||||
self._input_modifiers["alt"]
|
||||
or self._input_modifiers["shift"]
|
||||
or self._input_modifiers["ctrl"]
|
||||
)
|
||||
camera_button_active = self._input_buttons["mmb"] or self._input_buttons["rmb"]
|
||||
return not (
|
||||
camera_modifier_active
|
||||
or camera_button_active
|
||||
or transform_gizmo.is_hovering
|
||||
or transform_gizmo.is_dragging
|
||||
)
|
||||
|
||||
def _event_ndc(self, payload: dict[str, object]) -> tuple[float, float] | None:
|
||||
x = payload.get("x")
|
||||
y = payload.get("y")
|
||||
width = payload.get("width")
|
||||
height = payload.get("height")
|
||||
if x is None or y is None:
|
||||
return None
|
||||
if width is None or height is None:
|
||||
return float(x), float(y)
|
||||
w = max(float(width), 1.0)
|
||||
h = max(float(height), 1.0)
|
||||
return (float(x) / w) * 2.0 - 1.0, 1.0 - (float(y) / h) * 2.0
|
||||
|
||||
def _pick_and_attach_gizmo(
|
||||
self,
|
||||
picker_ray: PickerRay,
|
||||
transform_gizmo: TransformGizmo,
|
||||
ndc_x: float,
|
||||
ndc_y: float,
|
||||
) -> None:
|
||||
picked, _ = picker_ray.pick_object(ndc_x, ndc_y)
|
||||
if picked is None:
|
||||
transform_gizmo.detach()
|
||||
self._set_selected_node_path("")
|
||||
return
|
||||
|
||||
path = picked.get_net_python_tag("scene_path")
|
||||
if not path:
|
||||
return
|
||||
self._set_selected_node_path(str(path))
|
||||
self._attach_gizmo_to_selected(transform_gizmo)
|
||||
|
||||
def _attach_gizmo_to_selected(self, transform_gizmo: TransformGizmo) -> None:
|
||||
selected_path = self.selected_node_path()
|
||||
node = self._node_path_lookup.get(selected_path)
|
||||
if node is None:
|
||||
transform_gizmo.detach()
|
||||
return
|
||||
transform_gizmo.attach(node)
|
||||
|
||||
def _set_selected_node_path(self, path: str) -> None:
|
||||
with self._state_lock:
|
||||
self._selected_node_path = path
|
||||
|
||||
def _sync_camera_snapshot(self, camera_controller: CameraOrbitController) -> None:
|
||||
target_x, target_y, target_z, distance, yaw, pitch = camera_controller.camera_state_tuple()
|
||||
with self._state_lock:
|
||||
self._camera_state = CameraState(
|
||||
target_x=target_x,
|
||||
target_y=target_y,
|
||||
target_z=target_z,
|
||||
distance=distance,
|
||||
heading_deg=-yaw,
|
||||
pitch_deg=-pitch,
|
||||
)
|
||||
|
||||
def _update_scene_snapshot(self, scene_root:NodePath) -> None:
|
||||
node_path_lookup: dict[str, object] = {scene_root.get_name(): scene_root}
|
||||
children = [
|
||||
self._snapshot_node(child, scene_root.get_name(), node_path_lookup)
|
||||
for child in scene_root.get_children()
|
||||
]
|
||||
node_lookup: dict[str, SceneNodeSnapshot] = {}
|
||||
for child in children:
|
||||
self._flatten_snapshot(child, node_lookup)
|
||||
with self._state_lock:
|
||||
self._scene_snapshot = children
|
||||
self._node_lookup = node_lookup
|
||||
self._node_path_lookup = node_path_lookup
|
||||
|
||||
def _snapshot_node(self, node, parent_path: str) -> SceneNodeSnapshot:
|
||||
def _snapshot_node(self, node:NodePath, parent_path: str, node_path_lookup: dict[str, object]) -> SceneNodeSnapshot:
|
||||
pos = node.get_pos()
|
||||
hpr = node.get_hpr()
|
||||
scale = node.get_scale()
|
||||
path = f"{parent_path}/{node.get_name()}"
|
||||
node.set_python_tag("scene_path", path)
|
||||
node_path_lookup[path] = node
|
||||
snapshot = SceneNodeSnapshot(
|
||||
path=path,
|
||||
name=node.get_name(),
|
||||
@ -255,7 +456,10 @@ class PandaRendererThread(threading.Thread):
|
||||
hpr=(hpr.x, hpr.y, hpr.z),
|
||||
scale=(scale.x, scale.y, scale.z),
|
||||
)
|
||||
snapshot.children = [self._snapshot_node(child, path) for child in node.get_children()]
|
||||
snapshot.children = [
|
||||
self._snapshot_node(child, path, node_path_lookup)
|
||||
for child in node.get_children()
|
||||
]
|
||||
return snapshot
|
||||
|
||||
def _flatten_snapshot(self, node: SceneNodeSnapshot, lookup: dict[str, SceneNodeSnapshot]) -> None:
|
||||
|
||||
@ -61,17 +61,6 @@ def build_preview_scene(base: ShowBase):
|
||||
floor_material.set_metallic(0.0)
|
||||
floor.set_material(floor_material, 1)
|
||||
|
||||
backdrop = base.loader.loadModel("models/box")
|
||||
backdrop.set_name("Backdrop")
|
||||
backdrop.reparentTo(scene_root)
|
||||
backdrop.set_scale(9.5, 0.12, 6.0)
|
||||
backdrop.set_pos(0.0, 5.8, 3.8)
|
||||
backdrop_material = Material("BackdropMaterial")
|
||||
backdrop_material.set_base_color((0.24, 0.25, 0.28, 1.0))
|
||||
backdrop_material.set_roughness(1.0)
|
||||
backdrop_material.set_metallic(0.0)
|
||||
backdrop.set_material(backdrop_material, 1)
|
||||
|
||||
pedestal_material = Material("PedestalMaterial")
|
||||
pedestal_material.set_base_color((0.55, 0.57, 0.60, 1.0))
|
||||
pedestal_material.set_roughness(0.82)
|
||||
|
||||
569
src/tools/camera_orbit_controller.py
Normal file
569
src/tools/camera_orbit_controller.py
Normal file
@ -0,0 +1,569 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Unity-style orbit camera controller for Panda3D editor viewports.
|
||||
|
||||
The controller can work with normal Panda3D mouseWatcher input, but it also
|
||||
exposes explicit event methods so another UI layer, such as imgui-bundle, can
|
||||
forward viewport-scoped input into the Panda3D thread.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Callable, Optional
|
||||
|
||||
import panda3d.core as p3d
|
||||
from direct.showbase.ShowBase import ShowBase
|
||||
from direct.showbase.ShowBaseGlobal import globalClock
|
||||
from direct.task import Task
|
||||
from panda3d.core import KeyboardButton, LPoint3, MouseButton, MouseWatcher, NodePath, Vec3
|
||||
|
||||
|
||||
class CameraOrbitController:
|
||||
def __init__(
|
||||
self,
|
||||
base: ShowBase,
|
||||
target: Optional[LPoint3] = None,
|
||||
target_model: Optional[NodePath] = None,
|
||||
camera: Optional[NodePath] = None,
|
||||
distance: float = 12.0,
|
||||
yaw: float = 30.0,
|
||||
pitch: float = -20.0,
|
||||
move_speed: float = 12.0,
|
||||
orbit_sensitivity: float = 0.3,
|
||||
orbit_requires_alt: bool = True,
|
||||
pan_factor: float = 0.002,
|
||||
zoom_factor: float = 0.001,
|
||||
block_io: Optional[Callable[[], bool]] = None,
|
||||
use_mouse_watcher: bool = True,
|
||||
register_events: bool = True,
|
||||
) -> None:
|
||||
self.base = base
|
||||
if hasattr(self.base, "disableMouse"):
|
||||
self.base.disableMouse()
|
||||
|
||||
self.render = base.render
|
||||
self.cam = camera or getattr(base, "camera", None) or getattr(base, "cam", None)
|
||||
if self.cam is None:
|
||||
raise ValueError("CameraOrbitController requires a valid camera NodePath.")
|
||||
self.cam_lens_np = self._resolve_camera_lens_np(self.cam) or getattr(base, "cam", None)
|
||||
self.win = base.win
|
||||
self.mouseWatcher: MouseWatcher | None = getattr(base, "mouseWatcherNode", None)
|
||||
self.block_io = block_io
|
||||
|
||||
self.cam_target = target or LPoint3(0, 0, 0)
|
||||
self.distance = max(0.5, float(distance))
|
||||
if target_model is not None and not target_model.is_empty():
|
||||
center, focus_distance, has_bounds = self._compute_focus_goal(target_model, padding=1.2)
|
||||
self.cam_target = center
|
||||
if has_bounds:
|
||||
self.distance = max(0.5, focus_distance)
|
||||
|
||||
self._focus_target_node = target_model
|
||||
self._yaw = float(yaw)
|
||||
self._pitch = max(-89.9, min(89.9, float(pitch)))
|
||||
|
||||
self.move_speed = move_speed
|
||||
self.orbit_sensitivity = orbit_sensitivity
|
||||
self.orbit_requires_alt = orbit_requires_alt
|
||||
self.pan_factor = pan_factor
|
||||
self.zoom_factor = zoom_factor
|
||||
|
||||
self.lmb_down = False
|
||||
self.mmb_down = False
|
||||
self.rmb_down = False
|
||||
self.mods: set[str] = set()
|
||||
self._last_pointer: tuple[float, float] | None = None
|
||||
|
||||
self.move_state = {
|
||||
"w": False,
|
||||
"a": False,
|
||||
"s": False,
|
||||
"d": False,
|
||||
"q": False,
|
||||
"e": False,
|
||||
"space": False,
|
||||
}
|
||||
|
||||
self._mouse_task = None
|
||||
self._focus_task = None
|
||||
self._move_task = None
|
||||
self._events_registered = bool(register_events)
|
||||
|
||||
self._update_camera()
|
||||
if self._events_registered:
|
||||
self._register_events()
|
||||
|
||||
if use_mouse_watcher and self.mouseWatcher is not None:
|
||||
self._mouse_task = base.taskMgr.add(self._poll_mouse, "camera-orbit-mouse")
|
||||
self._move_task = base.taskMgr.add(self._update_task, "camera-orbit-update")
|
||||
|
||||
def destroy(self) -> None:
|
||||
if self._mouse_task:
|
||||
self.base.taskMgr.remove(self._mouse_task)
|
||||
self._mouse_task = None
|
||||
if self._move_task:
|
||||
self.base.taskMgr.remove(self._move_task)
|
||||
self._move_task = None
|
||||
if self._focus_task:
|
||||
self.base.taskMgr.remove(self._focus_task)
|
||||
self._focus_task = None
|
||||
|
||||
if self._events_registered:
|
||||
for key in list(self.move_state.keys()):
|
||||
self.base.ignore(key)
|
||||
self.base.ignore(f"{key}-up")
|
||||
for event_name in (
|
||||
"wheel_up",
|
||||
"wheel_down",
|
||||
"wheel",
|
||||
"mouse1",
|
||||
"mouse1-up",
|
||||
"mouse2",
|
||||
"mouse2-up",
|
||||
"mouse3",
|
||||
"mouse3-up",
|
||||
"mouse-move",
|
||||
"f",
|
||||
"alt-lalt",
|
||||
"lalt-up",
|
||||
"alt-ralt",
|
||||
"ralt-up",
|
||||
):
|
||||
self.base.ignore(event_name)
|
||||
|
||||
def set_modifiers(self, *, alt: bool = False, shift: bool = False, ctrl: bool = False) -> None:
|
||||
self._set_mod("alt", alt)
|
||||
self._set_mod("shift", shift)
|
||||
self._set_mod("ctrl", ctrl)
|
||||
|
||||
def set_button(self, button: str, down: bool, x: float | None = None, y: float | None = None) -> None:
|
||||
self._set_button_state(button, down, {"x": x, "y": y} if x is not None and y is not None else None)
|
||||
|
||||
def move_pointer(self, x: float, y: float) -> None:
|
||||
self._on_mouse_move_event({"x": x, "y": y})
|
||||
|
||||
def wheel(self, delta: float) -> None:
|
||||
self._on_wheel(delta)
|
||||
|
||||
def set_key(self, key: str, pressed: bool) -> None:
|
||||
if key in self.move_state:
|
||||
self.move_state[key] = bool(pressed)
|
||||
|
||||
def focus_on_current_target(self) -> None:
|
||||
self.focus_on_node()
|
||||
|
||||
def camera_state_tuple(self) -> tuple[float, float, float, float, float, float]:
|
||||
return (
|
||||
float(self.cam_target.x),
|
||||
float(self.cam_target.y),
|
||||
float(self.cam_target.z),
|
||||
float(self.distance),
|
||||
float(self._yaw),
|
||||
float(self._pitch),
|
||||
)
|
||||
|
||||
def _resolve_camera_lens_np(self, node_path: Optional[NodePath]) -> Optional[NodePath]:
|
||||
if node_path is None or node_path.is_empty():
|
||||
return None
|
||||
try:
|
||||
if hasattr(node_path.node(), "getLens") or hasattr(node_path.node(), "get_lens"):
|
||||
return node_path
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
candidate = node_path.find("**/+Camera")
|
||||
if candidate is not None and not candidate.is_empty():
|
||||
return candidate
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
def _get_camera_lens(self) -> Optional[p3d.Lens]:
|
||||
if self.cam_lens_np is None or self.cam_lens_np.is_empty():
|
||||
self.cam_lens_np = self._resolve_camera_lens_np(self.cam) or getattr(self.base, "cam", None)
|
||||
if self.cam_lens_np is None or self.cam_lens_np.is_empty():
|
||||
return None
|
||||
try:
|
||||
node = self.cam_lens_np.node()
|
||||
if hasattr(node, "getLens"):
|
||||
return node.getLens()
|
||||
return node.get_lens()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _is_input_blocked(self) -> bool:
|
||||
if self.block_io is None:
|
||||
return False
|
||||
try:
|
||||
return bool(self.block_io())
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _register_events(self) -> None:
|
||||
self.base.accept("alt-lalt", lambda: self._set_mod("alt", True))
|
||||
self.base.accept("lalt-up", lambda: self._set_mod("alt", False))
|
||||
self.base.accept("alt-ralt", lambda: self._set_mod("alt", True))
|
||||
self.base.accept("ralt-up", lambda: self._set_mod("alt", False))
|
||||
|
||||
self.base.accept("wheel_up", lambda: self._on_wheel(120))
|
||||
self.base.accept("wheel_down", lambda: self._on_wheel(-120))
|
||||
self.base.accept("wheel", lambda evt: self._on_wheel(evt.get("delta", 0) if isinstance(evt, dict) else 0))
|
||||
|
||||
for key in self.move_state:
|
||||
self.base.accept(key, self._set_key, [key, True])
|
||||
self.base.accept(f"{key}-up", self._set_key, [key, False])
|
||||
|
||||
self.base.accept("mouse1", lambda evt=None: self._set_button_state("lmb", True, evt))
|
||||
self.base.accept("mouse1-up", lambda evt=None: self._set_button_state("lmb", False, evt))
|
||||
self.base.accept("mouse2", lambda evt=None: self._set_button_state("mmb", True, evt))
|
||||
self.base.accept("mouse2-up", lambda evt=None: self._set_button_state("mmb", False, evt))
|
||||
self.base.accept("mouse3", lambda evt=None: self._set_button_state("rmb", True, evt))
|
||||
self.base.accept("mouse3-up", lambda evt=None: self._set_button_state("rmb", False, evt))
|
||||
self.base.accept("mouse-move", self._on_mouse_move_event)
|
||||
self.base.accept("f", self.focus_on_node)
|
||||
|
||||
def _set_mod(self, mod: str, state: bool) -> None:
|
||||
if state:
|
||||
self.mods.add(mod)
|
||||
else:
|
||||
self.mods.discard(mod)
|
||||
|
||||
def _set_key(self, key: str, pressed: bool) -> None:
|
||||
self.set_key(key, pressed)
|
||||
|
||||
def _set_button_state(self, name: str, down: bool, evt=None) -> None:
|
||||
if name == "lmb":
|
||||
self.lmb_down = bool(down)
|
||||
elif name == "mmb":
|
||||
self.mmb_down = bool(down)
|
||||
elif name == "rmb":
|
||||
self.rmb_down = bool(down)
|
||||
|
||||
if isinstance(evt, dict) and "x" in evt and "y" in evt:
|
||||
self._last_pointer = (float(evt["x"]), float(evt["y"]))
|
||||
elif down:
|
||||
self._last_pointer = None
|
||||
|
||||
def _on_wheel(self, delta: float) -> None:
|
||||
if self._is_input_blocked():
|
||||
return
|
||||
lens = self._get_camera_lens()
|
||||
if lens and not lens.isPerspective():
|
||||
try:
|
||||
film = lens.getFilmSize()
|
||||
zoom = max(0.2, min(5.0, 1.0 - (float(delta) * self.zoom_factor)))
|
||||
lens.setFilmSize(max(0.05, float(film[0]) * zoom), max(0.05, float(film[1]) * zoom))
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
zoom = max(0.2, min(5.0, 1.0 - (float(delta) * self.zoom_factor)))
|
||||
self.distance = max(0.5, self.distance * zoom)
|
||||
self._update_camera()
|
||||
|
||||
def _on_mouse_move_event(self, evt: dict) -> None:
|
||||
if self._is_input_blocked() or not isinstance(evt, dict):
|
||||
return
|
||||
x, y = evt.get("x"), evt.get("y")
|
||||
if x is None or y is None:
|
||||
return
|
||||
x = float(x)
|
||||
y = float(y)
|
||||
|
||||
if self._last_pointer is None:
|
||||
self._last_pointer = (x, y)
|
||||
return
|
||||
dx = x - self._last_pointer[0]
|
||||
dy = y - self._last_pointer[1]
|
||||
self._last_pointer = (x, y)
|
||||
|
||||
alt_down = "alt" in self.mods
|
||||
if self.lmb_down and (alt_down or not self.orbit_requires_alt):
|
||||
self._orbit(dx, dy)
|
||||
elif self.mmb_down:
|
||||
self._pan(dx, dy)
|
||||
elif alt_down and self.rmb_down:
|
||||
self._dolly_from_drag(dy)
|
||||
elif self.rmb_down:
|
||||
self._orbit(dx, dy)
|
||||
|
||||
def _poll_mouse(self, task):
|
||||
if self._is_input_blocked():
|
||||
self._last_pointer = None
|
||||
return task.cont
|
||||
if not (self.mouseWatcher and self.mouseWatcher.hasMouse()):
|
||||
self._last_pointer = None
|
||||
return task.cont
|
||||
|
||||
current_lmb = self.mouseWatcher.isButtonDown(MouseButton.one())
|
||||
current_mmb = self.mouseWatcher.isButtonDown(MouseButton.two())
|
||||
current_rmb = self.mouseWatcher.isButtonDown(MouseButton.three())
|
||||
if (current_lmb, current_mmb, current_rmb) != (self.lmb_down, self.mmb_down, self.rmb_down):
|
||||
self._last_pointer = None
|
||||
self.lmb_down = current_lmb
|
||||
self.mmb_down = current_mmb
|
||||
self.rmb_down = current_rmb
|
||||
|
||||
alt_down = (
|
||||
self.mouseWatcher.isButtonDown(KeyboardButton.alt())
|
||||
or self.mouseWatcher.isButtonDown(KeyboardButton.lalt())
|
||||
or self.mouseWatcher.isButtonDown(KeyboardButton.ralt())
|
||||
)
|
||||
self._set_mod("alt", alt_down)
|
||||
|
||||
pointer = self.win.getPointer(0)
|
||||
self._on_mouse_move_event({"x": pointer.getX(), "y": pointer.getY()})
|
||||
return task.cont
|
||||
|
||||
def _orbit(self, dx: float, dy: float) -> None:
|
||||
self._yaw += dx * self.orbit_sensitivity
|
||||
self._pitch = max(-89.9, min(89.9, self._pitch - dy * self.orbit_sensitivity))
|
||||
self._update_camera()
|
||||
|
||||
def _pan(self, dx: float, dy: float) -> None:
|
||||
quat = self.cam.getQuat(self.render)
|
||||
pan_speed = self.distance * self.pan_factor
|
||||
self.cam_target += quat.getRight() * (-dx * pan_speed)
|
||||
self.cam_target += quat.getUp() * (dy * pan_speed)
|
||||
self._update_camera()
|
||||
|
||||
def _dolly_from_drag(self, dy: float) -> None:
|
||||
dolly = max(0.2, min(5.0, 1.0 - (dy * self.zoom_factor)))
|
||||
self.distance = max(0.5, self.distance * dolly)
|
||||
self._update_camera()
|
||||
|
||||
def _update_task(self, task: Task):
|
||||
dt = globalClock.getDt()
|
||||
move_vec = Vec3(0, 0, 0)
|
||||
|
||||
quat = self.cam.getQuat(self.render)
|
||||
forward = quat.getForward()
|
||||
right = quat.getRight()
|
||||
up = Vec3(0, 0, 1)
|
||||
|
||||
if self.rmb_down:
|
||||
if self.move_state["w"]:
|
||||
move_vec += forward
|
||||
if self.move_state["s"]:
|
||||
move_vec -= forward
|
||||
if self.move_state["a"]:
|
||||
move_vec -= right
|
||||
if self.move_state["d"]:
|
||||
move_vec += right
|
||||
if self.move_state["q"]:
|
||||
move_vec -= up
|
||||
if self.move_state["e"] or self.move_state["space"]:
|
||||
move_vec += up
|
||||
|
||||
if move_vec.length_squared() > 0:
|
||||
move_vec.normalize()
|
||||
move_vec *= self.move_speed * dt
|
||||
self.cam_target += move_vec
|
||||
self._update_camera()
|
||||
|
||||
return task.cont
|
||||
|
||||
def _update_camera(self, look_at: bool = True) -> None:
|
||||
rad_yaw = math.radians(self._yaw)
|
||||
rad_pitch = math.radians(self._pitch)
|
||||
direction = Vec3(
|
||||
math.sin(rad_yaw) * math.cos(rad_pitch),
|
||||
math.cos(rad_yaw) * math.cos(rad_pitch),
|
||||
math.sin(rad_pitch),
|
||||
)
|
||||
self.cam.setPos(self.cam_target - direction * self.distance)
|
||||
if look_at:
|
||||
self.cam.lookAt(self.cam_target)
|
||||
|
||||
def get_pivot(self) -> LPoint3:
|
||||
return LPoint3(self.cam_target)
|
||||
|
||||
def set_target(self, target: NodePath | None) -> None:
|
||||
if target is not None:
|
||||
self.cam_target = target.get_pos(self.render)
|
||||
self._focus_target_node = target
|
||||
self._update_camera()
|
||||
|
||||
def set_focus_target(self, focus_node: Optional[NodePath]) -> None:
|
||||
self._focus_target_node = focus_node
|
||||
|
||||
def sync_from_camera(self, pivot: LPoint3 | Vec3 | NodePath | None = None) -> None:
|
||||
if pivot is None:
|
||||
pivot_point = LPoint3(self.cam_target)
|
||||
elif isinstance(pivot, NodePath):
|
||||
pivot_point = pivot.get_pos(self.render)
|
||||
else:
|
||||
pivot_point = LPoint3(pivot)
|
||||
|
||||
cam_pos = self.cam.getPos(self.render)
|
||||
offset = cam_pos - pivot_point
|
||||
distance = max(offset.length(), 0.5)
|
||||
horizontal_len = math.sqrt((offset.x * offset.x) + (offset.y * offset.y))
|
||||
if horizontal_len <= 1e-6:
|
||||
is_top_view = offset.z > 0.0
|
||||
pitch = -89.9 if is_top_view else 89.9
|
||||
up_vec = self.cam.getQuat(self.render).getUp()
|
||||
up_xy_len = math.sqrt((up_vec.x * up_vec.x) + (up_vec.y * up_vec.y))
|
||||
if up_xy_len <= 1e-6:
|
||||
yaw = self._yaw
|
||||
elif is_top_view:
|
||||
yaw = math.degrees(math.atan2(up_vec.x, up_vec.y))
|
||||
else:
|
||||
yaw = math.degrees(math.atan2(-up_vec.x, -up_vec.y))
|
||||
else:
|
||||
pitch = math.degrees(math.atan2(-offset.z, max(horizontal_len, 1e-8)))
|
||||
yaw = math.degrees(math.atan2(-offset.x, -offset.y))
|
||||
|
||||
self.cam_target = LPoint3(pivot_point)
|
||||
self.distance = distance
|
||||
self._yaw = yaw
|
||||
self._pitch = max(-89.9, min(89.9, pitch))
|
||||
|
||||
def set_projection_mode(self, perspective: bool) -> None:
|
||||
current_lens = self._get_camera_lens()
|
||||
if current_lens is None or self.cam_lens_np is None:
|
||||
return
|
||||
if bool(perspective) == bool(current_lens.isPerspective()):
|
||||
return
|
||||
|
||||
if perspective:
|
||||
new_lens = p3d.PerspectiveLens()
|
||||
try:
|
||||
new_lens.setFov(current_lens.getFov())
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
new_lens = p3d.OrthographicLens()
|
||||
aspect = 1.0
|
||||
if self.base.win and self.base.win.getYSize() > 0:
|
||||
aspect = float(self.base.win.getXSize()) / float(self.base.win.getYSize())
|
||||
try:
|
||||
fov_y = float(current_lens.getFov()[1])
|
||||
except Exception:
|
||||
fov_y = 40.0
|
||||
view_height = max(0.1, 2.0 * self.distance * math.tan(math.radians(fov_y) * 0.5))
|
||||
new_lens.setFilmSize(view_height * aspect, view_height)
|
||||
|
||||
try:
|
||||
new_lens.setNearFar(current_lens.getNear(), current_lens.getFar())
|
||||
except Exception:
|
||||
pass
|
||||
self.cam_lens_np.node().setLens(new_lens)
|
||||
|
||||
def _set_standard_view(
|
||||
self,
|
||||
yaw_deg: float,
|
||||
pitch_deg: float,
|
||||
distance: float | None = None,
|
||||
target: LPoint3 | NodePath | None = None,
|
||||
) -> None:
|
||||
if target is not None:
|
||||
if isinstance(target, NodePath):
|
||||
self.cam_target = target.get_pos(self.render)
|
||||
self._focus_target_node = target
|
||||
else:
|
||||
self.cam_target = LPoint3(target)
|
||||
if distance is not None:
|
||||
self.distance = max(0.5, float(distance))
|
||||
self._yaw = yaw_deg
|
||||
self._pitch = max(-89.9, min(89.9, pitch_deg))
|
||||
self._update_camera()
|
||||
|
||||
def set_front_view(self, distance: float | None = None, target: LPoint3 | NodePath | None = None) -> None:
|
||||
self._set_standard_view(0.0, 0.0, distance, target)
|
||||
|
||||
def set_side_view(
|
||||
self,
|
||||
right: bool = True,
|
||||
distance: float | None = None,
|
||||
target: LPoint3 | NodePath | None = None,
|
||||
) -> None:
|
||||
self._set_standard_view(-90.0 if right else 90.0, 0.0, distance, target)
|
||||
|
||||
def set_top_view(self, distance: float | None = None, target: LPoint3 | NodePath | None = None) -> None:
|
||||
self._set_standard_view(0.0, -89.9, distance, target)
|
||||
|
||||
def focus_on_node(
|
||||
self,
|
||||
node: Optional[NodePath] = None,
|
||||
duration: float = 0.2,
|
||||
padding: float = 1.2,
|
||||
force: bool = False,
|
||||
) -> None:
|
||||
target_node = node or self._focus_target_node
|
||||
if target_node is None or target_node.is_empty():
|
||||
return
|
||||
|
||||
center, target_distance, has_bounds = self._compute_focus_goal(target_node, padding)
|
||||
if not has_bounds:
|
||||
target_distance = max(self.distance, 3.0)
|
||||
if abs(p3d.LPoint3f(center - self.cam.get_pos()).length() - target_distance) < 0.1:
|
||||
target_distance = max(target_distance / 3.0, 1.0)
|
||||
|
||||
self._start_focus_lerp(center, target_distance, duration, force)
|
||||
|
||||
def _compute_focus_goal(self, node: NodePath, padding: float) -> tuple[LPoint3, float, bool]:
|
||||
try:
|
||||
bounds = node.get_tight_bounds(self.render)
|
||||
except Exception:
|
||||
try:
|
||||
bounds = node.getTightBounds(self.render)
|
||||
except Exception:
|
||||
bounds = None
|
||||
|
||||
if bounds and len(bounds) >= 2 and bounds[0] is not None and bounds[1] is not None:
|
||||
bmin, bmax = bounds
|
||||
extent = bmax - bmin
|
||||
if extent.length_squared() > 1e-6:
|
||||
center = (bmin + bmax) * 0.5
|
||||
radius = extent.length() * 0.5 * padding
|
||||
lens = self._get_camera_lens()
|
||||
if lens and lens.isPerspective():
|
||||
fov = lens.getFov()
|
||||
half_fov_x = max(1e-3, math.radians(float(fov[0])) * 0.5)
|
||||
half_fov_y = max(1e-3, math.radians(float(fov[1])) * 0.5)
|
||||
distance = max(radius / math.tan(half_fov_x), radius / math.tan(half_fov_y))
|
||||
distance = max(distance, lens.getNear() + radius * 0.5)
|
||||
elif lens:
|
||||
target_height = max(radius * 2.0, 0.1)
|
||||
film = lens.getFilmSize()
|
||||
aspect = max(1e-6, float(film[0]) / float(film[1])) if float(film[1]) else 1.0
|
||||
lens.setFilmSize(target_height * aspect, target_height)
|
||||
distance = max(self.distance, 0.5)
|
||||
else:
|
||||
distance = radius / math.tan(math.radians(30.0))
|
||||
return LPoint3(center), max(distance, 0.5), True
|
||||
|
||||
try:
|
||||
center = node.get_pos(self.render)
|
||||
except Exception:
|
||||
center = self.cam_target
|
||||
return LPoint3(center), max(self.distance, 0.5), False
|
||||
|
||||
def _start_focus_lerp(
|
||||
self,
|
||||
target_center: LPoint3,
|
||||
target_distance: float,
|
||||
duration: float,
|
||||
force: bool = False,
|
||||
) -> None:
|
||||
if self._focus_task:
|
||||
self.base.taskMgr.remove(self._focus_task)
|
||||
self._focus_task = None
|
||||
|
||||
start_target = LPoint3(self.cam_target)
|
||||
start_distance = float(self.distance)
|
||||
duration = max(0.01, duration)
|
||||
|
||||
def _lerp(task: Task):
|
||||
t = min(task.time / duration, 1.0)
|
||||
t = t * t * (3 - 2 * t)
|
||||
self.cam_target = start_target * (1.0 - t) + target_center * t
|
||||
self.distance = start_distance + (target_distance - start_distance) * t
|
||||
self._update_camera()
|
||||
if t >= 1.0:
|
||||
self._focus_task = None
|
||||
return task.done
|
||||
return task.cont
|
||||
|
||||
self._focus_task = self.base.taskMgr.add(_lerp, "camera-focus-lerp")
|
||||
80
src/tools/picker_ray.py
Normal file
80
src/tools/picker_ray.py
Normal file
@ -0,0 +1,80 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from direct.showbase.ShowBase import ShowBase
|
||||
from panda3d.core import (
|
||||
CollisionHandlerQueue,
|
||||
CollisionNode,
|
||||
CollisionRay,
|
||||
CollisionTraverser,
|
||||
GeomNode,
|
||||
LPoint3f,
|
||||
LVector3f,
|
||||
NodePath,
|
||||
)
|
||||
|
||||
|
||||
class PickerRay:
|
||||
def __init__(self, root: ShowBase, pick_root: NodePath | None = None, camera: NodePath | None = None):
|
||||
self.root = root
|
||||
self.pick_root = pick_root or root.render
|
||||
self.camera = camera or getattr(root, "cam", None)
|
||||
self.debug = False
|
||||
|
||||
def _setup_picker(self) -> None:
|
||||
if self.camera is None or self.camera.is_empty():
|
||||
raise RuntimeError("PickerRay requires a valid camera NodePath.")
|
||||
|
||||
self.picker = CollisionTraverser()
|
||||
self.pq = CollisionHandlerQueue()
|
||||
self.picker_node = CollisionNode("mouseRay")
|
||||
self.picker_np = self.camera.attachNewNode(self.picker_node)
|
||||
self.picker_node.setFromCollideMask(GeomNode.getDefaultCollideMask())
|
||||
self.picker_node.setIntoCollideMask(0)
|
||||
self.picker_ray = CollisionRay()
|
||||
self.picker_node.addSolid(self.picker_ray)
|
||||
self.picker.addCollider(self.picker_np, self.pq)
|
||||
|
||||
def debug_log(self, msg: str) -> None:
|
||||
if self.debug:
|
||||
print(f"[PickerRay] {msg}")
|
||||
|
||||
def pick_object(
|
||||
self,
|
||||
ndc_x: float | None = None,
|
||||
ndc_y: float | None = None,
|
||||
) -> tuple[NodePath | None, LVector3f | None]:
|
||||
if not hasattr(self, "picker"):
|
||||
self._setup_picker()
|
||||
|
||||
if ndc_x is None or ndc_y is None:
|
||||
mouse_watcher = getattr(self.root, "mouseWatcherNode", None)
|
||||
if mouse_watcher is None or not mouse_watcher.hasMouse():
|
||||
return None, None
|
||||
mpos = mouse_watcher.getMouse()
|
||||
ndc_x = mpos.x
|
||||
ndc_y = mpos.y
|
||||
|
||||
self.debug_log(f"screen ndc: {ndc_x}, {ndc_y}")
|
||||
|
||||
camera_node = self.camera.node()
|
||||
self.picker_ray.setFromLens(camera_node, float(ndc_x), float(ndc_y))
|
||||
self.pq.clearEntries()
|
||||
self.picker.traverse(self.pick_root)
|
||||
|
||||
if self.pq.getNumEntries() <= 0:
|
||||
self.debug_log("no object picked")
|
||||
return None, None
|
||||
|
||||
self.pq.sortEntries()
|
||||
for i in range(self.pq.getNumEntries()):
|
||||
closest_entry = self.pq.getEntry(i)
|
||||
picked_object = closest_entry.getIntoNodePath()
|
||||
if picked_object.isHidden():
|
||||
continue
|
||||
|
||||
collision_point = closest_entry.getSurfacePoint(self.root.render)
|
||||
self.debug_log(f"picked object: {picked_object.getName()}")
|
||||
self.debug_log(f"hit point: {collision_point}")
|
||||
return picked_object, collision_point
|
||||
|
||||
return None, LPoint3f(0, 0, 0)
|
||||
Loading…
Reference in New Issue
Block a user