304 lines
9.3 KiB
Python
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
|