diff --git a/src/TransformGizmo/docs/transform_gizmo_events.md b/src/TransformGizmo/docs/transform_gizmo_events.md new file mode 100644 index 0000000..461cc6c --- /dev/null +++ b/src/TransformGizmo/docs/transform_gizmo_events.md @@ -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,互不影响。 +- 如果只想监听部分事件,留空即可,内部会自动填充为空列表。 diff --git a/src/TransformGizmo/events.py b/src/TransformGizmo/events.py new file mode 100644 index 0000000..1e8b49b --- /dev/null +++ b/src/TransformGizmo/events.py @@ -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"] diff --git a/src/TransformGizmo/move_gizmo.py b/src/TransformGizmo/move_gizmo.py new file mode 100644 index 0000000..f4e5697 --- /dev/null +++ b/src/TransformGizmo/move_gizmo.py @@ -0,0 +1,1074 @@ +import math +from typing import Optional, Tuple, Callable, Dict, Any, List + +from panda3d.core import ( + NodePath, + Vec3, + Point2, + Vec4, + Quat, + CardMaker, + GeomNode, + GeomVertexFormat, + GeomVertexData, + GeomVertexWriter, + GeomTriangles, + Geom, + CollisionNode, + CollisionRay, + CollisionHandlerQueue, + CollisionTraverser, + CollisionTube, + CollisionSphere, + CollisionPolygon, + CollisionEntry, + Point3, + Lens, + PerspectiveLens +) +from direct.showbase.DirectObject import DirectObject +from direct.task import Task +from panda3d.core import BitMask32,LPoint2f +from direct.showbase.ShowBase import ShowBase + +from .events import GizmoEvent + + +class MoveGizmo(DirectObject): + """ + A Unity-like Move Gizmo for Panda3D. + Features: + - 3 Axes (X=Red, Y=Green, Z=Blue) with Cylinder + Cone. + - Renders on top of other geometry. + - Handles mouse interaction for dragging. + """ + + def __init__( + self, + world:ShowBase, + camera_np: NodePath = 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 = False + self._debug_drag_counter = 0 + self._picker_added = False + self.is_hovering = False + # Optional callback to report completed move actions to a higher-level + # manager (e.g. TransformGizmo) so it can build a global undo stack. + self.on_action_committed = on_action_committed + # Event hooks: drag_start / drag_move / drag_end + self._event_hooks = self._normalize_event_hooks(event_hooks) + # If camera is not provided, try to find it in the world or base + self.camera = camera_np if camera_np else 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") + else: + self._log(f"Camera found: {self.camera}") + + self.root = NodePath("MoveGizmo") + self.color_higher = 0.05 + self.color_normal = 0.01 + + self.target_node: Optional[NodePath] = None + self.attached = False + + # 控制柄外观设置 + self.arrow_cylider_length = 0.45 # 圆柱长度 + self.arrow_cylider_radius = 0.005 # 圆柱粗细 + # 碰撞体半径,可独立于可视半径调整 + self.arrow_cylider_col_radius = max(0.02, self.arrow_cylider_radius * 5.0) + self.arrow_cone_height = 0.1 # 圆锥高度 + self.arrow_cone_radius = 0.03 # 圆锥半径 + self.arrow_transparency: float = 0.8 # 箭头透明度 + + self.panel_transparency: float = 0.65 + self.plane_size: float = 0.1 + self.plane_offset: float = self.plane_size * 0.5 + 0.01 + + # Collision setup for picking + self.picker_ray = CollisionRay() + self.picker_node = CollisionNode('gizmo_picker_ray') + self.picker_node.addSolid(self.picker_ray) + self.picker_node.setFromCollideMask(BitMask32.bit(20)) # Specific mask for gizmo + self.picker_node.setIntoCollideMask(BitMask32.allOff()) + self.picker_np = NodePath(self.picker_node) + + self.c_trav = CollisionTraverser() + self.c_queue = CollisionHandlerQueue() + + # Dragging state + self.dragging: bool = False + self.drag_axis: int = None # 0=X, 1=Y, 2=Z + self.start_mouse: Point2 = None # NDC Point2 + self.start_node_pos = None + # param value of closest point on axis at drag start + self._start_param_on_ray: float = 0.0 + # sign so that dragging along axis arrow matches mouse direction + self._axis_param_sign: float = 1.0 + self.last_mouse_pos: Point3 = None + self.drag_axis_dir: Vec3 = Vec3(0, 0, 0) # world-space axis dir + self.drag_axis_screen: Point2 = Point2(1, 0) # axis direction in screen space + self.drag_base_dist: float = 1.0 # camera distance at drag start + + # Visuals + self.axes: list[NodePath] = [] + self.planes: list[NodePath] = [] # plane handles: 0=XY,1=YZ,2=ZX + self.axes_base_colors: list[Tuple[NodePath,Vec4]] = [] + self.planes_base_colors: list[Tuple[NodePath,Vec4]] = [] + # Plane drag state + self.drag_plane_id: Optional[int] = None # 0=XY,1=YZ,2=ZX + self.drag_plane_normal: Vec3 = Vec3(0, 0, 0) + self.drag_plane_origin: Point3 = Point3(0, 0, 0) # point on plane at drag start + # target_pos - hit_point at drag start + self._plane_drag_offset: Vec3 = Vec3(0, 0, 0) + + # --- Undo stack for move operations --------------------------------- + # Each entry: (target_node, old_pos(world), new_pos(world)) + self._move_undo_stack: list[Tuple[NodePath, Vec3, Vec3]] = [] + # Minimal distance to consider as a real move (avoid float noise) + self._undo_pos_epsilon: float = 1e-4 + + self._build_gizmo() + + # 初始挂在 world.render,后续通过更新同步到目标 + self.root.reparentTo(self.world.render) + self.root.setBin('fixed',40) + self.root.setDepthTest(False) + self.root.setDepthWrite(False) + self.root.setLightOff() + + self.root.hide() + + # Update gizmo scale every frame so that it + # stays roughly the same size on screen + # taskMgr.add(self._update_task, "MoveGizmoUpdateTask") + + def _log(self, msg: str): + if self.debug: + print(f"[MoveGizmo] {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): + """Builds the 3 axes geometry and plane handles.""" + # Axis params + + # X Axis (Red) + self.axes.append(self._create_axis(Vec3(1, 0, 0), Vec4( + 1, 0, 0, 1), self.arrow_cylider_length, self.arrow_cylider_radius, self.arrow_cylider_col_radius, self.arrow_cone_height, self.arrow_cone_radius, 0)) + # Y Axis (Green) + self.axes.append(self._create_axis(Vec3(0, 1, 0), Vec4( + 0, 1, 0, 1), self.arrow_cylider_length, self.arrow_cylider_radius, self.arrow_cylider_col_radius, self.arrow_cone_height, self.arrow_cone_radius, 1)) + # Z Axis (Blue) + self.axes.append(self._create_axis(Vec3(0, 0, 1), Vec4( + 0, 0, 1, 1), self.arrow_cylider_length, self.arrow_cylider_radius, self.arrow_cylider_col_radius, self.arrow_cone_height, self.arrow_cone_radius, 2)) + + # Plane handles: small squares between axes, slightly offset from origin. + # 0 = XY, 1 = YZ, 2 = ZX + half = self.plane_size * 0.5 + inner = self.plane_offset + half + # XY plane:贴近 +X/+Y 象限 + self.planes.append( + self._create_plane_handle( + name="plane_xy", + plane_id=0, + axis_a=0, + axis_b=1, + color=Vec4(1, 1, 0, self.panel_transparency), # 黄色 + size=self.plane_size, + # 以平面中心为轴心,让靠近原点的角距离原点 plane_offset + offset_vec=Vec3(inner, inner, 0.0), + hpr=Vec3(0, -90, 0), + ) + ) + # YZ plane (normal +X) + self.planes.append( + self._create_plane_handle( + name="plane_yz", + plane_id=1, + axis_a=1, + axis_b=2, + color=Vec4(0, 1, 1, self.panel_transparency), # 青色 + size=self.plane_size, + offset_vec=Vec3(0.0, inner, inner), + hpr=Vec3(90, 0, 0), # rotate XY card to YZ + ) + ) + # ZX plane (normal +Y) + self.planes.append( + self._create_plane_handle( + name="plane_zx", + plane_id=2, + axis_a=2, + axis_b=0, + color=Vec4(0.5, 0, 0, self.panel_transparency), # 深红 + size=self.plane_size, + offset_vec=Vec3(inner, 0.0, inner), + # Rotate XY card so it lies in the ZX plane (normal ±Y) + hpr=Vec3(0, 0, 0), + ) + ) + + def _create_axis(self, direction: Vec3, color: Vec4, length, radius_visual, radius_collision, cone_height, cone_radius, axis_id): + """Creates a single axis arrow.""" + axis_root:NodePath = self.root.attachNewNode(f"axis_{axis_id}") + + # Align axis (default geometry faces +Z) to the requested direction. + align_quat = self._quat_from_z(direction) + axis_root.setQuat(align_quat) + self._log( + f"axis {axis_id} dir={direction} quat={align_quat} " + f"world_dir={axis_root.getQuat(self.world.render).xform(Vec3(0, 0, 1))}" + ) + + # Visual Geometry + geom = self._create_arrow_geom(length, radius_visual, cone_height, cone_radius, color) + geom_np: NodePath = axis_root.attachNewNode(geom) + geom_np.setTransparency(True) + geom_np.setAlphaScale(self.arrow_transparency) + self._log(f"created geom_np -> {geom_np}") + + # Collision Geometry (Tube) + # Tube is defined by point A and B. + shaft_len = max(length - cone_height, 0.0) + shaft_radius = max(1e-6, radius_collision) + c_node = CollisionNode(f"axis_col_{axis_id}") + # Shaft collider + if shaft_len > 0.0: + c_node.addSolid(CollisionTube( + Point3(0, 0, 0), + Point3(0, 0, shaft_len), + shaft_radius)) + # Cone collider: a slightly wider tube plus a small sphere at the tip + if cone_height > 0.0: + cone_start = Point3(0, 0, shaft_len) + cone_tip = Point3(0, 0, length) + cone_radius_pad = max(cone_radius, shaft_radius) + c_node.addSolid(CollisionTube(cone_start, cone_tip, cone_radius_pad)) + c_node.addSolid(CollisionSphere(cone_tip, cone_radius_pad)) + c_node.setIntoCollideMask(BitMask32.bit(20)) + c_node.setFromCollideMask(BitMask32.allOff()) + c_node.setTag('gizmo_axis', str(axis_id)) + + col_np = axis_root.attachNewNode(c_node) + # col_np.show() # Debug collision + self._log(f"created col_np -> {col_np}") + + return axis_root + + def _create_plane_handle( + self, + name: str, + plane_id: int, + axis_a: int, + axis_b: int, + color: Vec4, + size: float, + offset_vec: Vec3, + hpr: Vec3 = Vec3(0, 0, 0), + ) -> NodePath: + """ + Create a small square plane handle between two axes. + plane_id: 0=XY, 1=YZ, 2=ZX + axis_a / axis_b: not currently used for math, kept for clarity. + """ + cm = CardMaker(name) + # Square centered at origin in local X/Z (pivot at center) + half = size * 0.5 + cm.setFrame(-half, half, -half, half) + card_np: NodePath = self.root.attachNewNode(cm.generate()) + card_np.setPos(offset_vec) + card_np.setHpr(hpr) + card_np.setColor(color) + card_np.setTransparency(True) + # Double-sided rendering so the plane looks like Unity handles + card_np.setTwoSided(True) + + # Collision polygon matching the card in local space + # Define square in the same local coordinates as the CardMaker frame. + p0 = Point3(-half, 0.0, -half) + p1 = Point3(half, 0.0, -half) + p2 = Point3(half, 0.0, half) + p3 = Point3(-half, 0.0, half) + # Create two polygons with opposite winding so the plane is hit + # from both sides by the picking ray. + poly_front = CollisionPolygon(p0, p1, p2, p3) + poly_back = CollisionPolygon(p3, p2, p1, p0) + + c_node = CollisionNode(f"plane_col_{plane_id}") + c_node.addSolid(poly_front) + c_node.addSolid(poly_back) + c_node.setIntoCollideMask(BitMask32.bit(20)) + c_node.setFromCollideMask(BitMask32.allOff()) + c_node.setTag("gizmo_plane", str(plane_id)) + + col_np = card_np.attachNewNode(c_node) + # col_np.show() + self._log(f"created plane {name} col_np -> {col_np}") + + return card_np + + def _create_arrow_geom(self, length, radius, cone_height, cone_radius, color): + """Creates the mesh for cylinder + cone.""" + # Using simple line or loading model is easier, but let's try procedural for "no external assets" requirement + # Actually, Panda has `loader.loadModel("models/misc/cylinder")` but let's make a simple one or use LineSegs for shaft? + # User requested "Cylinder + Cone". + + # Let's use a helper function to generate a cylinder and cone using GeomVertexWriter + # For simplicity in this script, I will use a very thin box or simple geometry. + # Or better, load a basic shape if available, but procedural is safer to avoid missing assets. + + # Simplified: Use Line for shaft (thick) and Triangle fan for cone? + # No, user wants "Cylinder". + + # Let's use CardMaker for a cross-section look or just build a simple mesh. + # To save code space, I'll implement a basic procedural cylinder/cone generator. + + vdata = GeomVertexData( + 'name', GeomVertexFormat.getV3c4(), Geom.UHStatic) + vertex = GeomVertexWriter(vdata, 'vertex') + vcolor = GeomVertexWriter(vdata, 'color') + + # Shaft (Cylinder) - Simplified as a prism with 8 sides + segments = 12 + shaft_len = length - cone_height + + def add_circle_verts(z, r): + start_idx = vertex.getWriteRow() + for i in range(segments): + angle = 2 * math.pi * i / segments + x = r * math.cos(angle) + y = r * math.sin(angle) + vertex.addData3(x, y, z) + vcolor.addData4(color) + return start_idx + + # Bottom circle + base_idx = add_circle_verts(0, radius) + # Top of shaft + top_shaft_idx = add_circle_verts(shaft_len, radius) + + prim = GeomTriangles(Geom.UHStatic) + for i in range(segments): + next_i = (i + 1) % segments + # Side quads (2 tris) + prim.addVertices(base_idx + i, base_idx + + next_i, top_shaft_idx + next_i) + prim.addVertices(base_idx + i, top_shaft_idx + + next_i, top_shaft_idx + i) + + # Cone base + cone_base_idx = add_circle_verts(shaft_len, cone_radius) + # Cone tip + vertex.addData3(0, 0, length) + vcolor.addData4(color) + tip_idx = vertex.getWriteRow() - 1 + + # Cone sides + for i in range(segments): + next_i = (i + 1) % segments + prim.addVertices(cone_base_idx + i, + cone_base_idx + next_i, tip_idx) + # Cone bottom cap (optional, usually hidden by shaft) + prim.addVertices(cone_base_idx + next_i, + cone_base_idx + i, top_shaft_idx + i) # Stitching? + + geom = Geom(vdata) + geom.addPrimitive(prim) + node = GeomNode('axis_geom') + node.addGeom(geom) + return node + + def _quat_from_z(self, direction: Vec3) -> Quat: + """Return a quaternion rotating +Z to the given (normalized) 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() # Already aligned + if abs(dot + 1.0) < 1e-6: + # Opposite; rotate 180° around any axis perpendicular to Z + 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): + """Attach gizmo to a node.""" + 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()} world_pos={self.root.getPos(self.world.render)} " + f"target_hpr={node_path.getHpr(self.world.render)}" + ) + self._register_events() + self._reset_planels() + + def detach(self): + """Detach gizmo.""" + self.target_node = None + self.root.hide() + self.attached = False + self.drag_plane_id = None + self.is_hovering = False + self._log("detached") + self._ignore_events() + + # --- Undo support ------------------------------------------------- + def _record_move_action(self, node: NodePath, old_pos: Vec3, new_pos: Vec3): + """ + Push a move operation to the undo stack if the position changed + by more than a small epsilon. + """ + if not node or node.isEmpty(): + return + if not old_pos or new_pos is None: + return + delta = new_pos - old_pos + if delta.length() < self._undo_pos_epsilon: + # Ignore tiny moves caused by float noise + return + # Store copies so later modifications don't affect history + old_copy = Vec3(old_pos) + new_copy = Vec3(new_pos) + self._move_undo_stack.append((node, old_copy, new_copy)) + self._log( + f"record move action: node={node.getName()} " + f"old_pos={old_copy} new_pos={new_copy} stack_size={len(self._move_undo_stack)}" + ) + # Report to external listener (e.g. TransformGizmo) to build a + # unified, cross-gizmo undo history. + if self.on_action_committed is not None: + try: + self.on_action_committed( + { + "kind": "move", + "node": node, + "old_pos": Vec3(old_copy), + "new_pos": Vec3(new_copy), + } + ) + except Exception as exc: + # Avoid breaking interaction if user callback fails. + self._log(f"on_action_committed(move) error: {exc}") + + def undo_last_move(self): + """ + Undo the last committed move. + Default hotkey (from Qt -> Panda3D translation): Ctrl+Z => 'control-z'. + """ + if not self._move_undo_stack: + self._log("undo_last_move: stack empty") + return + node, old_pos, new_pos = self._move_undo_stack.pop() + if node is None or node.isEmpty(): + self._log("undo_last_move: target node invalid") + return + node.setPos(self.world.render, old_pos) + self._log( + f"undo_last_move: node={node.getName()} " + f"old_pos={old_pos} new_pos={new_pos} remaining={len(self._move_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") + + # --- Mouse helpers ------------------------------------------------- + def _get_normalized_mouse(self, extra) -> Optional[Point3]: + """ + Convert Qt pixel coordinates (from QPanda3DWidget) to Panda's + normalized device coordinates (-1..1), or fall back to + mouseWatcherNode when available. + """ + 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) + + # 1) Extra payload from QPanda3DWidget (pixels) + if isinstance(extra, dict) and "x" in extra and "y" in extra: + parent = getattr(self.world, "parent", None) + if parent is not None: + w = max(parent.width(), 1) + h = max(parent.height(), 1) + nx = (extra["x"] / w) * 2.0 - 1.0 + # Qt origin is top‑left, Panda origin is center with +Y up + ny = 1.0 - (extra["y"] / h) * 2.0 + return Point3(nx, ny, 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: + return + + # Check for picking + mpos = self._get_normalized_mouse(extra) + + if mpos is None: + self._log("mouse_down ignored: no mouse pos") + return + + # Setup ray + self.picker_np.reparentTo(self.camera) + if not self._picker_added: + self.c_trav.addCollider(self.picker_np, self.c_queue) + self._picker_added = True + self.picker_ray.setFromLens(self.camera.node(), mpos.x, mpos.y) + + self.c_queue.clearEntries() + self.c_trav.traverse(self.root) + + self._log( + f"mouse_down mpos={mpos} cam_pos={self.camera.getPos(self.world.render)} " + f"cam_hpr={self.camera.getHpr(self.world.render)} entries={self.c_queue.getNumEntries()}" + ) + + num_entries = self.c_queue.getNumEntries() + if num_entries > 0: + self.c_queue.sortEntries() + + # Unity 风格:优先选择平面,其次才是轴。 + plane_entry: Optional[CollisionEntry] = None + axis_entry: Optional[CollisionEntry] = None + for i in range(num_entries): + e: CollisionEntry = self.c_queue.getEntry(i) + if not plane_entry and e.getIntoNode().getTag("gizmo_plane"): + plane_entry = e + if not axis_entry and e.getIntoNode().getTag("gizmo_axis"): + axis_entry = e + if plane_entry and axis_entry: + break + + # Prefer plane hit when available + if plane_entry is not None: + entry = plane_entry + plane_tag: str = entry.getIntoNode().getTag("gizmo_plane") + # --- Begin plane dragging --- + # --- Begin plane dragging --- + self.dragging = True + self.drag_plane_id = int(plane_tag) # 0=XY,1=YZ,2=ZX + # print(f"[Gizmo] drag_plane_id={self.drag_plane_id}") + self.drag_axis = None + + cam_pos = self.camera.getPos(self.world.render) + node_pos_w = self.target_node.getPos(self.world.render) + lens: PerspectiveLens = self.camera.node().getLens() + + # Determine plane normal in world space from the actual plane node, + # so that dragging follows the visual orientation even when the + # target node is rotated (local mode). + plane_np = self.planes[self.drag_plane_id] + plane_normal: Vec3 = Quat(plane_np.getQuat(self.world.render)).xform( + Vec3(0, 1, 0) + ) + plane_normal.normalize() + self.drag_plane_normal = plane_normal + self.drag_plane_origin = node_pos_w + self.start_node_pos = node_pos_w + self.start_mouse = Point2(mpos.x, mpos.y) + + # Build initial mouse ray and compute intersection with plane + p_from = Point3() + p_to = Point3() + hit_point = None + if lens.extrude(Point2(mpos.x, mpos.y), p_from, p_to): + ray_origin = self.world.render.getRelativePoint( + self.camera, p_from + ) + ray_to = self.world.render.getRelativePoint( + self.camera, p_to + ) + ray_dir = ray_to - ray_origin + if ray_dir.length_squared() != 0: + ray_dir.normalize() + denom = ray_dir.dot(plane_normal) + if abs(denom) > 1e-6: + t = (self.drag_plane_origin - + ray_origin).dot(plane_normal) / denom + hit_point = ray_origin + ray_dir * t + + if hit_point is None: + # Fallback: use current node position as hit point + hit_point = node_pos_w + + self._plane_drag_offset = node_pos_w - hit_point + self._debug_drag_counter = 0 + self._highlight_plane(self.drag_plane_id) + self._log( + f"pick plane={self.drag_plane_id} target={self.target_node.getName()} " + f"plane_origin={self.drag_plane_origin} plane_normal={self.drag_plane_normal} " + f"hit_point={hit_point}" + ) + self._emit_event( + GizmoEvent.DRAG_START, + { + "gizmo": "move", + "target": self.target_node, + "axis": None, + "plane": self.drag_plane_id, + "mouse": Point2(mpos.x, mpos.y), + "start_pos": self.start_node_pos, + }, + ) + + elif axis_entry is not None: + entry = axis_entry + axis_tag: str = entry.getIntoNode().getTag("gizmo_axis") + # --- Begin axis dragging (existing behavior) --- + self.dragging = True + self.drag_axis = int(axis_tag) + self.drag_plane_id = None + + # --- Freeze axis direction & screen-space mapping at drag start --- + 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() + cam_pos = self.camera.getPos(self.world.render) + node_pos_w = self.target_node.getPos(self.world.render) + view_dir = node_pos_w - cam_pos + if view_dir.length_squared() != 0: + view_dir.normalize() + + self.drag_axis_dir = axis_dir + + lens: PerspectiveLens = self.camera.node().getLens() + self.drag_base_dist = (cam_pos - node_pos_w).length() + 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() + if lens.project(p0_cam, p0_2d) and lens.project(p1_cam, p1_2d): + axis_screen: Point2 = p1_2d - p0_2d + if axis_screen.length_squared() > 0: + axis_screen.normalize() + else: + axis_screen = Point2(1, 0) + else: + axis_screen = Point2(1, 0) + self.drag_axis_screen = axis_screen + + self.drag_base_dist = max(self.drag_base_dist, 0.5) + self.start_mouse = Point2(mpos.x, mpos.y) + self.start_node_pos = node_pos_w + self.last_mouse_pos = Point3(mpos.x, mpos.y, 0) + + p_from = Point3() + p_to = Point3() + if lens.extrude(Point2(mpos.x, mpos.y), p_from, p_to): + ray_origin: Point3 = self.world.render.getRelativePoint( + self.camera, p_from + ) + ray_to: Point3 = self.world.render.getRelativePoint( + self.camera, p_to + ) + ray_dir: Vec3 = ray_to - ray_origin + if ray_dir.length_squared() != 0: + ray_dir.normalize() + axis_origin = self.start_node_pos + a = self.drag_axis_dir + d = ray_dir + w0 = ray_origin - axis_origin + a_dot_a = a.dot(a) + d_dot_d = d.dot(d) + a_dot_d = a.dot(d) + a_dot_w0 = a.dot(w0) + d_dot_w0 = d.dot(w0) + denom = a_dot_a * d_dot_d - a_dot_d * a_dot_d + if abs(denom) >= 1e-6: + s0 = (a_dot_d * d_dot_w0 - + d_dot_d * a_dot_w0) / denom + self._start_param_on_ray = s0 + else: + self._start_param_on_ray = 0.0 + else: + self._start_param_on_ray = 0.0 + else: + self._start_param_on_ray = 0.0 + + self._debug_drag_counter = 0 + self._highlight_axis(self.drag_axis) + self._log( + f"pick axis={self.drag_axis} target={self.target_node.getName()} " + f"start_pos={self.start_node_pos} world_root={self.root.getPos(self.world.render)} " + f"axis_dir={axis_dir} axis_screen={self.drag_axis_screen}" + ) + self._emit_event( + GizmoEvent.DRAG_START, + { + "gizmo": "move", + "target": self.target_node, + "axis": self.drag_axis, + "plane": None, + "mouse": Point2(mpos.x, mpos.y), + "start_pos": self.start_node_pos, + }, + ) + else: + self._log( + "pick hit but no gizmo_axis or gizmo_plane tag on node") + else: + self._log("mouse_down: no collision entries on gizmo") + + def _on_mouse_up(self, extra=None): + if self.dragging and self.target_node is not None: + # Commit a move action to the undo stack if position changed + try: + final_pos = self.target_node.getPos(self.world.render) + except Exception: + final_pos = None + old_pos = self.start_node_pos + if old_pos is not None and final_pos is not None: + self._record_move_action(self.target_node, old_pos, final_pos) + + axis_id = self.drag_axis + plane_id = self.drag_plane_id + self._emit_event( + GizmoEvent.DRAG_END, + { + "gizmo": "move", + "target": self.target_node, + "axis": axis_id, + "plane": plane_id, + "final_pos": final_pos, + "start_pos": old_pos, + }, + ) + self.dragging = False + self.drag_axis = None + self.drag_plane_id = None + if not self.is_local: + self.root.setHpr(self.world.render, 0, 0, 0) + self._reset_highlights() + self._log("mouse_up -> stop dragging") + + self._reset_planels() + + def _reset_planels(self): + if not self.camera: + return + + # 摄像机在 gizmo 局部坐标系中的位置(会跟随 root 旋转) + local_cam_pos: Vec3 = self.camera.getPos(self.root) + + # 三个轴向的符号:摄像机在 gizmo 局部坐标系里的哪一侧 + sx = 1.0 if local_cam_pos.x >= 0.0 else -1.0 + sy = 1.0 if local_cam_pos.y >= 0.0 else -1.0 + sz = 1.0 if local_cam_pos.z >= 0.0 else -1.0 + + o = self.plane_offset + + # XY 平面:在 X/Y 方向选离摄像机最近的那一角,Z 固定 0 + self.planes[0].setPos(sx * o, sy * o, 0.0) + + # YZ 平面:在 Y/Z 方向选离摄像机最近的那一角,X 固定 0 + self.planes[1].setPos(0.0, sy * o, sz * o) + + # ZX 平面:在 Z/X 方向选离摄像机最近的那一角,Y 固定 0 + self.planes[2].setPos(sx * o, 0.0, sz * o) + + 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 dragging") + return + + # Hover highlight when not dragging. + if not self.dragging: + self._update_hover_highlight(mpos) + return + + if not self.target_node: + return + + # Build world-space mouse ray from current mouse position + lens:Lens = self.camera.node().getLens() + p_from = Point3() + p_to = Point3() + if not lens.extrude(Point2(mpos.x, mpos.y), p_from, p_to): + self._log("mouse_move: lens.extrude failed") + return + + ray_origin = self.world.render.getRelativePoint(self.camera, p_from) + ray_to = self.world.render.getRelativePoint(self.camera, p_to) + ray_dir = ray_to - ray_origin + if ray_dir.length_squared() == 0: + return + ray_dir.normalize() + + # Plane dragging: move freely within the plane defined at drag start. + if self.drag_plane_id is not None: + n = self.drag_plane_normal + if n.length_squared() == 0: + return + denom = ray_dir.dot(n) + if abs(denom) < 1e-6: + # Ray nearly parallel to plane; skip this move to avoid jumps. + return + t = (self.drag_plane_origin - ray_origin).dot(n) / denom + hit_point = ray_origin + ray_dir * t + new_pos = hit_point + self._plane_drag_offset + self.target_node.setPos(self.world.render, new_pos) + + if self._debug_drag_counter % 4 == 0: + self._log( + f"drag plane={self.drag_plane_id} mpos={mpos} " + f"ray_origin={ray_origin} ray_dir={ray_dir} " + f"plane_origin={self.drag_plane_origin} plane_normal={n} " + f"hit_point={hit_point} new_pos={new_pos}" + ) + self._debug_drag_counter += 1 + self._emit_event( + GizmoEvent.DRAG_MOVE, + { + "gizmo": "move", + "target": self.target_node, + "axis": None, + "plane": self.drag_plane_id, + "mouse": Point2(mpos.x, mpos.y), + "new_pos": new_pos, + }, + ) + return + + # --- 3D-based drag along axis: use closest point between mouse ray and axis line --- + axis_dir = self.drag_axis_dir + if axis_dir.length_squared() == 0: + return + + axis_origin = self.start_node_pos + a = axis_dir + d = ray_dir + w0 = ray_origin - axis_origin + + a_dot_a = a.dot(a) + d_dot_d = d.dot(d) + a_dot_d = a.dot(d) + a_dot_w0 = a.dot(w0) + d_dot_w0 = d.dot(w0) + + denom = a_dot_a * d_dot_d - a_dot_d * a_dot_d + if abs(denom) < 1e-6: + mouse_ndc = Point2(mpos.x, mpos.y) + delta = mouse_ndc - self.start_mouse + s_scalar = delta.dot(self.drag_axis_screen) + move_amount = s_scalar * self.drag_base_dist * 0.5 + new_pos = axis_origin + a * move_amount + self._log( + f"drag fallback (parallel): s={s_scalar:.4f} move={move_amount:.4f}" + ) + else: + s = (a_dot_d * d_dot_w0 - d_dot_d * a_dot_w0) / denom + delta_s = self._start_param_on_ray - s + new_pos = axis_origin + a * delta_s + + self.target_node.setPos(self.world.render, new_pos) + + if self._debug_drag_counter % 4 == 0: + self._log( + f"drag axis={self.drag_axis} mpos={mpos} " + f"ray_origin={ray_origin} ray_dir={ray_dir} " + f"axis_origin={axis_origin} axis_dir={axis_dir} new_pos={new_pos}" + ) + self._debug_drag_counter += 1 + self._emit_event( + GizmoEvent.DRAG_MOVE, + { + "gizmo": "move", + "target": self.target_node, + "axis": self.drag_axis, + "plane": None, + "mouse": Point2(mpos.x, mpos.y), + "new_pos": new_pos, + }, + ) + + def _update_hover_highlight(self, mpos: Point3): + """Highlight the handle under cursor during hover (no drag).""" + self.picker_np.reparentTo(self.camera) + if not self._picker_added: + self.c_trav.addCollider(self.picker_np, self.c_queue) + self._picker_added = True + self.picker_ray.setFromLens(self.camera.node(), mpos.x, mpos.y) + + self.c_queue.clearEntries() + self.c_trav.traverse(self.root) + + num_entries = self.c_queue.getNumEntries() + if num_entries <= 0: + self._reset_highlights() + self.is_hovering = False + return + + self.c_queue.sortEntries() + + plane_entry: Optional[CollisionEntry] = None + axis_entry: Optional[CollisionEntry] = None + for i in range(num_entries): + e = self.c_queue.getEntry(i) + if plane_entry is None and e.getIntoNode().getTag("gizmo_plane"): + plane_entry = e + if axis_entry is None and e.getIntoNode().getTag("gizmo_axis"): + axis_entry = e + if plane_entry and axis_entry: + break + + if plane_entry is not None: + plane_tag = plane_entry.getIntoNode().getTag("gizmo_plane") + self._highlight_plane(int(plane_tag)) + self.is_hovering = True + elif axis_entry is not None: + axis_tag = 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): + """高亮被选中的轴,其他轴和平面变暗。""" + 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) + # 所有平面也变暗 + for plane in self.planes: + plane.setAlphaScale(0.3) + + def _reset_highlights(self): + """重置所有高亮效果。""" + for ax in self.axes: + ax.clearColor() + ax.clearColorScale() + ax.setAlphaScale(self.arrow_transparency) + for plane in self.planes: + plane.clearColorScale() + plane.setAlphaScale(self.panel_transparency) + + def _highlight_plane(self, plane_id: int): + """高亮被选中的平面,其他平面和轴变暗。""" + for i, plane in enumerate(self.planes): + if i == plane_id: + plane.setColorScale(1.6, 1.6, 1.6, 2.0) + else: + plane.setAlphaScale(0.3) + # 所有轴也变暗 + for ax in self.axes: + ax.clearColor() + ax.setAlphaScale(0.3) + + def update(self): + """Update gizmo scale based on camera distance.""" + 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: + 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 # Constant screen size factor + self.root.setScale(self.world.render, scale) + + def _update_task(self, task: Task): + """Panda3D task that keeps the gizmo updated every frame.""" + self.update() + return Task.cont + + # ------------------------------------------------------------------ # + # Internal transform sync + # ------------------------------------------------------------------ # + def _update_root_transform(self): + """Keep gizmo root aligned to target without inheriting its render state.""" + 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()) diff --git a/src/TransformGizmo/rotate_gizmo.py b/src/TransformGizmo/rotate_gizmo.py new file mode 100644 index 0000000..a5049b7 --- /dev/null +++ b/src/TransformGizmo/rotate_gizmo.py @@ -0,0 +1,1692 @@ +import math +from typing import Optional, Tuple, Callable, Dict, Any, List + +from panda3d.core import ( + NodePath, + Vec3, + Point3, + Point2, + Vec4, + Quat, + GeomNode, + GeomVertexFormat, + GeomVertexData, + GeomVertexWriter, + GeomLines, + GeomTriangles, + Geom, + CollisionNode, + CollisionRay, + CollisionHandlerQueue, + CollisionTraverser, + CollisionTube, + CollisionSphere, + CollisionEntry, + BitMask32, + PerspectiveLens, + TransparencyAttrib, + Material, + LPoint2f +) +from direct.showbase.DirectObject import DirectObject +from direct.task import Task +from direct.task.TaskManagerGlobal import taskMgr +from direct.showbase.ShowBase import ShowBase + +from .events import GizmoEvent + + +""" +旋转控制柄(Rotate Gizmo) +========================= + +功能概述: + - 模仿 Unity 的旋转控制柄,使用 3 个彩色圆环表示绕 X/Y/Z 轴旋转 + - X 轴:红色圆环(绕局部 X 轴旋转) + - Y 轴:绿色圆环(绕局部 Y 轴旋转) + - Z 轴:蓝色圆环(绕局部 Z 轴旋转) + - 额外提供屏幕轨迹球(trackball)用于自由旋转 + - 拖拽时展示半透明角度扇形与箭头,辅助观察旋转量 + - 中心轴线辅助观察当前坐标系朝向 + - 使用 Panda3D 的碰撞系统进行鼠标拾取与拖拽 + - 控件跟随目标节点的局部坐标系(即“局部模式”), + 当目标有旋转时,三个圆环会随之旋转 + - 控件会根据摄像机距离自动缩放,屏幕大小保持近似不变 + +集成方式(示例): + from QPanda3D.Panda3DWorld import Panda3DWorld + from QPanda3DExamples.rotate_gizmo import RotateGizmo + + world = Panda3DWorld() + model_np = world.render.attachNewNode("Box") + # ... 在 model_np 下加载模型 + + gizmo = RotateGizmo(world) # 创建旋转 Gizmo + gizmo.attach(model_np) # 绑定到某个模型 + + # 当需要切换目标时: + gizmo.attach(another_np) + + # 当不需要时,可隐藏: + gizmo.detach() + +鼠标事件要求: + - 本类默认监听 Panda3D 的 "mouse1" / "mouse1-up" / "mouse-move" 事件 + - 如果从 Qt / 自定义 UI 传递鼠标像素坐标,可以在发送事件时传入 extra 字典: + messenger.send("mouse1", [{"x": mouse_x, "y": mouse_y}]) + 本类会自动将像素坐标转换到 [-1, 1] 的标准化设备坐标 +""" + + +class RotateGizmo(DirectObject): + """ + Unity 风格的旋转控制柄: + - 3 个圆环分别代表绕局部 X/Y/Z 轴旋转 + - 额外的屏幕轨迹球,便于自由旋转 + - 左键点击圆环并拖拽,可以绕对应轴旋转绑定的 NodePath + """ + + def __init__( + self, + world, + camera_np: NodePath | None = None, + on_action_committed: Optional[Callable[[Dict[str, Any]], None]] = None, + event_hooks: Optional[Dict[str, List[Callable[[Dict[str, Any]], None]]]] = None + ): + super().__init__() + + self.world:ShowBase = world + self.is_local: bool = True + self.debug: bool = False + self.is_hovering = False + self._picker_added: bool = False + # Optional callback to report completed rotate actions to a higher-level + # manager (e.g. TransformGizmo) so it can build a global undo stack. + self.on_action_committed = on_action_committed + # Event hooks: drag_start / drag_move / drag_end + self._event_hooks = self._normalize_event_hooks(event_hooks) + + # 摄像机节点,如果未手动传入,则尝试从 world/base 中自动获取 + self.camera: Optional[NodePath] = camera_np or getattr(world, "cam", None) + if not self.camera and hasattr(world, "base"): + self.camera = world.base.cam + if not self.camera: + self._log("No camera found for RotateGizmo") + else: + self._log(f"Camera found for RotateGizmo: {self.camera}") + + # 控制柄根节点 + self.root: NodePath = NodePath("RotateGizmo") + self.color_higher = 0.05 + self.color_normal = 0.01 + + # 当前绑定的目标节点 + self.target_node: Optional[NodePath] = None + self.attached: bool = False + + self.last_mouse_pos = None + + # 控制柄视觉参数 + self.ring_radius: float = 0.45 # 圆环半径(局部空间) + self.ring_thickness: float = 0.04 # 圆环粗细(碰撞体半径) + self.ring_alpha: float = 0.9 # 圆环透明度 + self.trackball_radius: float = self.ring_radius * 1.0 # 轨迹球半径(略小,避免遮挡轴拾取) + self.trackball_color: Vec4 = Vec4(0.25, 0.25, 0.25, 0.45) + self.trackball_edge_color: Vec4 = Vec4(1, 1, 1, 0.75) + self.highlight_color: Vec4 = Vec4(1.0, 1.0, 1.0, 1.0) + self.arrow_length_factor: float = 0.35 # 箭头长度相对圆环半径的倍数 + self.arrow_head_width_factor: float = 0.25 # 箭头头部宽度相对箭长的倍数 + + # 拾取射线与碰撞系统 + self.picker_ray = CollisionRay() + self.picker_node = CollisionNode("rotate_gizmo_picker_ray") + self.picker_node.addSolid(self.picker_ray) + self.picker_node.setFromCollideMask(BitMask32.bit(20)) + self.picker_node.setIntoCollideMask(BitMask32.allOff()) + self.picker_np: NodePath = NodePath(self.picker_node) + + self.c_trav = CollisionTraverser() + self.c_queue = CollisionHandlerQueue() + + # 拖拽状态 + self.dragging: bool = False + self.drag_axis: Optional[int] = None # 0 = X, 1 = Y, 2 = Z + self.drag_mode: Optional[str] = None # "axis" / "trackball" + self.drag_local_axis: Optional[Vec3] = None + self.start_mouse: Optional[Point2] = None + self._axis_screen_dir: Point2 | None = None + # 起始姿态:世界与父坐标系两份,方便选用一致的参考系进行旋转 + self.start_quat: Optional[Quat] = None # 世界四元数 + self.start_quat_local: Optional[Quat] = None # 父坐标系四元数 + self.start_root_axis_dir: Optional[Vec3] = None + self.rotate_axis_world: Vec3 = Vec3(0, 0, 0) # 当前轴的世界方向 + self.start_vec_on_plane: Vec3 = Vec3(0, 0, 0) # 旋转平面上的起始向量 + self.center_world: Point3 = Point3(0, 0, 0) # Gizmo 中心(世界坐标) + self._trackball_start_vec: Optional[Vec3] = None # 屏幕空间轨迹球起始向量 + self._angle_start_offset: float = 0.0 # 扇形起点(弧度) + self._drag_root_quat_world: Optional[Quat] = None # 拖拽时保持的 Gizmo 世界朝向 + self._arrow_tangent_angle_deg: float = 0.0 # 箭头朝向(在扇形平面内的角度) + self._start_vec_local: Optional[Vec3] = None # 起始向量(扇形局部坐标) + + # 撤销栈:每项为 (node, old_hpr(world), new_hpr(world)) + self._rotate_undo_stack: list[Tuple[NodePath, Vec3, Vec3]] = [] + self._undo_angle_epsilon: float = 0.01 # 角度变化阈值(防止浮点抖动) + + # 边缘视角(grazing angle)时使用屏幕空间旋转的参数 + self._grazing_threshold: float = 0.15 # denom 小于此值时启用屏幕空间计算 + self._center_screen: Optional[Point2] = None # Gizmo 中心的屏幕投影 + self._start_screen_angle: float = 0.0 # 起始鼠标相对于屏幕中心的角度 + self._axis_screen_sign: float = 1.0 # 屏幕空间旋转方向符号 + self._last_screen_angle: Optional[float] = None + self._last_screen_mouse: Optional[Point2] = None + self._drag_accumulated_deg: float = 0.0 + self._last_drag_vec_on_plane: Optional[Vec3] = None + + # 三个圆环节点:0=X, 1=Y, 2=Z + self.rings: list[NodePath] = [] + self.ring_colors: list[Tuple[NodePath, Vec4]] = [] + # (front_geom_np, back_geom_np, front_col_np, back_col_np) + self.ring_halves: list[Tuple[NodePath,NodePath, NodePath, NodePath]] = [] + self.center_axes: list[NodePath] = [] + self.center_axes_colors: list[Tuple[NodePath, Vec4]] = [] + self.trackball_np: Optional[NodePath] = None + self.angle_disc_root: Optional[NodePath] = None + self.angle_fill_np: Optional[NodePath] = None + self.angle_fill_np_copy: Optional[NodePath] = None + + self.angle_arrow_np: Optional[NodePath] = None + + # 构建几何与碰撞体 + self._build_gizmo() + + # 初始挂在 world.render,后续通过更新同步到目标 + self.root.reparentTo(self.world.render) + self.root.setBin('fixed',40) + self.root.setDepthTest(False) + self.root.setDepthWrite(False) + self.root.setLightOff() + self.root.hide() + + # 每帧更新 Gizmo 缩放 + # taskMgr.add(self._update_task, "RotateGizmoUpdateTask") + + # ------------------------------------------------------------------ # + # 内部工具 + # ------------------------------------------------------------------ # + def _log(self, msg: str): + if self.debug: + print(f"[RotateGizmo] {msg}") + + def _normalize_event_hooks(self, hooks): + base = {name: [] for name in GizmoEvent.ALL} + if not hooks: + return base + for name in list(base.keys()): + cbs = hooks.get(name) + if cbs: + base[name] = list(cbs) + return base + + def _emit_event(self, name: str, payload: Dict[str, Any]): + handlers = self._event_hooks.get(name, []) + for cb in handlers: + try: + cb(payload) + except Exception as exc: + self._log(f"event hook '{name}' error: {exc}") + + def _build_gizmo(self): + """构建旋转 Gizmo 的可视化元素(轴环、轨迹球、角度扇形等)。""" + # 屏幕对齐的轨迹球,用于自由旋转 + # 轨迹球:默认隐藏可视几何,仅在选中时显示 + self.trackball_np = self._create_trackball_disc( + radius=self.trackball_radius, + color=self.trackball_color, + visual=True, + ) + if self.trackball_np: + trackball_geom = self.trackball_np.find("**/trackball_geom") + if not trackball_geom.isEmpty(): + trackball_geom.hide() + # X 轴(红)—— 绕局部 X 轴旋转 + self.rings.append( + self._create_ring( + axis_dir=Vec3(1, 0, 0), + color=Vec4(1, 0, 0, 1), + radius=self.ring_radius, + thickness=self.ring_thickness, + axis_id=0, + ) + ) + # Y 轴(绿)—— 绕局部 Y 轴旋转 + self.rings.append( + self._create_ring( + axis_dir=Vec3(0, 1, 0), + color=Vec4(0, 1, 0, 1), + radius=self.ring_radius, + thickness=self.ring_thickness, + axis_id=1, + ) + ) + # Z 轴(蓝)—— 绕局部 Z 轴旋转 + self.rings.append( + self._create_ring( + axis_dir=Vec3(0, 0, 1), + color=Vec4(0, 0, 1, 1), + radius=self.ring_radius, + thickness=self.ring_thickness, + axis_id=2, + ) + ) + # 轨迹球边缘辅助圆,标示球的尺寸(无碰撞) + self.trackball_edge = self._create_trackball_edge( + radius=self.trackball_radius, + color=self.trackball_edge_color, + ) + # 中心轴线,帮助观察旋转轴朝向 + self.center_axes = [ + self._create_center_axis(Vec3(1, 0, 0), Vec4(0.3, 0.0, 0.0, 1.0)), + self._create_center_axis(Vec3(0, 1, 0), Vec4(0.0, 0.3, 0.0, 1.0)), + self._create_center_axis(Vec3(0, 0, 1), Vec4(0.0, 0.0, 0.3, 1.0)), + ] + # 角度可视化扇形 + self._build_angle_disc() + + def _create_ring( + self, + axis_dir: Vec3, + color: Vec4, + radius: float, + thickness: float, + axis_id: int, + ) -> NodePath: + """ + 构建一个圆环: + - 几何:使用 GeomLines 画出若干线段组成的圆 + - 碰撞体:使用多段 CollisionTube 近似圆环 + 默认圆环在局部 XY 平面,法线为 +Z,通过四元数旋转到指定 axis_dir。 + """ + ring_root = self.root.attachNewNode(f"ring_{axis_id}") + + # 本地 +Z 对齐到目标“旋转轴方向”(即圆环法线) + align_quat = self._quat_from_z(axis_dir) + ring_root.setQuat(align_quat) + + # -------------------- 几何圆环 -------------------- + vdata = GeomVertexData("ring", GeomVertexFormat.getV3c4(), Geom.UHStatic) + vertex = GeomVertexWriter(vdata, "vertex") + vcolor = GeomVertexWriter(vdata, "color") + + segments = 64 + for i in range(segments + 1): + angle = 2 * math.pi * i / segments + x = radius * math.cos(angle) + y = radius * math.sin(angle) + vertex.addData3(x, y, 0.0) + vcolor.addData4(color) + + # 将圆拆分为两个半圆环,方便根据摄像机方向隐藏背面的半圆。 + half_segments = segments // 2 + + # 前半圆:索引 [0, half_segments] + prim_front = GeomLines(Geom.UHStatic) + for i in range(half_segments): + prim_front.addVertices(i, i + 1) + geom_front = Geom(vdata) + geom_front.addPrimitive(prim_front) + node_front = GeomNode("ring_geom_front") + node_front.addGeom(geom_front) + np_front = ring_root.attachNewNode(node_front) + np_front.setTransparency(True) + np_front.setColorScale(1, 1, 1, self.ring_alpha) + + # 后半圆:索引 [half_segments, segments] + prim_back = GeomLines(Geom.UHStatic) + for i in range(half_segments, segments): + prim_back.addVertices(i, i + 1) + geom_back = Geom(vdata) + geom_back.addPrimitive(prim_back) + node_back = GeomNode("ring_geom_back") + node_back.addGeom(geom_back) + np_back = ring_root.attachNewNode(node_back) + np_back.setTransparency(True) + np_back.setColorScale(1, 1, 1, self.ring_alpha) + # -------------------- 碰撞圆环拆分为两半 -------------------- + col_segments = 24 + half_col = col_segments // 2 + + c_node_front = CollisionNode(f"ring_col_front_{axis_id}") + c_node_front.setIntoCollideMask(BitMask32.bit(20)) + c_node_front.setFromCollideMask(BitMask32.allOff()) + c_node_front.setTag("gizmo_axis", str(axis_id)) + for i in range(half_col): + a0 = 2 * math.pi * i / col_segments + a1 = 2 * math.pi * (i + 1) / col_segments + p0 = Point3(radius * math.cos(a0), radius * math.sin(a0), 0.0) + p1 = Point3(radius * math.cos(a1), radius * math.sin(a1), 0.0) + c_node_front.addSolid(CollisionTube(p0, p1, thickness)) + col_front_np = ring_root.attachNewNode(c_node_front) + + c_node_back = CollisionNode(f"ring_col_back_{axis_id}") + c_node_back.setIntoCollideMask(BitMask32.bit(20)) + c_node_back.setFromCollideMask(BitMask32.allOff()) + c_node_back.setTag("gizmo_axis", str(axis_id)) + for i in range(half_col, col_segments): + a0 = 2 * math.pi * i / col_segments + a1 = 2 * math.pi * (i + 1) / col_segments + p0 = Point3(radius * math.cos(a0), radius * math.sin(a0), 0.0) + p1 = Point3(radius * math.cos(a1), radius * math.sin(a1), 0.0) + c_node_back.addSolid(CollisionTube(p0, p1, thickness)) + col_back_np = ring_root.attachNewNode(c_node_back) + + # 默认先都显示,后续在 update() 中根据摄像机位置只显示靠近摄像机的一半(含碰撞) + self.ring_halves.append((np_front, np_back, col_front_np, col_back_np)) + + return ring_root + + def _create_center_axis(self, axis_dir: Vec3, color: Vec4) -> NodePath: + """创建中心轴线,方便观察轴向。""" + vdata = GeomVertexData( + "center_axis", GeomVertexFormat.getV3c4(), Geom.UHStatic) + vertex = GeomVertexWriter(vdata, "vertex") + vcolor = GeomVertexWriter(vdata, "color") + + vertex.addData3(0.0, 0.0, 0.0) + vcolor.addData4(color) + tip = axis_dir * (self.ring_radius * 0.8) + vertex.addData3(tip) + vcolor.addData4(color) + + lines = GeomLines(Geom.UHStatic) + lines.addVertices(0, 1) + geom = Geom(vdata) + geom.addPrimitive(lines) + node = GeomNode("center_axis_geom") + node.addGeom(geom) + np = self.root.attachNewNode(node) + np.setTransparency(True) + np.setColorScale(1, 1, 1, 0.9) + return np + + def _create_trackball_edge(self, radius: float, color: Vec4) -> NodePath: + """创建轨迹球尺寸辅助圆(无碰撞,仅显示)。""" + edge_root = self.root.attachNewNode("trackball_edge") + vdata = GeomVertexData("trackball_edge", GeomVertexFormat.getV3c4(), Geom.UHStatic) + vertex = GeomVertexWriter(vdata, "vertex") + vcolor = GeomVertexWriter(vdata, "color") + + segments = 96 + for i in range(segments + 1): + ang = 2 * math.pi * i / segments + x = radius * math.cos(ang) + z = radius * math.sin(ang) + vertex.addData3(x, 0.0, z) + vcolor.addData4(color) + + prim = GeomLines(Geom.UHStatic) + for i in range(segments): + prim.addVertices(i, i + 1) + geom = Geom(vdata) + geom.addPrimitive(prim) + node = GeomNode("trackball_edge_geom") + node.addGeom(geom) + np_geom = edge_root.attachNewNode(node) + np_geom.setRenderModeThickness(1.5) + np_geom.setBillboardPointEye() + np_geom.setTransparency(True) + np_geom.setColorScale(color) + return edge_root + + def _create_trackball_disc(self, radius: float, color: Vec4, visual: bool = True) -> NodePath: + """创建屏幕对齐的轨迹球圆盘。visual=False 时仅保留拾取体。""" + disc_root = self.root.attachNewNode("trackball_disc") + + if visual: + np_geom = self.make_sphere(radius=radius,color=color) + np_geom.set_name("trackball_geom") + np_geom.wrt_reparent_to(disc_root) + np_geom.setTransparency(True) + # 拾取使用球体,方便捕获整个圆盘区域 + c_node = CollisionNode("trackball_col") + c_node.setIntoCollideMask(BitMask32.bit(20)) + c_node.setFromCollideMask(BitMask32.allOff()) + c_node.setTag("gizmo_handle", "trackball") + c_node.addSolid(CollisionSphere(0, 0, 0, radius + self.ring_thickness * 0.25)) + disc_root.attachNewNode(c_node) + + return disc_root + + def make_sphere(self,radius=0.5, lat_steps=32, lon_steps=64, color=(1,1,1,0.5)) -> NodePath: + fmt = GeomVertexFormat.getV3c4() + vdata = GeomVertexData("sphere", fmt, Geom.UHStatic) + vw = GeomVertexWriter(vdata, "vertex") + cw = GeomVertexWriter(vdata, "color") + tris = GeomTriangles(Geom.UHStatic) + + # 顶点 + for i in range(lat_steps + 1): # 纬度 0..pi + theta = math.pi * i / lat_steps + st, ct = math.sin(theta), math.cos(theta) + for j in range(lon_steps): # 经度 0..2pi + phi = 2 * math.pi * j / lon_steps + sp, cp = math.sin(phi), math.cos(phi) + x, y, z = radius * cp * st, radius * sp * st, radius * ct + vw.addData3(x, y, z) + cw.addData4(*color) + + # 索引 + def vid(i, j): # i:0..lat_steps, j:0..lon_steps-1 + return i * lon_steps + (j % lon_steps) + + for i in range(lat_steps): + for j in range(lon_steps): + a = vid(i, j) + b = vid(i+1, j) + c = vid(i+1, j+1) + d = vid(i, j+1) + tris.addVertices(a, b, c) + tris.addVertices(a, c, d) + + geom = Geom(vdata); geom.addPrimitive(tris) + node = GeomNode("hi_sphere"); node.addGeom(geom) + np = NodePath(node) + return np + + def _build_angle_disc(self): + """搭建旋转角度的可视化扇形。""" + self.angle_disc_root = self.root.attachNewNode("angle_disc_root") + self.angle_disc_root.setTransparency(TransparencyAttrib.M_alpha) + self.angle_disc_root.setTwoSided(True) + self.angle_disc_root.hide() + + self.angle_fill_np = self.angle_disc_root.attachNewNode(GeomNode("angle_sector")) + self.angle_fill_np.setTransparency(TransparencyAttrib.M_alpha) + self.angle_fill_np.setColor(0.9, 0.9, 0.9, 0.5) + # 末端箭头 + self.angle_arrow_np = self._create_angle_arrow( + self.angle_disc_root, + self.ring_radius * self.arrow_length_factor, + self.arrow_head_width_factor, + ) + self.angle_arrow_np.setColor(1.0, 1.0, 1.0, 1.0) + self.angle_arrow_np.hide() + + def _create_angle_disc_half(self, parent: NodePath, mirror: bool = False) -> NodePath: + """创建角度盘的一半,方便做基础背景。""" + segments = 50 + angle = math.pi / segments + angle = -angle if mirror else angle + offset = math.pi * 0.5 + + vdata = GeomVertexData("angle_disc_half", GeomVertexFormat.getV3(), Geom.UHStatic) + vertex = GeomVertexWriter(vdata, "vertex") + + for i in range(segments + 1): + x = math.cos(angle * i - offset) + y = math.sin(angle * i - offset) + vertex.addData3(x, y, 0.0) + + prim = GeomTriangles(Geom.UHStatic) + for i in range(1, segments): + prim.addVertices(0, i, i + 1) + geom = Geom(vdata) + geom.addPrimitive(prim) + node = GeomNode("angle_disc_half_node") + node.addGeom(geom) + half_np = parent.attachNewNode(node) + half_np.setTransparency(TransparencyAttrib.M_alpha) + half_np.setColor(1, 1, 1, 0.1) + return half_np + + def _create_angle_arrow(self, parent: NodePath, length: float, head_width_factor: float) -> NodePath: + """创建显示末端角度的箭头。""" + vdata = GeomVertexData( + "angle_arrow", GeomVertexFormat.getV3(), Geom.UHStatic) + vertex = GeomVertexWriter(vdata, "vertex") + + # 箭头沿 +X 方向,从原点出发,便于通过平面内旋转对齐切线 + head_w = max(length * head_width_factor, length * 0.02) + vertex.addData3(0.0, 0.0, 0.0) + vertex.addData3(length, 0.0, 0.0) + vertex.addData3(length * 0.7, head_w, 0.0) + vertex.addData3(length * 0.7, -head_w, 0.0) + + prim = GeomLines(Geom.UHStatic) + prim.addVertices(0, 1) + prim.addVertices(1, 2) + prim.addVertices(1, 3) + geom = Geom(vdata) + geom.addPrimitive(prim) + node = GeomNode("angle_arrow_geom") + node.addGeom(geom) + np = parent.attachNewNode(node) + np.setTransparency(TransparencyAttrib.M_alpha) + np.setTwoSided(True) + return np + + def _build_sector_geom(self, start_rad: float, end_rad: float) -> Geom: + """根据起止弧度创建扇形几何。""" + radius = self.ring_radius + span = end_rad - start_rad + direction = 1.0 if span >= 0.0 else -1.0 + span = abs(span) + segments = max(8, int(48 * (span / (2 * math.pi)))) + + vdata = GeomVertexData("angle_sector_data", GeomVertexFormat.getV3(), Geom.UHStatic) + vertex = GeomVertexWriter(vdata, "vertex") + + # 中心点 + vertex.addData3(0.0, 0.0, 0.0) + for i in range(segments + 1): + a = start_rad + direction * (span * i / segments) + x = radius * math.cos(a) + y = radius * math.sin(a) + vertex.addData3(x, y, 0.0) + + prim = GeomTriangles(Geom.UHStatic) + for i in range(1, segments + 1): + prim.addVertices(0, i, i + 1) + geom = Geom(vdata) + geom.addPrimitive(prim) + + return geom + + def _quat_from_z(self, direction: Vec3) -> Quat: + """ + 与 MoveGizmo 保持一致: + 返回一个四元数,使本地 +Z 旋转到指定 direction(自动归一化)。 + """ + dir_norm = Vec3(direction) + if dir_norm.length_squared() == 0: + return Quat.identQuat() + dir_norm.normalize() + z_axis = Vec3(0, 0, 1) + dot = z_axis.dot(dir_norm) + if abs(dot - 1.0) < 1e-6: + return Quat.identQuat() # 已经对齐 + if abs(dot + 1.0) < 1e-6: + # 反向,绕任意垂直于 Z 的轴旋转 180° + ortho = Vec3(1, 0, 0) if abs(z_axis.dot( + Vec3(1, 0, 0))) < 0.9 else Vec3(0, 1, 0) + axis = z_axis.cross(ortho) + axis.normalize() + q = Quat() + q.setFromAxisAngle(180.0, axis) + return q + axis = z_axis.cross(dir_norm) + axis_len = axis.length() + if axis_len == 0: + return Quat.identQuat() + axis.normalize() + angle_rad = math.atan2(axis_len, dot) + q = Quat() + q.setFromAxisAngle(math.degrees(angle_rad), axis) + return q + + def _begin_angle_visual(self, axis_world: Vec3): + """开始显示角度扇形,基于当前旋转轴和起始向量。""" + if self.angle_disc_root is None: + return + if axis_world.length_squared() == 0: + return + + align_q = self._quat_from_z(axis_world) + self.angle_disc_root.setQuat(self.world.render, align_q) + self.angle_disc_root.setPos(self.root, 0, 0, 0) + + # 计算起始向量在扇形平面上的角度,作为扇形起点 + if self.start_vec_on_plane.length_squared() > 0: + q_inv = Quat(self.angle_disc_root.getQuat(self.world.render)) + q_inv.invertInPlace() + local_vec = q_inv.xform(self.start_vec_on_plane) + if local_vec.length_squared() == 0: + local_vec = Vec3(1, 0, 0) + local_vec.normalize() + self._start_vec_local = local_vec + self._angle_start_offset = math.atan2(local_vec.y, local_vec.x) + + # 箭头朝向:起始向量的切线方向(轴 x start_vec),并记录为局部平面角度 + axis_world_norm = Vec3(axis_world) + axis_world_norm.normalize() + tangent_world = axis_world_norm.cross(self.start_vec_on_plane) + if tangent_world.length_squared() == 0: + tangent_world = Vec3(-self.start_vec_on_plane.y, + self.start_vec_on_plane.x, 0) + tangent_world.normalize() + tangent_local = q_inv.xform(tangent_world) + self._arrow_tangent_angle_deg = math.degrees( + math.atan2(tangent_local.y, tangent_local.x)) + + # 将箭头放在起始点位置,面向相机 + if self.angle_arrow_np: + self.angle_arrow_np.setPos( + self.angle_disc_root, + Vec3(local_vec.x * self.ring_radius, + local_vec.y * self.ring_radius, + 0.0), + ) + # 初始朝向即切线方向 + self.angle_arrow_np.setH(self._arrow_tangent_angle_deg) + else: + self._start_vec_local = Vec3(1, 0, 0) + self._angle_start_offset = 0.0 + self._arrow_tangent_angle_deg = 90.0 + + # 初始化扇形(零度) + self._update_angle_sector( + self._angle_start_offset, self._angle_start_offset) + self.angle_disc_root.show() + if self.angle_arrow_np: + self.angle_arrow_np.hide() + + def _update_angle_visual(self, delta_deg: float): + """根据当前旋转角度更新扇形与箭头。""" + if self.angle_disc_root is None or self.angle_fill_np is None: + return + start_rad = self._angle_start_offset + end_rad = start_rad + math.radians(delta_deg) + self._update_angle_sector(start_rad, end_rad) + if self.angle_arrow_np: + arrow_angle = self._arrow_tangent_angle_deg + if delta_deg < 0.0: + arrow_angle += 180.0 + self.angle_arrow_np.setH(arrow_angle) + if self.angle_arrow_np.isHidden(): + self.angle_arrow_np.show() + + def _update_angle_sector(self, start_rad: float, end_rad: float): + """生成并替换当前的角度扇形几何。""" + if self.angle_fill_np is None: + return + geom = self._build_sector_geom(start_rad, end_rad) + node = self.angle_fill_np.node() + node.removeAllGeoms() + node.addGeom(geom) + + self.angle_fill_np.setTwoSided(True) + + def _hide_angle_disc(self): + """结束旋转时隐藏角度扇形。""" + if self.angle_disc_root: + self.angle_disc_root.hide() + if self.angle_fill_np: + self.angle_fill_np.node().removeAllGeoms() + if self.angle_arrow_np: + self.angle_arrow_np.hide() + self._angle_start_offset = 0.0 + + def _map_to_trackball(self, mpos: Point3) -> Vec3: + """将屏幕坐标映射到轨迹球上的向量。""" + x = mpos.x + y = mpos.y + length_sq = x * x + y * y + if length_sq <= 1.0: + z = math.sqrt(max(0.0, 1.0 - length_sq)) + v = Vec3(x, y, z) + else: + v = Vec3(x, y, 0.0) + v.normalize() + return v + + def _apply_trackball_rotation(self, mpos: Point3): + """轨迹球旋转计算:基于屏幕向量的弧球算法。""" + if self._trackball_start_vec is None or self.start_quat is None: + return + curr_vec = self._map_to_trackball(mpos) + axis_cam = self._trackball_start_vec.cross(curr_vec) + axis_len_sq = axis_cam.length_squared() + if axis_len_sq < 1e-8: + return + axis_cam.normalize() + dot = max(min(self._trackball_start_vec.dot(curr_vec), 1.0), -1.0) + angle_rad = math.acos(dot) + axis_world = self.camera.getQuat(self.world.render).xform(axis_cam) + q_delta = Quat() + q_delta.setFromAxisAngle(math.degrees(angle_rad), axis_world) + new_quat_world = q_delta * self.start_quat + self.target_node.setQuat(self.world.render, new_quat_world) + self._emit_event( + GizmoEvent.DRAG_MOVE, + { + "gizmo": "rotate", + "mode": "trackball", + "axis": None, + "target": self.target_node, + "mouse": Point2(mpos.x, mpos.y), + "delta_deg": math.degrees(angle_rad), + }, + ) + + # ------------------------------------------------------------------ # + # 公共接口:绑定 / 解绑 + # ------------------------------------------------------------------ # + def attach(self, node_path: NodePath): + """ + 绑定控制柄到指定 NodePath。 + 当前实现采用 Unity 类似的“世界模式(Global)”: + - Gizmo 的三个旋转轴始终对齐世界坐标系的 X/Y/Z 方向。 + """ + if not node_path: + return + + self.target_node = node_path + self.attached = True + self._update_root_transform() + self.root.show() + self._log( + f"attached to {node_path.getName()} " + f"world_pos={self.root.getPos(self.world.render)} " + f"target_hpr={node_path.getHpr(self.world.render)}" + ) + # 根据当前相机位置初始化半圆环的正反显示 + self._update_ring_halves_visibility() + self._register_events() + + def detach(self): + """解绑并隐藏 Gizmo。""" + self.target_node = None + self.root.hide() + self.attached = False + self.dragging = False + self.drag_axis = None + self.drag_mode = None + self._trackball_start_vec = None + self._drag_root_quat_world = None + self._last_drag_vec_on_plane = None + self._last_screen_mouse = None + self._last_screen_angle = None + self._drag_accumulated_deg = 0.0 + self.is_hovering = False + self._hide_angle_disc() + self._reset_highlights() + self._ignore_events() + self._log("detached") + + def _update_ring_halves_visibility(self): + """根据摄像机与 Gizmo 相对位置,切换每个轴的前/后半圆显示。""" + if not self.camera: + return + + cam_pos = self.camera.getPos(self.world.render) + center = self.root.getPos(self.world.render) + view_vec = cam_pos - center + + for axis_id, ring_root in enumerate(self.rings): + if axis_id >= len(self.ring_halves): + continue + front_np, back_np, col_front_np, col_back_np = self.ring_halves[axis_id] + if front_np.isEmpty() or back_np.isEmpty(): + continue + + # 圆环所在平面的法线(世界坐标) + plane_normal = ring_root.getQuat(self.world.render).xform(Vec3(0, 0, 1)) + if plane_normal.length_squared() == 0: + continue + plane_normal.normalize() + + # 将摄像机方向投影到圆环平面内,确定“近半圆”切分线的朝向 + d = view_vec - plane_normal * view_vec.dot(plane_normal) + if d.length_squared() == 0: + # 摄像机几乎正对法线,保持显示近侧半圆 + front_np.show() + back_np.hide() + if not col_front_np.isEmpty(): + col_front_np.node().setIntoCollideMask(BitMask32.bit(20)) + if not col_back_np.isEmpty(): + col_back_np.node().setIntoCollideMask(BitMask32.allOff()) + front_np.setHpr(ring_root, 0, 0, 0) + back_np.setHpr(ring_root, 0, 0, 0) + continue + + q_world = ring_root.getQuat(self.world.render) + q_inv = Quat(q_world) + q_inv.invertInPlace() + d_local = q_inv.xform(d) + if d_local.length_squared() == 0: + front_np.show() + back_np.hide() + if not col_front_np.isEmpty(): + col_front_np.node().setIntoCollideMask(BitMask32.bit(20)) + if not col_back_np.isEmpty(): + col_back_np.node().setIntoCollideMask(BitMask32.allOff()) + if not col_front_np.isEmpty(): + col_front_np.setHpr(ring_root, 0, 0, 0) + if not col_back_np.isEmpty(): + col_back_np.setHpr(ring_root, 0, 0, 0) + continue + d_local.normalize() + + # 将半圆几何整体绕轴旋转,让切分线对齐相机投影方向 + angle = math.atan2(d_local.y, d_local.x) + # 将近侧半圆的中心对准相机投影方向(边界垂直于视线投影) + angle_deg = math.degrees(angle) - 90.0 + front_np.setHpr(ring_root, angle_deg, 0, 0) + back_np.setHpr(ring_root, angle_deg, 0, 0) + if not col_front_np.isEmpty(): + col_front_np.setHpr(ring_root, angle_deg, 0, 0) + if not col_back_np.isEmpty(): + col_back_np.setHpr(ring_root, angle_deg, 0, 0) + + # 近侧半圆显示,远侧隐藏 + front_np.show() + back_np.hide() + if not col_front_np.isEmpty(): + col_front_np.node().setIntoCollideMask(BitMask32.bit(20)) + if not col_back_np.isEmpty(): + col_back_np.node().setIntoCollideMask(BitMask32.allOff()) + + # ------------------------------------------------------------------ # + # 撤销支持(可选使用) + # ------------------------------------------------------------------ # + + def _record_rotate_action(self, node: NodePath, old_hpr: Vec3, new_hpr: Vec3): + """将一次旋转操作压入撤销栈,并在需要时通知外部监听者。""" + if node is None or node.isEmpty(): + return + if old_hpr is None or new_hpr is None: + return + + delta = new_hpr - old_hpr + if ( + abs(delta.x) < self._undo_angle_epsilon + and abs(delta.y) < self._undo_angle_epsilon + and abs(delta.z) < self._undo_angle_epsilon + ): + return + + old_copy = Vec3(old_hpr) + new_copy = Vec3(new_hpr) + self._rotate_undo_stack.append((node, old_copy, new_copy)) + self._log( + f"record rotate action: node={node.getName()} " + f"old_hpr={old_copy} new_hpr={new_copy} stack_size={len(self._rotate_undo_stack)}" + ) + + # 把这次旋转操作上报给 TransformGizmo,用于全局撤销栈 + if self.on_action_committed is not None: + try: + self.on_action_committed( + { + "kind": "rotate", + "node": node, + "old_hpr": Vec3(old_copy), + "new_hpr": Vec3(new_copy), + } + ) + except Exception as exc: + self._log(f"on_action_committed(rotate) error: {exc}") + + def undo_last_rotate(self): + """ + 撤销最近一次旋转操作。 + 可在 UI 中绑定快捷键调用。 + """ + if not self._rotate_undo_stack: + self._log("undo_last_rotate: stack empty") + return + node, old_hpr, new_hpr = self._rotate_undo_stack.pop() + if node is None or node.isEmpty(): + self._log("undo_last_rotate: target node invalid") + return + node.setHpr(self.world.render, old_hpr) + self._log( + f"undo_last_rotate: node={node.getName()} " + f"old_hpr={old_hpr} new_hpr={new_hpr} remaining={len(self._rotate_undo_stack)}" + ) + + # ------------------------------------------------------------------ # + # 事件注册 / 取消 + # ------------------------------------------------------------------ # + def _register_events(self): + """注册鼠标事件监听。""" + self.accept("mouse1", self._on_mouse_down) + self.accept("mouse1-up", self._on_mouse_up) + self.accept("mouse2-up", self._on_mouse_up) + self.accept("mouse3-up", self._on_mouse_up) + self.accept("mouse-move", self._on_mouse_move) + self._log("event listeners registered (mouse)") + + def _ignore_events(self): + """取消事件监听。""" + self.ignore("mouse1") + self.ignore("mouse1-up") + self.ignore("mouse2-up") + self.ignore("mouse3-up") + self.ignore("mouse-move") + self._log("event listeners removed") + + # ------------------------------------------------------------------ # + # 鼠标坐标工具 + # ------------------------------------------------------------------ # + def _get_normalized_mouse(self, extra) -> Optional[Point3]: + """ + 将鼠标转换到 Panda3D 的标准化设备坐标 [-1, 1]。 + + 优先使用 Qt / 外部 UI 传入的像素坐标(extra 字典), + 如果没有,则回退到 Panda3D 的 mouseWatcherNode。 + """ + if 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) + + # 1) 外部 UI:通过 messenger 传入像素坐标 + if isinstance(extra, dict) and "x" in extra and "y" in extra: + parent = getattr(self.world, "parent", None) + if parent is not None: + w = max(parent.width(), 1) + h = max(parent.height(), 1) + nx = (extra["x"] / w) * 2.0 - 1.0 + # Qt 原点在左上,Panda3D 原点在中心,Y 轴向上 + ny = 1.0 - (extra["y"] / h) * 2.0 + return Point3(nx, ny, 0.0) + 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 + + @staticmethod + def _wrap_angle_rad(angle_rad: float) -> float: + """将弧度角包裹到 (-pi, pi],避免跨越 180 度时跳变。""" + while angle_rad <= -math.pi: + angle_rad += math.tau + while angle_rad > math.pi: + angle_rad -= math.tau + return angle_rad + + def _get_mouse_screen_angle(self, mpos: Point3) -> Optional[float]: + """返回鼠标相对 Gizmo 屏幕中心的极角。""" + if self._center_screen is None: + return None + + offset_x = mpos.x - self._center_screen.x + offset_y = mpos.y - self._center_screen.y + if offset_x * offset_x + offset_y * offset_y < 1e-10: + return None + return math.atan2(offset_y, offset_x) + + # ------------------------------------------------------------------ # + # 屏幕空间旋转辅助方法 + # ------------------------------------------------------------------ # + def _compute_screen_space_params(self, mpos: Point3): + """ + 计算屏幕空间旋转所需的参数: + - Gizmo 中心在屏幕上的投影位置 + - 旋转轴在屏幕上的投影方向(2D向量) + - 起始鼠标位置 + """ + if not self.camera: + return + + lens = self.camera.node().getLens() + + # 将 Gizmo 中心投影到屏幕空间 + center_cam = self.camera.getRelativePoint( + self.world.render, self.center_world) + center_2d = Point2() + if lens.project(center_cam, center_2d): + self._center_screen = center_2d + else: + self._center_screen = Point2(0, 0) + + # 将旋转轴末端投影到屏幕空间,计算轴在屏幕上的方向 + axis_end_world = self.center_world + self.rotate_axis_world + axis_end_cam = self.camera.getRelativePoint( + self.world.render, axis_end_world) + axis_end_2d = Point2() + if lens.project(axis_end_cam, axis_end_2d): + # 屏幕上的轴方向向量 + self._axis_screen_dir = Point2( + axis_end_2d.x - self._center_screen.x, + axis_end_2d.y - self._center_screen.y + ) + # 归一化 + axis_len = math.sqrt( + self._axis_screen_dir.x ** 2 + self._axis_screen_dir.y ** 2) + if axis_len > 1e-6: + self._axis_screen_dir = Point2( + self._axis_screen_dir.x / axis_len, + self._axis_screen_dir.y / axis_len + ) + else: + self._axis_screen_dir = Point2(0, 1) + else: + self._axis_screen_dir = Point2(0, 1) + + # 记录起始鼠标位置 + self._start_mouse_screen = Point2(mpos.x, mpos.y) + self._last_screen_mouse = Point2(mpos.x, mpos.y) + + # 旋转灵敏度(屏幕单位到角度的转换系数) + self._screen_sensitivity = 180.0 # 移动1个屏幕单位 = 180度 + start_screen_angle = self._get_mouse_screen_angle(mpos) + self._start_screen_angle = start_screen_angle or 0.0 + self._last_screen_angle = start_screen_angle + self._axis_screen_sign = self._compute_screen_rotation_sign() + + self._log( + f"screen params: center={self._center_screen} " + f"axis_dir={self._axis_screen_dir} " + f"start_mouse={self._start_mouse_screen} " + f"start_angle={self._start_screen_angle:.4f} " + f"sign={self._axis_screen_sign:.1f}" + ) + + def _compute_screen_rotation_sign(self) -> float: + """计算屏幕极角增长方向与世界正旋转方向之间的映射符号。""" + if ( + not self.camera + or self._center_screen is None + or self.start_vec_on_plane.length_squared() == 0 + or self.rotate_axis_world.length_squared() == 0 + ): + return 1.0 + + lens = self.camera.node().getLens() + start_world = self.center_world + self.start_vec_on_plane * self.ring_radius + positive_tangent = self.rotate_axis_world.cross(self.start_vec_on_plane) + if positive_tangent.length_squared() == 0: + return 1.0 + positive_tangent.normalize() + + sample_world = start_world + positive_tangent * (self.ring_radius * 0.15) + start_cam = self.camera.getRelativePoint(self.world.render, start_world) + sample_cam = self.camera.getRelativePoint(self.world.render, sample_world) + + start_2d = Point2() + sample_2d = Point2() + if not lens.project(start_cam, start_2d) or not lens.project(sample_cam, sample_2d): + return 1.0 + + center = self._center_screen + start_angle = math.atan2(start_2d.y - center.y, start_2d.x - center.x) + sample_angle = math.atan2(sample_2d.y - center.y, sample_2d.x - center.x) + delta = self._wrap_angle_rad(sample_angle - start_angle) + if abs(delta) < 1e-6: + return 1.0 + return 1.0 if delta > 0.0 else -1.0 + + def _calc_screen_space_angle(self, mpos: Point3) -> float: + """ + 使用屏幕空间累计旋转角度(度)。 + 优先根据 Gizmo 中心的极角连续展开;投影退化时回退到切线位移。 + """ + current_angle = self._get_mouse_screen_angle(mpos) + if current_angle is not None: + if self._last_screen_angle is not None: + step_rad = self._wrap_angle_rad(current_angle - self._last_screen_angle) + step_deg = math.degrees(step_rad) * self._axis_screen_sign + self._drag_accumulated_deg += step_deg + self._log( + f"screen calc(polar): mouse=({mpos.x:.3f},{mpos.y:.3f}) " + f"step_deg={step_deg:.2f} total={self._drag_accumulated_deg:.2f}" + ) + self._last_screen_angle = current_angle + self._last_screen_mouse = Point2(mpos.x, mpos.y) + return self._drag_accumulated_deg + + if self._axis_screen_dir is None or self._last_screen_mouse is None: + self._log("screen params not ready") + return self._drag_accumulated_deg + + move_x = mpos.x - self._last_screen_mouse.x + move_y = mpos.y - self._last_screen_mouse.y + perp_x = -self._axis_screen_dir.y + perp_y = self._axis_screen_dir.x + perp_component = move_x * perp_x + move_y * perp_y + step_deg = -(perp_component * self._screen_sensitivity) + self._drag_accumulated_deg += step_deg + self._last_screen_mouse = Point2(mpos.x, mpos.y) + + self._log( + f"screen calc(linear): mouse=({mpos.x:.3f},{mpos.y:.3f}) " + f"move=({move_x:.3f},{move_y:.3f}) " + f"step_deg={step_deg:.2f} total={self._drag_accumulated_deg:.2f}" + ) + return self._drag_accumulated_deg + + def _calc_plane_space_angle(self, curr_vec: Vec3, axis_world: Vec3) -> float: + """使用平面向量的相邻帧有符号夹角累计旋转量。""" + prev_vec = self._last_drag_vec_on_plane + curr = Vec3(curr_vec) + if curr.length_squared() == 0: + return self._drag_accumulated_deg + curr.normalize() + + if prev_vec is None or prev_vec.length_squared() == 0: + self._last_drag_vec_on_plane = Vec3(curr) + return self._drag_accumulated_deg + + prev = Vec3(prev_vec) + prev.normalize() + dot = max(min(prev.dot(curr), 1.0), -1.0) + cross = prev.cross(curr) + step_rad = math.atan2(cross.dot(axis_world), dot) + step_deg = math.degrees(step_rad) + self._drag_accumulated_deg += step_deg + self._last_drag_vec_on_plane = Vec3(curr) + + self._log( + f"plane calc: step_deg={step_deg:.2f} total={self._drag_accumulated_deg:.2f}" + ) + return self._drag_accumulated_deg + + # ------------------------------------------------------------------ # + # 鼠标事件:按下 / 弹起 / 移动 + # ------------------------------------------------------------------ # + def _on_mouse_down(self, extra=None): + if not self.attached or not self.camera or self.target_node is None: + return + + mpos = self._get_normalized_mouse(extra) + if mpos is None: + self._log("mouse_down ignored: no mouse pos") + return + + # 设置拾取射线 + self.picker_np.reparentTo(self.camera) + if not self._picker_added: + self.c_trav.addCollider(self.picker_np, self.c_queue) + self._picker_added = True + self.picker_ray.setFromLens(self.camera.node(), mpos.x, mpos.y) + + self.c_queue.clearEntries() + self.c_trav.traverse(self.root) + + self._log( + f"mouse_down mpos={mpos} cam_pos={self.camera.getPos(self.world.render)} " + f"cam_hpr={self.camera.getHpr(self.world.render)} " + f"entries={self.c_queue.getNumEntries()}" + ) + + num_entries = self.c_queue.getNumEntries() + if num_entries <= 0: + self._log("mouse_down: no collision entries on rotate gizmo") + return + + self.c_queue.sortEntries() + + axis_entry: Optional[CollisionEntry] = None + trackball_entry: Optional[CollisionEntry] = None + picked_kind: Optional[str] = None # "axis" / "trackball" + picked_axis_id: Optional[int] = None + for i in range(num_entries): + e = self.c_queue.getEntry(i) + axis_tag = e.getIntoNode().getTag("gizmo_axis") + handle_tag = e.getIntoNode().getTag("gizmo_handle") + if axis_tag: + axis_entry = e + picked_axis_id = int(axis_tag) + break + if handle_tag == "trackball" and trackball_entry is None: + trackball_entry = e + + if axis_entry is not None: + picked_kind = "axis" + elif trackball_entry is not None: + axis_entry = trackball_entry + picked_kind = "trackball" + + if axis_entry is None or picked_kind is None: + self._log("mouse_down: hit something but no gizmo handle tag") + return + + self.dragging = True + self.drag_mode = "axis" if picked_kind == "axis" else picked_kind + self.drag_axis = picked_axis_id if self.drag_mode == "axis" else None + + self.center_world = self.root.getPos(self.world.render) + self.start_quat = self.target_node.getQuat(self.world.render) + self.start_quat_local = self.target_node.getQuat() + self.start_mouse = Point2(mpos.x, mpos.y) + self._trackball_start_vec = None + self._drag_root_quat_world = self.root.getQuat(self.world.render) + self._hide_angle_disc() + + # 轨迹球模式:屏幕空间自由旋转,不需要射线平面求交 + if self.drag_mode == "trackball": + self._trackball_start_vec = self._map_to_trackball(mpos) + self._highlight_axis("trackball") + self._emit_event( + GizmoEvent.DRAG_START, + { + "gizmo": "rotate", + "mode": self.drag_mode, + "axis": self.drag_axis, + "target": self.target_node, + "mouse": Point2(mpos.x, mpos.y), + "start_quat": self.start_quat, + }, + ) + return + + # 以“可见圆环”的朝向为准,避免目标节点已有旋转时出现视觉/数学轴不一致 + if self.drag_mode == "axis" and self.drag_axis is not None: + ring_np = self.rings[self.drag_axis] + # 环所在平面法线:本地 +Z + ring_axis_local = ring_np.getQuat().xform(Vec3(0, 0, 1)) + if ring_axis_local.length_squared() == 0: + ring_axis_local = Vec3(0, 0, 1) + ring_axis_local.normalize() + self.drag_local_axis = ring_axis_local # 父坐标系下的轴(与控件视觉一致) + + if self.drag_axis == 0: + self.start_root_axis_dir = Quat(self.root.getQuat()).getRight() + elif self.drag_axis == 1: + self.start_root_axis_dir = Quat( + self.root.getQuat()).getForward() + elif self.drag_axis == 2: + self.start_root_axis_dir = Quat(self.root.getQuat()).getUp() + + # 使用圆环本身的朝向来定义平面法线(可见轴与数学轴一致) + ring_np_world_quat = ring_np.getQuat(self.world.render) + axis_dir: Vec3 = ring_np_world_quat.xform(Vec3(0, 0, 1)) + if axis_dir.length_squared() == 0: + axis_dir = Vec3(0, 0, 1) + axis_dir.normalize() + self.rotate_axis_world = axis_dir + + # 计算当前鼠标射线与“旋转平面”(法线为当前轴,过 center_world)之间的交点 + lens: PerspectiveLens = self.camera.node().getLens() + p_from = Point3() + p_to = Point3() + hit_point: Optional[Point3] = None + if lens.extrude(Point2(mpos.x, mpos.y), p_from, p_to): + ray_origin = self.world.render.getRelativePoint( + self.camera, p_from) + ray_to = self.world.render.getRelativePoint(self.camera, p_to) + ray_dir = ray_to - ray_origin + if ray_dir.length_squared() != 0: + ray_dir.normalize() + n = self.rotate_axis_world + denom = ray_dir.dot(n) + if abs(denom) > 1e-6: + t = (self.center_world - ray_origin).dot(n) / denom + hit_point = ray_origin + ray_dir * t + + # 如果无法求交(几乎平行),回退到“摄像机投影向量” + if hit_point is None: + cam_pos = self.camera.getPos(self.world.render) + v = cam_pos - self.center_world + n = self.rotate_axis_world + proj = v - n * v.dot(n) + if proj.length_squared() == 0: + proj = Vec3(1, 0, 0) if abs(n.x) < 0.9 else Vec3(0, 1, 0) + hit_point = self.center_world + proj + + start_vec = hit_point - self.center_world + if start_vec.length_squared() == 0: + start_vec = Vec3(1, 0, 0) + start_vec.normalize() + self.start_vec_on_plane = start_vec + self._last_drag_vec_on_plane = Vec3(start_vec) + self._drag_accumulated_deg = 0.0 + self._last_screen_mouse = Point2(mpos.x, mpos.y) + + # 计算屏幕空间参数,用于边缘视角时的备用旋转计算 + self._compute_screen_space_params(mpos) + + # 初始化角度扇形 + self._begin_angle_visual(self.rotate_axis_world) + + self._highlight_axis( + self.drag_axis if self.drag_mode == "axis" else self.drag_mode) + if self.debug: + t_quat_world = self.target_node.getQuat(self.world.render) + t_quat_local = self.target_node.getQuat() + self._log( + " / ".join( + [ + f"pick handle={self.drag_mode} axis={self.drag_axis} target={self.target_node.getName()}", + f"center={self.center_world}", + f"axis_world(from ring)={self.rotate_axis_world}", + f"axis_local(from ring)={self.drag_local_axis}", + f"start_vec={self.start_vec_on_plane}", + f"t_quat_world={t_quat_world}", + f"t_quat_local={t_quat_local}", + ] + ) + ) + self._emit_event( + GizmoEvent.DRAG_START, + { + "gizmo": "rotate", + "mode": self.drag_mode, + "axis": self.drag_axis, + "target": self.target_node, + "mouse": Point2(mpos.x, mpos.y), + "start_quat": self.start_quat, + "center": Point3(self.center_world), + "axis_world": Vec3(self.rotate_axis_world), + }, + ) + + def _on_mouse_up(self, extra=None): + self._update_ring_halves_visibility() + if self.dragging and self.target_node is not None: + try: + final_hpr = self.target_node.getHpr(self.world.render) + except Exception: + final_hpr = None + old_hpr = None + if self.start_quat is not None: + # 将起始四元数转换为 HPR(世界坐标系) + tmp_np = NodePath("tmp_quat_to_hpr") + tmp_np.reparentTo(self.world.render) + tmp_np.setQuat(self.world.render, self.start_quat) + old_hpr = tmp_np.getHpr(self.world.render) + tmp_np.removeNode() + + if old_hpr is not None and final_hpr is not None: + self._record_rotate_action( + self.target_node, old_hpr, final_hpr) + + mode = self.drag_mode + axis_id = self.drag_axis + self._emit_event( + GizmoEvent.DRAG_END, + { + "gizmo": "rotate", + "mode": mode, + "axis": axis_id, + "target": self.target_node, + "start_quat": self.start_quat, + "final_hpr": final_hpr if "final_hpr" in locals() else None, + }, + ) + self.dragging = False + self.drag_axis = None + self.drag_mode = None + self._trackball_start_vec = None + self._drag_root_quat_world = None + self._last_drag_vec_on_plane = None + self._last_screen_mouse = None + self._last_screen_angle = None + self._drag_accumulated_deg = 0.0 + self._hide_angle_disc() + # 结束拖拽后恢复 Gizmo 与目标的对齐方式 + if self.is_local: + self.root.setHpr(self.root.parent, 0, 0, 0) + else: + self.root.setHpr(self.world.render, 0, 0, 0) + self._reset_highlights() + self._log("mouse_up -> stop rotating") + + def _on_mouse_move(self, extra=None): + if not self.attached or not self.camera: + return + + mpos = self._get_normalized_mouse(extra) + if mpos is None: + if self.dragging: + self._log("mouse_move ignored: no mouse pos while rotating") + return + + # Hover highlight when idle (not dragging). + if not self.dragging: + self._update_ring_halves_visibility() + self._update_hover_highlight(mpos) + return + + if not self.target_node: + return + + # 拖拽过程中持续更新半圆可见性与分割线朝向 + self._update_ring_halves_visibility() + + # 轨迹球模式:直接基于屏幕向量计算旋转 + if self.drag_mode == "trackball": + self._apply_trackball_rotation(mpos) + return + + lens: PerspectiveLens = self.camera.node().getLens() + p_from = Point3() + p_to = Point3() + if not lens.extrude(Point2(mpos.x, mpos.y), p_from, p_to): + self._log("mouse_move: lens.extrude failed") + return + + ray_origin = self.world.render.getRelativePoint(self.camera, p_from) + ray_to = self.world.render.getRelativePoint(self.camera, p_to) + ray_dir = ray_to - ray_origin + if ray_dir.length_squared() == 0: + return + ray_dir.normalize() + + # 与旋转平面求交:平面法线为当前轴向,过 Gizmo 中心 + axis_world = Vec3(self.rotate_axis_world) + if axis_world.length_squared() == 0: + return + axis_world.normalize() + + denom = ray_dir.dot(axis_world) + + curr_vec: Optional[Vec3] = None + if abs(denom) > 1e-6: + t = (self.center_world - ray_origin).dot(axis_world) / denom + hit_point = ray_origin + ray_dir * t + curr_vec = hit_point - self.center_world + if curr_vec.length_squared() != 0: + curr_vec.normalize() + else: + curr_vec = None + + # 判断是否处于边缘视角(grazing angle) + use_screen_space = abs(denom) < self._grazing_threshold or curr_vec is None + + if use_screen_space: + delta_deg = self._calc_screen_space_angle(mpos) + if curr_vec is not None: + self._last_drag_vec_on_plane = Vec3(curr_vec) + if self.debug: + self._log( + f"grazing angle detected: denom={denom:.4f}, " + f"using screen space, delta_deg={delta_deg:.2f}" + ) + else: + delta_deg = self._calc_plane_space_angle(curr_vec, axis_world) + screen_angle = self._get_mouse_screen_angle(mpos) + if screen_angle is not None: + self._last_screen_angle = screen_angle + self._last_screen_mouse = Point2(mpos.x, mpos.y) + + # 轴向旋转根据 is_local 切换本地/世界应用方式 + if self.drag_mode == "axis" and self.is_local: + # 本地模式:绕父坐标系下的轴旋转,保持与可见圆环一致。 + if self.drag_local_axis is None or self.start_quat_local is None: + return + q_delta = Quat() + q_delta.setFromAxisAngle(delta_deg, self.drag_local_axis) + if self.debug: + self._log( + f"[RotateGizmo] local q_delta={q_delta} delta_deg={delta_deg} axis_world={axis_world} axis_local={self.drag_local_axis}" + ) + new_quat_local = q_delta * self.start_quat_local + self.target_node.setQuat(new_quat_local) + else: + # 世界模式:绕固定的世界轴旋转,直接在世界坐标系应用。 + q_delta = Quat() + axis_for_world = self.start_root_axis_dir or axis_world + if axis_for_world.length_squared() == 0: + axis_for_world = axis_world + else: + axis_for_world.normalize() + q_delta.setFromAxisAngle(delta_deg, axis_for_world) + if self.debug: + self._log( + f"[RotateGizmo] world q_delta={q_delta} delta_deg={delta_deg} axis_world={axis_world}" + ) + new_quat_world = q_delta * self.start_quat + self.target_node.setQuat(self.world.render, new_quat_world) + + # 更新角度扇形可视化 + self._update_angle_visual(delta_deg) + # 拖拽时锁定 Gizmo 的世界朝向,避免扇形跟随目标旋转 + if self._drag_root_quat_world is not None: + self.root.setQuat(self.world.render, self._drag_root_quat_world) + + self._emit_event( + GizmoEvent.DRAG_MOVE, + { + "gizmo": "rotate", + "mode": self.drag_mode, + "axis": self.drag_axis, + "target": self.target_node, + "mouse": Point2(mpos.x, mpos.y), + "delta_deg": delta_deg, + }, + ) + + if self.debug: + if use_screen_space: + self._log( + f"rotate handle={self.drag_mode} angle={delta_deg:.2f} center={self.center_world} n={axis_world} (screen space)" + ) + else: + self._log( + f"rotate handle={self.drag_mode} axis={self.drag_axis} angle={delta_deg:.2f} " + f"center={self.center_world} n={axis_world} " + f"start_vec={self.start_vec_on_plane} curr_vec={curr_vec}" + ) + + def _update_hover_highlight(self, mpos: Point3): + """Highlight the hovered rotate handle without starting a drag.""" + self.picker_np.reparentTo(self.camera) + if not self._picker_added: + self.c_trav.addCollider(self.picker_np, self.c_queue) + self._picker_added = True + self.picker_ray.setFromLens(self.camera.node(), mpos.x, mpos.y) + + self.c_queue.clearEntries() + self.c_trav.traverse(self.root) + + num_entries = self.c_queue.getNumEntries() + if num_entries <= 0: + self._reset_highlights() + self.is_hovering = False + return + + self.c_queue.sortEntries() + + axis_entry: Optional[CollisionEntry] = None + trackball_entry: Optional[CollisionEntry] = None + for i in range(num_entries): + e = self.c_queue.getEntry(i) + axis_tag = e.getIntoNode().getTag("gizmo_axis") + handle_tag = e.getIntoNode().getTag("gizmo_handle") + if axis_tag: + axis_entry = e + break + if handle_tag == "trackball" and trackball_entry is None: + trackball_entry = e + + if axis_entry is not None: + self._highlight_axis(int(axis_entry.getIntoNode().getTag("gizmo_axis"))) + self.is_hovering = True + elif trackball_entry is not None: + self._highlight_axis("trackball") + self.is_hovering = True + else: + self._reset_highlights() + self.is_hovering = False + + # ------------------------------------------------------------------ # + # 高亮与更新 + # ------------------------------------------------------------------ # + def _highlight_axis(self, axis_id: Optional[int | str]): + """高亮指定的旋转手柄(轴 / 轨迹球)。""" + for i, ring in enumerate(self.rings): + if i == axis_id: + ring.setColorScale(2, 2, 2, 1.0) + ring.setAlphaScale(1.0) + else: + ring.clearColorScale() + ring.setAlphaScale(0.3) + + for idx, axis_np in enumerate(self.center_axes): + if axis_id == idx: + axis_np.setColorScale(1.5, 1.5, 1.5, 1.0) + else: + axis_np.clearColorScale() + + if self.trackball_np: + geom_np = self.trackball_np.find("**/trackball_geom") + if not geom_np.isEmpty(): + if axis_id == "trackball": + geom_np.show() + geom_np.setColorScale(self.highlight_color.x, self.highlight_color.y,self.highlight_color.z, 0.4) + if self.trackball_edge: + self.trackball_edge.setColorScale(self.highlight_color.x, self.highlight_color.y, self.highlight_color.z, 0.6) + else: + geom_np.hide() + if self.trackball_edge: + self.trackball_edge.setColorScale(self.trackball_edge_color) + + def _reset_highlights(self): + for ring in self.rings: + ring.clearColorScale() + ring.setAlphaScale(self.ring_alpha) + if self.trackball_edge: + self.trackball_edge.setColorScale(self.trackball_edge_color) + for axis_np in self.center_axes: + axis_np.clearColorScale() + + if self.trackball_np: + geom_np = self.trackball_np.find("**/trackball_geom") + if not geom_np.isEmpty(): + geom_np.hide() + + def update(self): + """根据摄像机距离自动缩放 Gizmo。""" + 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() + # 持续调整半圆的可见性与分割线朝向 + self._update_ring_halves_visibility() + dist = ( + self.camera.getPos(self.world.render) + - self.root.getPos(self.world.render) + ).length() + scale = dist * 0.15 + self.root.setScale(self.world.render, scale) + + # print(f"target_node_hpr -> {self.target_node.getHpr()}") + + def _update_task(self, task: Task): + """每帧调用,用于持续更新 Gizmo 的缩放。""" + self.update() + return Task.cont + + # ------------------------------------------------------------------ # + # 内部:跟随目标更新根节点变换 + # ------------------------------------------------------------------ # + def _update_root_transform(self): + if not self.attached or not self.target_node: + return + render = self.world.render + tgt = self.target_node + self.root.setPos(render, tgt.getPos(render)) + # 拖拽过程中保持根节点朝向锁定,避免 gizmo 轴随目标旋转而跳动 + if self.dragging and self._drag_root_quat_world is not None: + self.root.setQuat(render, self._drag_root_quat_world) + else: + if self.is_local: + self.root.setQuat(render, tgt.getQuat(render)) + else: + self.root.setQuat(render, Quat.identQuat()) + + +__all__ = ["RotateGizmo"] diff --git a/src/TransformGizmo/scale_gizmo.py b/src/TransformGizmo/scale_gizmo.py new file mode 100644 index 0000000..8fd74ae --- /dev/null +++ b/src/TransformGizmo/scale_gizmo.py @@ -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"] diff --git a/src/TransformGizmo/transform_gizmo.py b/src/TransformGizmo/transform_gizmo.py new file mode 100644 index 0000000..895bd60 --- /dev/null +++ b/src/TransformGizmo/transform_gizmo.py @@ -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"] diff --git a/src/impanda3d/app.py b/src/impanda3d/app.py index 8e2acbd..58d46a8 100644 --- a/src/impanda3d/app.py +++ b/src/impanda3d/app.py @@ -1,4 +1,4 @@ -from imgui_bundle import immapp, immvision +from imgui_bundle import immapp, immvision # type: ignore from .editor import EditorApp diff --git a/src/impanda3d/config.py b/src/impanda3d/config.py index 097d6f8..4cb06b4 100644 --- a/src/impanda3d/config.py +++ b/src/impanda3d/config.py @@ -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: diff --git a/src/impanda3d/editor.py b/src/impanda3d/editor.py index 0fb5a38..7d5900b 100644 --- a/src/impanda3d/editor.py +++ b/src/impanda3d/editor.py @@ -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): diff --git a/src/impanda3d/renderer.py b/src/impanda3d/renderer.py index 2f05804..e61bb41 100644 --- a/src/impanda3d/renderer.py +++ b/src/impanda3d/renderer.py @@ -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: diff --git a/src/impanda3d/scene.py b/src/impanda3d/scene.py index c9eca83..55fd083 100644 --- a/src/impanda3d/scene.py +++ b/src/impanda3d/scene.py @@ -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) diff --git a/src/tools/camera_orbit_controller.py b/src/tools/camera_orbit_controller.py new file mode 100644 index 0000000..6f20525 --- /dev/null +++ b/src/tools/camera_orbit_controller.py @@ -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") diff --git a/src/tools/picker_ray.py b/src/tools/picker_ray.py new file mode 100644 index 0000000..e6eb277 --- /dev/null +++ b/src/tools/picker_ray.py @@ -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)