import math from typing import Optional, Tuple, Callable, Dict, Any, List from panda3d.core import ( NodePath, Vec3, Point3, Point2, Vec4, Quat, GeomNode, GeomVertexFormat, GeomVertexData, GeomVertexWriter, GeomTriangles, GeomLines, Geom, CollisionNode, CollisionRay, CollisionHandlerQueue, CollisionTraverser, CollisionTube, CollisionBox, BitMask32, PerspectiveLens, Material, LPoint2f ) from direct.showbase.DirectObject import DirectObject from direct.task import Task from direct.task.TaskManagerGlobal import taskMgr from direct.showbase.ShowBase import ShowBase from .events import GizmoEvent """ 缩放控制柄(Scale Gizmo) ========================= 功能概述: - 模仿 Unity 的缩放控制柄,使用 3 个彩色轴线表示沿 X/Y/Z 轴缩放 - X 轴:红色线条 + 方块(沿局部 X 轴缩放) - Y 轴:绿色线条 + 方块(沿局部 Y 轴缩放) - Z 轴:蓝色线条 + 方块(沿局部 Z 轴缩放) - 中心有一个白色小方块,用于均匀缩放(沿所有轴同时缩放) - 使用 Panda3D 的碰撞系统进行鼠标拾取与拖拽 - 控件会根据摄像机距离自动缩放,屏幕大小保持近似不变 集成方式(示例): from direct.showbase.ShowBase import ShowBase from TransformGizmo.scale_gizmo import ScaleGizmo world = ShowBase() model_np = world.render.attachNewNode("Box") # ... 在 model_np 下加载模型 gizmo = ScaleGizmo(world) # 创建缩放 Gizmo gizmo.attach(model_np) # 绑定到某个模型 # 当需要切换目标时: gizmo.attach(another_np) # 当不需要时,可隐藏: gizmo.detach() 鼠标事件要求: - 本类默认监听 Panda3D 的 "mouse1" / "mouse1-up" / "mouse-move" 事件 - 如果从外部 UI 传递鼠标像素坐标,可以在发送事件时传入 extra 字典: messenger.send("mouse1", [{"x": mouse_x, "y": mouse_y}]) 本类会自动将像素坐标转换到 [-1, 1] 的标准化设备坐标 """ class ScaleGizmo(DirectObject): """ Unity 风格的缩放控制柄: - 3 个轴线 + 方块分别代表沿局部 X/Y/Z 轴缩放 - 中心方块代表均匀缩放 - 左键点击并拖拽,可以缩放绑定的 NodePath """ def __init__( self, world:ShowBase, camera_np: NodePath | None = None, on_action_committed: Optional[Callable[[Dict[str, Any]], None]] = None, event_hooks: Optional[Dict[str, List[Callable[[Dict[str, Any]], None]]]] = None ): super().__init__() self.world = world self.is_local = True self.debug: bool = False self.is_hovering = False self._picker_added: bool = False self._debug_drag_counter: int = 0 self.on_action_committed = on_action_committed # Event hooks: drag_start / drag_move / drag_end self._event_hooks = self._normalize_event_hooks(event_hooks) self.camera: Optional[NodePath] = camera_np or getattr( world, "cam", None) if not self.camera and hasattr(world, "base"): self.camera = world.base.cam if not self.camera: self._log("No camera found for ScaleGizmo") else: self._log(f"Camera found for ScaleGizmo: {self.camera}") self.root: NodePath = NodePath("ScaleGizmo") self.color_higher = 0.05 self.color_normal = 0.01 self.last_mouse_pos = None # self.root.setBin("fixed", 40) # self.root.setDepthTest(False) # self.root.setDepthWrite(False) # self.root.setLightOff() self.target_node: Optional[NodePath] = None self.attached: bool = False # 控制柄视觉参数 self.axis_length: float = 0.5 self.axis_thickness: float = 0.02 self.cube_size: float = 0.04 self.center_cube_size: float = 0.06 self.axis_alpha: float = 0.75 # 记录基准尺寸,用于拖拽时的可视放缩 self._axis_length0: float = self.axis_length self._cube_size0: float = self.cube_size self._center_cube_size0: float = self.center_cube_size # 拾取射线与碰撞系统 self.picker_ray = CollisionRay() self.picker_node = CollisionNode("scale_gizmo_picker_ray") self.picker_node.addSolid(self.picker_ray) self.picker_node.setFromCollideMask(BitMask32.bit(20)) self.picker_node.setIntoCollideMask(BitMask32.allOff()) self.picker_np: NodePath = NodePath(self.picker_node) self.c_trav = CollisionTraverser() self.c_queue = CollisionHandlerQueue() # 拖拽状态 self.dragging: bool = False self.drag_axis: Optional[int] = None # 0=X, 1=Y, 2=Z, 3=Uniform self.start_mouse: Optional[Point2] = None self.start_scale: Optional[Vec3] = None self.drag_axis_dir: Vec3 = Vec3(0, 0, 0) self.drag_axis_screen: Point2 = Point2(1, 0) self.drag_base_dist: float = 1.0 self._start_param_on_ray: float = 0.0 # 撤销栈 self._scale_undo_stack: list[Tuple[NodePath, Vec3, Vec3]] = [] self._undo_scale_epsilon: float = 1e-4 # 轴和中心控制柄节点 self.axes: list[NodePath] = [] self.axes_colors: list[Tuple[NodePath,NodePath, Vec4]] = [] # 可视节点(线与端点立方体),与碰撞体分离,便于单独放缩 self.axis_visuals: list[Dict[str, NodePath]] = [] self.axis_visual_colors: list[Tuple[NodePath, Vec4]] = [] self.center_handle: Optional[NodePath] = None self.center_handle_color = Vec4(1,1,1,1) self._build_gizmo() self.root.reparentTo(self.world.render) self.root.setBin('fixed',40) self.root.setDepthTest(False) self.root.setDepthWrite(False) self.root.setLightOff() self.root.hide() # taskMgr.add(self._update_task, "ScaleGizmoUpdateTask") def _log(self, msg: str): if self.debug: print(f"[ScaleGizmo] {msg}") def _normalize_event_hooks(self, hooks): base = {name: [] for name in GizmoEvent.ALL} if not hooks: return base for name in list(base.keys()): cbs = hooks.get(name) if cbs: base[name] = list(cbs) return base def _emit_event(self, name: str, payload: Dict[str, Any]): handlers = self._event_hooks.get(name, []) for cb in handlers: try: cb(payload) except Exception as exc: self._log(f"event hook '{name}' error: {exc}") def _build_gizmo(self): """构建三个轴控制柄和中心均匀缩放控制柄。""" # X 轴(红) axis_root, line_np, cube_np = self._create_axis( axis_dir=Vec3(1, 0, 0), color=Vec4(1, 0, 0, 1), length=self.axis_length, thickness=self.axis_thickness, cube_size=self.cube_size, axis_id=0, ) self.axes.append(axis_root) self.axis_visuals.append( {"line": line_np, "cube": cube_np, "length": self.axis_length, "cube_size": self.cube_size} ) # Y 轴(绿) axis_root, line_np, cube_np = self._create_axis( axis_dir=Vec3(0, 1, 0), color=Vec4(0, 1, 0, 1), length=self.axis_length, thickness=self.axis_thickness, cube_size=self.cube_size, axis_id=1, ) self.axes.append(axis_root) self.axis_visuals.append( {"line": line_np, "cube": cube_np, "length": self.axis_length, "cube_size": self.cube_size} ) # Z 轴(蓝) axis_root, line_np, cube_np = self._create_axis( axis_dir=Vec3(0, 0, 1), color=Vec4(0, 0, 1, 1), length=self.axis_length, thickness=self.axis_thickness, cube_size=self.cube_size, axis_id=2, ) self.axes.append(axis_root) self.axis_visuals.append({"line": line_np, "cube": cube_np, "length": self.axis_length, "cube_size": self.cube_size}) # 中心控制柄(白色方块,用于均匀缩放) self.center_handle = self._create_center_handle( size=self.center_cube_size, color=Vec4(1, 1, 1, 1), ) def _set_node_dept(self,node:NodePath,color:Vec4 = None): if node is None or node.is_empty():return mat = Material('default') mat.set_emission((1,0,1,1)) if color: mat.base_color = color * self.color_normal node.set_material(mat) node.set_bin('fixed',40) node.set_depth_test(False) node.set_depth_write(False) def _create_axis( self, axis_dir: Vec3, color: Vec4, length: float, thickness: float, cube_size: float, axis_id: int, ) -> Tuple[NodePath, NodePath, NodePath]: """创建一个轴控制柄:线条 + 末端方块。""" axis_root = self.root.attachNewNode(f"scale_axis_{axis_id}") align_quat = self._quat_from_z(axis_dir) axis_root.setQuat(align_quat) # 几何:线条 line_geom = self._create_line_geom(length, color) line_np: NodePath = axis_root.attachNewNode(line_geom) line_np.setTransparency(True) line_np.setAlphaScale(self.axis_alpha) line_np_copy = line_np.copyTo(line_np) line_np_copy.set_pos(0,0,0) line_np_copy.set_hpr(0,0,0) # 几何:末端方块 cube_geom = self._create_cube_geom(cube_size, color) cube_np: NodePath = axis_root.attachNewNode(cube_geom) cube_np.setPos(0, 0, length) cube_np.setTransparency(True) cube_np.setAlphaScale(self.axis_alpha) cube_np_copy = cube_np.copy_to(cube_np) cube_np_copy.set_pos(0,0,0) cube_np_copy.set_hpr(0,0,0) # 碰撞体:线条使用 Tube c_node = CollisionNode(f"scale_axis_col_{axis_id}") tube = CollisionTube(Point3(0, 0, 0), Point3(0, 0, length), thickness * 2) c_node.addSolid(tube) c_node.setIntoCollideMask(BitMask32.bit(20)) c_node.setFromCollideMask(BitMask32.allOff()) c_node.setTag("gizmo_axis", str(axis_id)) axis_root.attachNewNode(c_node) # 碰撞体:末端方块 c_cube_node = CollisionNode(f"scale_cube_col_{axis_id}") half = cube_size * 0.5 box = CollisionBox(Point3(0, 0, length), half, half, half) c_cube_node.addSolid(box) c_cube_node.setIntoCollideMask(BitMask32.bit(20)) c_cube_node.setFromCollideMask(BitMask32.allOff()) c_cube_node.setTag("gizmo_axis", str(axis_id)) axis_root.attachNewNode(c_cube_node) return axis_root, line_np, cube_np def _create_center_handle(self, size: float, color: Vec4) -> NodePath: """创建中心均匀缩放控制柄(方块)。""" center_root = self.root.attachNewNode("scale_center") cube_geom = self._create_cube_geom(size, color) cube_np: NodePath = center_root.attachNewNode(cube_geom) cube_np.setTransparency(True) cube_np.setAlphaScale(self.axis_alpha) c_node = CollisionNode("scale_center_col") half = size * 0.5 box = CollisionBox(Point3(0, 0, 0), half, half, half) c_node.addSolid(box) c_node.setIntoCollideMask(BitMask32.bit(20)) c_node.setFromCollideMask(BitMask32.allOff()) c_node.setTag("gizmo_center", "1") center_root.attachNewNode(c_node) return center_root def _create_line_geom(self, length: float, color: Vec4) -> GeomNode: """创建一条沿 +Z 方向的线段几何。""" vdata = GeomVertexData( "line", GeomVertexFormat.getV3c4(), Geom.UHStatic) vertex = GeomVertexWriter(vdata, "vertex") vcolor = GeomVertexWriter(vdata, "color") vertex.addData3(0, 0, 0) vcolor.addData4(color) vertex.addData3(0, 0, length) vcolor.addData4(color) prim = GeomLines(Geom.UHStatic) prim.addVertices(0, 1) geom = Geom(vdata) geom.addPrimitive(prim) node = GeomNode("line_geom") node.addGeom(geom) return node def _create_cube_geom(self, size: float, color: Vec4) -> GeomNode: """创建一个以原点为中心的立方体几何。""" vdata = GeomVertexData( "cube", GeomVertexFormat.getV3c4(), Geom.UHStatic) vertex = GeomVertexWriter(vdata, "vertex") vcolor = GeomVertexWriter(vdata, "color") half = size * 0.5 # 8个顶点 vertices = [ Point3(-half, -half, -half), # 0 Point3(half, -half, -half), # 1 Point3(half, half, -half), # 2 Point3(-half, half, -half), # 3 Point3(-half, -half, half), # 4 Point3(half, -half, half), # 5 Point3(half, half, half), # 6 Point3(-half, half, half), # 7 ] for v in vertices: vertex.addData3(v) vcolor.addData4(color) prim = GeomTriangles(Geom.UHStatic) # 6个面,每个面2个三角形 faces = [ (0, 1, 2, 3), # 底面 -Z (4, 7, 6, 5), # 顶面 +Z (0, 4, 5, 1), # 前面 -Y (2, 6, 7, 3), # 后面 +Y (0, 3, 7, 4), # 左面 -X (1, 5, 6, 2), # 右面 +X ] for f in faces: prim.addVertices(f[0], f[1], f[2]) prim.addVertices(f[0], f[2], f[3]) geom = Geom(vdata) geom.addPrimitive(prim) node = GeomNode("cube_geom") node.addGeom(geom) return node def _quat_from_z(self, direction: Vec3) -> Quat: """返回一个四元数,使本地 +Z 旋转到指定 direction。""" dir_norm = Vec3(direction) if dir_norm.length_squared() == 0: return Quat.identQuat() dir_norm.normalize() z_axis = Vec3(0, 0, 1) dot = z_axis.dot(dir_norm) if abs(dot - 1.0) < 1e-6: return Quat.identQuat() if abs(dot + 1.0) < 1e-6: ortho = Vec3(1, 0, 0) if abs(z_axis.dot( Vec3(1, 0, 0))) < 0.9 else Vec3(0, 1, 0) axis = z_axis.cross(ortho) axis.normalize() q = Quat() q.setFromAxisAngle(180.0, axis) return q axis = z_axis.cross(dir_norm) axis_len = axis.length() if axis_len == 0: return Quat.identQuat() axis.normalize() angle_rad = math.atan2(axis_len, dot) q = Quat() q.setFromAxisAngle(math.degrees(angle_rad), axis) return q def attach(self, node_path: NodePath): """绑定控制柄到指定 NodePath。""" if not node_path: return self.target_node = node_path self.attached = True self._update_root_transform() self.root.show() self._log( f"attached to {node_path.getName()} " f"world_pos={self.root.getPos(self.world.render)} " f"target_scale={node_path.getScale()}" ) self._register_events() def detach(self): """解绑并隐藏 Gizmo。""" self.target_node = None self.root.hide() self.attached = False self.dragging = False self.drag_axis = None self.is_hovering = False self._reset_axis_visuals() self._ignore_events() self._log("detached") def _record_scale_action(self, node: NodePath, old_scale: Vec3, new_scale: Vec3): """将一次缩放操作压入撤销栈。""" if node is None or node.isEmpty(): return if old_scale is None or new_scale is None: return delta = new_scale - old_scale if delta.length() < self._undo_scale_epsilon: return old_copy = Vec3(old_scale) new_copy = Vec3(new_scale) self._scale_undo_stack.append((node, old_copy, new_copy)) self._log( f"record scale action: node={node.getName()} " f"old_scale={old_copy} new_scale={new_copy} stack_size={len(self._scale_undo_stack)}" ) if self.on_action_committed is not None: try: self.on_action_committed( { "kind": "scale", "node": node, "old_scale": Vec3(old_copy), "new_scale": Vec3(new_copy), } ) except Exception as exc: self._log(f"on_action_committed(scale) error: {exc}") def undo_last_scale(self): """撤销最近一次缩放操作。""" if not self._scale_undo_stack: self._log("undo_last_scale: stack empty") return node, old_scale, new_scale = self._scale_undo_stack.pop() if node is None or node.isEmpty(): self._log("undo_last_scale: target node invalid") return node.setScale(old_scale) self._log( f"undo_last_scale: node={node.getName()} " f"old_scale={old_scale} new_scale={new_scale} remaining={len(self._scale_undo_stack)}" ) def _register_events(self): """注册鼠标事件监听。""" self.accept("mouse1", self._on_mouse_down) self.accept("mouse1-up", self._on_mouse_up) self.accept("mouse2-up", self._on_mouse_up) self.accept("mouse3-up", self._on_mouse_up) self.accept("mouse-move", self._on_mouse_move) self._log("event listeners registered (mouse)") def _ignore_events(self): """取消事件监听。""" self.ignore("mouse1") self.ignore("mouse1-up") self.ignore("mouse2-up") self.ignore("mouse3-up") self.ignore("mouse-move") self._log("event listeners removed") def _get_normalized_mouse(self, extra) -> Optional[Point3]: if self.world.mouseWatcherNode.hasMouse(): mouse = self.world.mouseWatcherNode.getMouse() return Point3(mouse.x, mouse.y, 0) """将鼠标转换到 Panda3D 的标准化设备坐标 [-1, 1]。""" if isinstance(extra, dict) and "x" in extra and "y" in extra: parent = getattr(self.world, "parent", None) if parent is not None: w = max(parent.width(), 1) h = max(parent.height(), 1) nx = (extra["x"] / w) * 2.0 - 1.0 ny = 1.0 - (extra["y"] / h) * 2.0 return Point3(nx, ny, 0.0) return None def _on_mouse_down(self, extra=None): if not self.attached or not self.camera or self.target_node is None: return mpos = self._get_normalized_mouse(extra) if mpos is None: self._log("mouse_down ignored: no mouse pos") return self.picker_np.reparentTo(self.camera) if not self._picker_added: self.c_trav.addCollider(self.picker_np, self.c_queue) self._picker_added = True self.picker_ray.setFromLens(self.camera.node(), mpos.x, mpos.y) self.c_queue.clearEntries() self.c_trav.traverse(self.root) self._log( f"mouse_down mpos={mpos} cam_pos={self.camera.getPos(self.world.render)} " f"entries={self.c_queue.getNumEntries()}" ) num_entries = self.c_queue.getNumEntries() if num_entries <= 0: self._log("mouse_down: no collision entries on scale gizmo") return self.c_queue.sortEntries() axis_entry = None center_entry = None for i in range(num_entries): e = self.c_queue.getEntry(i) if not center_entry and e.getIntoNode().getTag("gizmo_center"): center_entry = e if not axis_entry and e.getIntoNode().getTag("gizmo_axis"): axis_entry = e if center_entry and axis_entry: break # 优先选择中心控制柄 if center_entry is not None: self.dragging = True self.drag_axis = 3 # Uniform scale self.start_scale = self.target_node.getScale() self.start_mouse = Point2(mpos.x, mpos.y) node_pos_w = self.target_node.getPos(self.world.render) cam_pos = self.camera.getPos(self.world.render) self.drag_base_dist = max((cam_pos - node_pos_w).length(), 0.5) self._debug_drag_counter = 0 self._highlight_center() self._log( f"pick center (uniform scale) target={self.target_node.getName()} " f"start_scale={self.start_scale}" ) self._emit_event( GizmoEvent.DRAG_START, { "gizmo": "scale", "target": self.target_node, "axis": self.drag_axis, "mouse": Point2(mpos.x, mpos.y), "start_scale": self.start_scale, }, ) elif axis_entry is not None: axis_tag: str = axis_entry.getIntoNode().getTag("gizmo_axis") self.dragging = True self.drag_axis = int(axis_tag) self.start_scale = self.target_node.getScale() self.start_mouse = Point2(mpos.x, mpos.y) axis_index = int(axis_tag) axis_np: NodePath = self.axes[axis_index] axis_dir: Vec3 = axis_np.getQuat( self.world.render).xform(Vec3(0, 0, 1)) if axis_dir.length_squared() == 0: axis_dir = Vec3(0, 0, 1) axis_dir.normalize() self.drag_axis_dir = axis_dir cam_pos = self.camera.getPos(self.world.render) node_pos_w = self.target_node.getPos(self.world.render) lens: PerspectiveLens = self.camera.node().getLens() self.drag_base_dist = max((cam_pos - node_pos_w).length(), 0.5) sample_len = max(self.drag_base_dist * 0.25, 0.25) p0_cam = self.camera.getRelativePoint( self.world.render, node_pos_w) p1_cam = self.camera.getRelativePoint( self.world.render, node_pos_w + axis_dir * sample_len ) p0_2d = Point2() p1_2d = Point2() # 注意:即使 lens.project 返回 False(点可能超出裁剪范围), # 它仍然会填充 p0_2d/p1_2d,我们可以使用这些值来计算方向 proj_ok_0 = lens.project(p0_cam, p0_2d) proj_ok_1 = lens.project(p1_cam, p1_2d) # 无论 project 返回值如何,都尝试计算屏幕方向 axis_screen_raw: Point2 = p1_2d - p0_2d axis_screen_len = axis_screen_raw.length() if axis_screen_len > 1e-6: axis_screen = axis_screen_raw / axis_screen_len else: # 只有当投影长度真的为0时才使用默认值 axis_screen = Point2(1, 0) self.drag_axis_screen = axis_screen self._axis_screen_len = axis_screen_len # 保存用于诊断 # 计算摄像机视线方向与轴的夹角(用于诊断) view_dir = node_pos_w - cam_pos if view_dir.length_squared() > 0: view_dir.normalize() axis_view_dot = abs(axis_dir.dot(view_dir)) self._debug_drag_counter = 0 self._highlight_axis(self.drag_axis) # 诊断打印 strer: list[str] = [] strer.append(f"[ScaleGizmo DEBUG] ========== AXIS PICK ==========") strer.append( f" axis_id: {self.drag_axis} ({'XYZ'[self.drag_axis]})") strer.append(f" axis_dir (world): {axis_dir}") strer.append(f" cam_pos: {cam_pos}") strer.append(f" node_pos: {node_pos_w}") strer.append(f" view_dir: {view_dir}") strer.append(f" axis_view_dot (|axis·view|): {axis_view_dot:.4f}") strer.append(f" p0_cam: {p0_cam}, p1_cam: {p1_cam}") strer.append(f" proj_ok: p0={proj_ok_0}, p1={proj_ok_1}") strer.append(f" p0_2d: {p0_2d}, p1_2d: {p1_2d}") strer.append( f" axis_screen_raw: ({axis_screen_raw.x:.4f}, {axis_screen_raw.y:.4f})") strer.append( f" axis_screen_len (before normalize): {axis_screen_len:.6f}") strer.append( f" axis_screen (normalized): ({axis_screen.x:.4f}, {axis_screen.y:.4f})") if axis_view_dot > 0.9: strer.append( f" WARNING: Axis nearly parallel to view direction!") if axis_screen_len < 0.01: strer.append(f" WARNING: Axis screen projection very short!") strer.append(f" ==========================================") self._log("\n".join(strer)) self._log( f"pick axis={self.drag_axis} target={self.target_node.getName()} " f"start_scale={self.start_scale} axis_dir={axis_dir}" ) self._emit_event( GizmoEvent.DRAG_START, { "gizmo": "scale", "target": self.target_node, "axis": self.drag_axis, "mouse": Point2(mpos.x, mpos.y), "start_scale": self.start_scale, }, ) else: self._log( "mouse_down: hit something but no gizmo_axis or gizmo_center tag") def _on_mouse_up(self, extra=None): if self.dragging and self.target_node is not None: try: final_scale = self.target_node.getScale() except Exception: final_scale = None old_scale = self.start_scale if old_scale is not None and final_scale is not None: self._record_scale_action( self.target_node, old_scale, final_scale) axis_id = self.drag_axis self._emit_event( GizmoEvent.DRAG_END, { "gizmo": "scale", "target": self.target_node, "axis": axis_id, "start_scale": old_scale, "final_scale": final_scale, }, ) self.dragging = False self.drag_axis = None self._reset_axis_visuals() self._reset_highlights() self._log("mouse_up -> stop scaling") def _on_mouse_move(self, extra=None): if not self.attached or not self.camera: return mpos = self._get_normalized_mouse(extra) if mpos is None: if self.dragging: self._log("mouse_move ignored: no mouse pos while scaling") return # Hover highlight when idle. if not self.dragging: self._update_hover_highlight(mpos) return if not self.target_node: return mouse_ndc = Point2(mpos.x, mpos.y) delta = mouse_ndc - self.start_mouse if self.drag_axis == 3: # 均匀缩放:基于鼠标在屏幕上的垂直移动 scale_factor = 1.0 + delta.y * 2.0 scale_factor = max(scale_factor, 0.01) # 防止负缩放 new_scale = self.start_scale * scale_factor self.target_node.setScale(new_scale) if self._debug_drag_counter % 4 == 0: self._log( f"uniform scale factor={scale_factor:.4f} " f"new_scale={new_scale}" ) self._debug_drag_counter += 1 self._emit_event( GizmoEvent.DRAG_MOVE, { "gizmo": "scale", "target": self.target_node, "axis": self.drag_axis, "mouse": Point2(mpos.x, mpos.y), "new_scale": new_scale, "scale_factor": scale_factor, }, ) self._update_axis_visuals_for_scale(new_scale) else: # 单轴缩放 s_scalar = delta.dot(self.drag_axis_screen) scale_factor = 1.0 + s_scalar * 2.0 scale_factor = max(scale_factor, 0.01) new_scale = Vec3(self.start_scale) if self.drag_axis == 0: new_scale.x = self.start_scale.x * scale_factor elif self.drag_axis == 1: new_scale.y = self.start_scale.y * scale_factor elif self.drag_axis == 2: new_scale.z = self.start_scale.z * scale_factor self.target_node.setScale(new_scale) # 诊断打印(每16帧打印一次,避免刷屏) # if self._debug_drag_counter % 16 == 0: # axis_screen_len = getattr(self, '_axis_screen_len', 0.0) # print(f"[ScaleGizmo DRAG] axis={self.drag_axis}({'XYZ'[self.drag_axis]}) " # f"delta=({delta.x:.4f}, {delta.y:.4f}) " # f"axis_screen=({self.drag_axis_screen.x:.4f}, {self.drag_axis_screen.y:.4f}) " # f"axis_screen_len={axis_screen_len:.4f} " # f"s_scalar={s_scalar:.4f} " # f"scale_factor={scale_factor:.4f}") self._debug_drag_counter += 1 self._emit_event( GizmoEvent.DRAG_MOVE, { "gizmo": "scale", "target": self.target_node, "axis": self.drag_axis, "mouse": Point2(mpos.x, mpos.y), "new_scale": new_scale, "scale_factor": scale_factor, }, ) self._update_axis_visuals_for_scale(new_scale) def _update_hover_highlight(self, mpos: Point3): """Highlight hovered handle during mouse move without dragging.""" self.picker_np.reparentTo(self.camera) if not self._picker_added: self.c_trav.addCollider(self.picker_np, self.c_queue) self._picker_added = True self.picker_ray.setFromLens(self.camera.node(), mpos.x, mpos.y) self.c_queue.clearEntries() self.c_trav.traverse(self.root) num_entries = self.c_queue.getNumEntries() if num_entries <= 0: self._reset_highlights() self.is_hovering = False return self.c_queue.sortEntries() axis_entry = None center_entry = None for i in range(num_entries): e = self.c_queue.getEntry(i) if center_entry is None and e.getIntoNode().getTag("gizmo_center"): center_entry = e if axis_entry is None and e.getIntoNode().getTag("gizmo_axis"): axis_entry = e if center_entry and axis_entry: break if center_entry is not None: self._highlight_center() self.is_hovering = True elif axis_entry is not None: axis_tag: str = axis_entry.getIntoNode().getTag("gizmo_axis") self._highlight_axis(int(axis_tag)) self.is_hovering = True else: self._reset_highlights() self.is_hovering = False def _highlight_axis(self, axis_id: Optional[int]): """高亮被选中的轴。""" for i, ax in enumerate(self.axes): if i == axis_id: ax.setColor(1, 1, 0, 1) ax.clearColorScale() else: ax.clearColor() ax.setAlphaScale(0.3) if self.center_handle: self.center_handle.setAlphaScale(0.3) def _highlight_center(self): """高亮中心控制柄。""" for ax in self.axes: ax.clearColor() ax.setAlphaScale(0.3) if self.center_handle: self.center_handle.setColor(1, 1, 0, 1) self.center_handle.clearColorScale() def _reset_highlights(self): """重置所有高亮。""" for ax in self.axes: ax.clearColor() ax.clearColorScale() ax.setAlphaScale(self.axis_alpha) if self.center_handle: self.center_handle.clearColor() self.center_handle.clearColorScale() self.center_handle.setAlphaScale(self.axis_alpha) # --- 可视轴动态长度 ------------------------------------------------- def _set_axis_visual_factor(self, axis_id: int, factor: float): """仅调整可视几何长度/末端大小,不改碰撞体。""" if axis_id < 0 or axis_id >= len(self.axis_visuals): return info = self.axis_visuals[axis_id] line = info.get("line") cube = info.get("cube") base_len = info.get("length", self._axis_length0) base_cube = info.get("cube_size", self._cube_size0) if line is None or cube is None: return factor = max(0.3, min(factor, 3.0)) line.setScale(1, 1, factor) cube.setZ(base_len * factor) cube.setScale(factor) def _reset_axis_visuals(self): """恢复所有轴的可视长度/端点大小。""" for i in range(len(self.axis_visuals)): self._set_axis_visual_factor(i, 1.0) def _update_axis_visuals_for_scale(self, new_scale: Vec3): """根据当前缩放比例动态调整可视轴长度。""" if self.start_scale is None: return def safe_ratio(a: float, b: float) -> float: return a / b if abs(b) > 1e-6 else 1.0 sx0, sy0, sz0 = self.start_scale sx1, sy1, sz1 = new_scale if self.drag_axis == 3: ratios = [] if abs(sx0) > 1e-6: ratios.append(sx1 / sx0) if abs(sy0) > 1e-6: ratios.append(sy1 / sy0) if abs(sz0) > 1e-6: ratios.append(sz1 / sz0) if not ratios: return factor = sum(ratios) / len(ratios) for i in range(3): self._set_axis_visual_factor(i, factor) elif self.drag_axis is not None and 0 <= self.drag_axis <= 2: ratio = [safe_ratio(sx1, sx0), safe_ratio(sy1, sy0), safe_ratio(sz1, sz0)] self._set_axis_visual_factor(self.drag_axis, ratio[self.drag_axis]) def update(self): """根据摄像机距离自动缩放 Gizmo。""" if self.world.mouseWatcherNode.has_mouse(): current_mouse = LPoint2f(self.world.mouseWatcherNode.get_mouse()) if current_mouse != self.last_mouse_pos or self.last_mouse_pos is None: self._on_mouse_move() self.last_mouse_pos = current_mouse if self.attached and self.camera: self._update_root_transform() dist = ( self.camera.getPos(self.world.render) - self.root.getPos(self.world.render) ).length() scale = dist * 0.15 self.root.setScale(self.world.render, scale) def _update_task(self, task: Task): """每帧调用,用于持续更新 Gizmo 的缩放。""" self.update() return Task.cont # ------------------------------------------------------------------ # # 内部:跟随目标更新根节点变换 # ------------------------------------------------------------------ # def _update_root_transform(self): if not self.attached or not self.target_node: return render = self.world.render tgt = self.target_node self.root.setPos(render, tgt.getPos(render)) if self.is_local: self.root.setQuat(render, tgt.getQuat(render)) else: self.root.setQuat(render, Quat.identQuat()) __all__ = ["ScaleGizmo"]