EG/core/selection_outline.py

304 lines
9.3 KiB
Python

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