编辑器中集成编辑器漫游相机,点选物体和Gizmo拖动变换;docs(TransformGizmo): 添加事件钩子使用文档和事件常量定义

---
- 新增 transform_gizmo_events.md 文档,详细说明如何使用事件回调钩子
- 定义 GizmoEvent 和 TransformGizmoMode 常量类
- 提供完整的事件钩子使用示例和回调负载字段说明
- 支持运行时动态添加/移除事件回调

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
赵豪 2026-04-24 16:17:27 +08:00
parent 69fb0c21b5
commit 4f5eaeb92b
13 changed files with 5319 additions and 58 deletions

View 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/2X/Y/Z平面拖拽时为 `None`
- `plane`0/1/2XY/YZ/ZX轴拖拽时为 `None`
- `mouse``Point2`,拖拽开始/过程中鼠标 NDC 坐标
- `start_pos` / `new_pos` / `final_pos`:世界坐标 `Vec3`
- `target`:当前绑定的 `NodePath`
- `gizmo`:字符串 `"move"`
### Rotate
- `mode``"axis"` 或 `"trackball"`
- `axis`0/1/2X/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/Z3 表示中心均匀缩放
- `scale_factor`:本次 `drag_move` 计算出的缩放倍率
- `start_scale` / `new_scale` / `final_scale``Vec3`
- `mouse``Point2`
- `target``gizmo` 同上
## 小贴士
- 事件字典的键可以用字符串 `"move"`/`"rotate"`/`"scale"`,也可以用 `TransformGizmoMode` 的对应值,内部都会匹配。
- 每个事件支持多个回调(列表),内部对单个回调异常做了 try/except互不影响。
- 如果只想监听部分事件,留空即可,内部会自动填充为空列表。

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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

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

View File

@ -1,4 +1,4 @@
from imgui_bundle import immapp, immvision
from imgui_bundle import immapp, immvision # type: ignore
from .editor import EditorApp

View File

@ -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:

View File

@ -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,11 +12,23 @@ 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()
runner.app_window_params.window_title = WINDOW_TITLE
@ -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):

View File

@ -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:

View File

@ -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)

View 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
View 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)