from direct.task.TaskManagerGlobal import taskMgr from panda3d.core import ( BitMask32, Camera, FrameBufferProperties, GraphicsOutput, GraphicsPipe, NodePath, Shader, Texture, Vec4, WindowProperties, ) class SelectionOutlineManager: """Selection mask manager feeding RenderPipeline SelectionOutlineStage.""" OUTLINE_PREFIX = "selectionOutline" def __init__( self, app, enabled=True, outline_color=Vec4(1.0, 0.55, 0.0, 1.0), outline_width_px=2.0, fill_alpha=0.0, max_targets=128, ): self.app = app self.enabled = bool(enabled) self.outline_color = Vec4(outline_color) self.outline_width_px = max(0.0, float(outline_width_px)) self.fill_alpha = max(0.0, min(1.0, float(fill_alpha))) self.max_targets = max(1, int(max_targets)) self._task_name = "selection_outline_mask_sync" self._tracked = [] # [(source_np, clone_np), ...] self._stage_missing_warned = False self._mask_root = NodePath(f"{self.OUTLINE_PREFIX}_mask_root") self._mask_buffer = None self._mask_texture = None self._mask_cam = None self._mask_cam_np = None self._mask_shader = self._build_mask_shader() self._buffer_size = (0, 0) @staticmethod def _is_empty(np): if not np: return True if hasattr(np, "isEmpty"): return np.isEmpty() if hasattr(np, "is_empty"): return np.is_empty() return False @classmethod def is_outline_node(cls, node_path): if not node_path or cls._is_empty(node_path): return False name = node_path.getName() if hasattr(node_path, "getName") else "" if name.startswith(cls.OUTLINE_PREFIX): return True try: if node_path.hasPythonTag("selection_outline"): return True except Exception: pass return False def set_enabled(self, enabled): self.enabled = bool(enabled) if not self.enabled: self.clear() self._apply_stage_inputs() def set_targets(self, targets): if not self.enabled: self.clear() self._apply_stage_inputs() return self._ensure_mask_resources() self.clear() if not targets: self._apply_stage_inputs() return seen = set() valid = [] for target in targets: if self._is_empty(target) or self.is_outline_node(target): continue key = str(target) if key in seen: continue seen.add(key) valid.append(target) if len(valid) >= self.max_targets: break for source in valid: self._clone_target(source) if self._tracked: self._start_sync_task() self._sync_once() print(f"[SelectionOutline] targets={len(self._tracked)} active") else: print("[SelectionOutline] no valid targets for outline") self._apply_stage_inputs() def clear(self): self._stop_sync_task() for _, clone_np in self._tracked: if not self._is_empty(clone_np): clone_np.removeNode() self._tracked = [] self._apply_stage_inputs() def cleanup(self): self.clear() self._destroy_mask_resources() def _build_mask_shader(self): return Shader.make( Shader.SL_GLSL, """ #version 430 in vec4 p3d_Vertex; uniform mat4 p3d_ModelViewProjectionMatrix; void main() { gl_Position = p3d_ModelViewProjectionMatrix * p3d_Vertex; } """, """ #version 430 out vec4 result; void main() { result = vec4(1.0, 1.0, 1.0, 1.0); } """, ) def _clone_target(self, source): try: clone_np = source.copyTo(self._mask_root) if self._is_empty(clone_np): return if not self._node_has_geom(clone_np): clone_np.removeNode() return clone_np.setName(f"{self.OUTLINE_PREFIX}_{source.getName()}") clone_np.setPythonTag("selection_outline", True) clone_np.setCollideMask(BitMask32.allOff()) clone_np.setMat(self.app.render, source.getMat(self.app.render)) self._tracked.append((source, clone_np)) except Exception as exc: print(f"[SelectionOutline] clone failed: {exc}") def _node_has_geom(self, np): if self._is_empty(np): return False try: node = np.node() if node and hasattr(node, "isGeomNode") and node.isGeomNode(): return True except Exception: pass try: return not np.find("**/+GeomNode").isEmpty() except Exception: return False def _start_sync_task(self): taskMgr.remove(self._task_name) taskMgr.add(self._sync_task, self._task_name) def _stop_sync_task(self): taskMgr.remove(self._task_name) def _sync_task(self, task): self._sync_once() return task.cont def _sync_once(self): if not self.enabled: self._apply_stage_inputs() return self._ensure_mask_resources() alive = [] for source, clone_np in self._tracked: if self._is_empty(source) or self._is_empty(clone_np): if not self._is_empty(clone_np): clone_np.removeNode() continue clone_np.setMat(self.app.render, source.getMat(self.app.render)) alive.append((source, clone_np)) self._tracked = alive self._apply_stage_inputs() def _get_stage(self): rp = getattr(self.app, "render_pipeline", None) if not rp or not getattr(rp, "stage_mgr", None): return None return rp.stage_mgr.get_stage("SelectionOutlineStage") def _apply_stage_inputs(self): stage = self._get_stage() if not stage: if not self._stage_missing_warned: print("[SelectionOutline] SelectionOutlineStage not found; plugin may be disabled.") self._stage_missing_warned = True return self._stage_missing_warned = False stage.set_outline_style( color=self.outline_color, width_px=self.outline_width_px, fill_alpha=self.fill_alpha, ) stage.set_mask_texture(self._mask_texture) stage.set_enabled_outline(self.enabled and bool(self._tracked)) def _get_window_size(self): if not getattr(self.app, "win", None): return 1, 1 return max(1, self.app.win.getXSize()), max(1, self.app.win.getYSize()) def _ensure_mask_resources(self): size = self._get_window_size() if size != self._buffer_size: self._destroy_mask_resources() self._buffer_size = size if self._mask_buffer: return if not getattr(self.app, "graphicsEngine", None) or not getattr(self.app, "win", None): return w, h = self._buffer_size win_props = WindowProperties() win_props.setSize(w, h) fb_props = FrameBufferProperties() fb_props.setRgbaBits(8, 8, 8, 8) fb_props.setDepthBits(24) self._mask_buffer = self.app.graphicsEngine.make_output( self.app.pipe, "selection_outline_mask", -80, fb_props, win_props, GraphicsPipe.BFRefuseWindow, self.app.win.getGsg(), self.app.win, ) if not self._mask_buffer: print("[SelectionOutline] failed to create mask buffer") return self._mask_texture = Texture("selection_outline_mask_tex") self._mask_texture.setMinfilter(Texture.FTNearest) self._mask_texture.setMagfilter(Texture.FTNearest) self._mask_buffer.addRenderTexture(self._mask_texture, GraphicsOutput.RTMBindOrCopy) self._mask_buffer.setClearColor(Vec4(0, 0, 0, 0)) self._mask_buffer.setClearColorActive(True) self._mask_buffer.setActive(True) self._mask_cam = Camera("selection_outline_mask_camera") self._mask_cam.setScene(self._mask_root) self._mask_cam.setLens(self.app.camLens) self._mask_cam_np = self.app.cam.attachNewNode(self._mask_cam) dr = self._mask_buffer.makeDisplayRegion() dr.setCamera(self._mask_cam_np) state_np = NodePath("selection_outline_mask_state") state_np.setShader(self._mask_shader, 10000) state_np.setLightOff(1) state_np.setMaterialOff(1) state_np.setTextureOff(1) state_np.setColorOff(1) self._mask_cam.setInitialState(state_np.getState()) def _destroy_mask_resources(self): if self._mask_cam_np and not self._is_empty(self._mask_cam_np): self._mask_cam_np.removeNode() self._mask_cam_np = None self._mask_cam = None if self._mask_buffer and getattr(self.app, "graphicsEngine", None): try: self.app.graphicsEngine.removeWindow(self._mask_buffer) except Exception: pass self._mask_buffer = None self._mask_texture = None