867 lines
35 KiB
Python
867 lines
35 KiB
Python
|
|
import sys
|
|
import os
|
|
import struct
|
|
import time
|
|
import types
|
|
from panda3d.core import (
|
|
Filename, loadPrcFileData, GeomVertexFormat,
|
|
GeomVertexWriter, InternalName, Shader, Texture, SamplerState,
|
|
Vec3, Vec4, Point2, Point3, LMatrix4f, ShaderBuffer, GeomEnums, OmniBoundingVolume,
|
|
TransparencyAttrib, BoundingSphere, NodePath,
|
|
GraphicsEngine, WindowProperties, FrameBufferProperties,
|
|
GraphicsPipe, GraphicsOutput, Camera, DisplayRegion, OrthographicLens,
|
|
BoundingBox, BitMask32
|
|
)
|
|
|
|
# p3dimgui.backend first tries `from shaders import *`, which can be shadowed by
|
|
# project folders named `shaders/` and leave VERT_SHADER/FRAG_SHADER undefined.
|
|
# Seed a valid fallback module before importing p3dimgui.
|
|
_shaders_mod = sys.modules.get("shaders")
|
|
if not (_shaders_mod and hasattr(_shaders_mod, "VERT_SHADER") and hasattr(_shaders_mod, "FRAG_SHADER")):
|
|
_shaders_mod = types.ModuleType("shaders")
|
|
_shaders_mod.FRAG_SHADER = """
|
|
#version 120
|
|
varying vec2 texcoord;
|
|
varying vec4 color;
|
|
uniform sampler2D p3d_Texture0;
|
|
void main() {
|
|
gl_FragColor = color * texture2D(p3d_Texture0, texcoord);
|
|
}
|
|
"""
|
|
_shaders_mod.VERT_SHADER = """
|
|
#version 120
|
|
attribute vec4 p3d_Vertex;
|
|
attribute vec4 p3d_Color;
|
|
varying vec2 texcoord;
|
|
varying vec4 color;
|
|
uniform mat4 p3d_ModelViewProjectionMatrix;
|
|
void main() {
|
|
texcoord = p3d_Vertex.zw;
|
|
color = p3d_Color.bgra;
|
|
gl_Position = p3d_ModelViewProjectionMatrix * vec4(p3d_Vertex.x, 0.0, -p3d_Vertex.y, 1.0);
|
|
}
|
|
"""
|
|
sys.modules["shaders"] = _shaders_mod
|
|
|
|
from p3dimgui import ImGuiBackend
|
|
from imgui_bundle import imgui
|
|
from rpcore.effect import Effect
|
|
|
|
from .ssbo_controller import ObjectController
|
|
from core.selection_outline import SelectionOutlineManager
|
|
|
|
class SSBOEditor:
|
|
"""
|
|
SSBO Editor Component
|
|
====================
|
|
Encapsulates the SSBO rendering, ImGui editor, and interaction logic.
|
|
Can be integrated into any ShowBase application using RenderPipeline.
|
|
"""
|
|
|
|
def __init__(self, base_app, render_pipeline, model_path=None, font_path=None):
|
|
self.base = base_app
|
|
self.rp = render_pipeline
|
|
self.controller = None
|
|
self.model = None
|
|
self.ssbo = None
|
|
self.font_path = font_path
|
|
self._transform_gizmo = None
|
|
self._ssbo_transform_active = False
|
|
self._ssbo_selected_local_indices = []
|
|
self._ssbo_gizmo_proxy = None
|
|
|
|
# Internal State
|
|
self.selected_name = None
|
|
self.selected_ids = []
|
|
self.search_text = ""
|
|
self.last_search_text = None
|
|
self.filtered_nodes = []
|
|
self.debug_mode = False
|
|
self.keys = {}
|
|
self.pick_mask = BitMask32.bit(29)
|
|
self.pick_buffer = None
|
|
self._empty_pick_scene = NodePath("ssbo_pick_empty")
|
|
# Avoid heavy per-frame sync for huge group selections.
|
|
self._pick_sync_bg_limit = 256
|
|
self._last_group_sync_mat = None
|
|
self._last_single_sync_gid = None
|
|
self._last_single_sync_mat = None
|
|
# Performance toggle: forcing shadow tasks every frame is expensive.
|
|
# Keep it off by default so frustum/content reduction has clearer FPS impact.
|
|
self.realtime_shadow_updates = False
|
|
self._scheduler_tasks_original = None
|
|
self._realtime_shadow_tasks_enabled = False
|
|
self._outline_manager = getattr(self.base, "_selection_outline_manager", None)
|
|
if self._outline_manager is None:
|
|
self._outline_manager = SelectionOutlineManager(self.base)
|
|
setattr(self.base, "_selection_outline_manager", self._outline_manager)
|
|
|
|
# Initialize ImGui Backend if not already present
|
|
if not hasattr(self.base, 'imgui_backend'):
|
|
print("[SSBOEditor] Initializing ImGui Backend...")
|
|
self.base.imgui_backend = ImGuiBackend()
|
|
|
|
self.load_font()
|
|
|
|
# Register Events
|
|
self.base.accept("imgui-new-frame", self.draw_imgui)
|
|
self.base.accept("f", self.focus_on_selected)
|
|
self.base.accept("d", self.toggle_debug)
|
|
self.base.accept("mouse1", self.on_mouse_click)
|
|
|
|
# Register Input Tasks
|
|
for key in ['arrow_up', 'arrow_down', 'arrow_left', 'arrow_right', 'z', 'x']:
|
|
self.base.accept(key, self.keys.__setitem__, [key, True])
|
|
self.base.accept(f"{key}-up", self.keys.__setitem__, [key, False])
|
|
|
|
# Add Tasks
|
|
self.base.taskMgr.add(self.update_task, "update_task")
|
|
|
|
# Load Model if provided
|
|
if model_path:
|
|
self.load_model(model_path)
|
|
|
|
def _capture_scheduler_tasks_snapshot(self):
|
|
scheduler = getattr(self.rp, "task_scheduler", None)
|
|
if not scheduler or not hasattr(scheduler, "_tasks"):
|
|
return
|
|
if self._scheduler_tasks_original is None:
|
|
self._scheduler_tasks_original = [list(frame_tasks) for frame_tasks in scheduler._tasks]
|
|
|
|
def _enable_realtime_shadow_tasks(self):
|
|
"""
|
|
Force PSSM-related scheduled tasks to run every frame to avoid visible
|
|
shadow lag/ghosting while editing moving objects.
|
|
"""
|
|
scheduler = getattr(self.rp, "task_scheduler", None)
|
|
if not scheduler or not hasattr(scheduler, "_tasks"):
|
|
return
|
|
|
|
self._capture_scheduler_tasks_snapshot()
|
|
|
|
required = {
|
|
"pssm_scene_shadows",
|
|
"pssm_distant_shadows",
|
|
"pssm_convert_distant_to_esm",
|
|
"pssm_blur_distant_vert",
|
|
"pssm_blur_distant_horiz",
|
|
}
|
|
changed = False
|
|
for frame_tasks in scheduler._tasks:
|
|
for task_name in required:
|
|
if task_name not in frame_tasks:
|
|
frame_tasks.append(task_name)
|
|
changed = True
|
|
|
|
if changed:
|
|
print("[SSBOEditor] Realtime shadow tasks enabled (PSSM updates every frame).")
|
|
self._realtime_shadow_tasks_enabled = True
|
|
|
|
def _disable_realtime_shadow_tasks(self):
|
|
"""Restore scheduler layout captured before realtime shadow override."""
|
|
scheduler = getattr(self.rp, "task_scheduler", None)
|
|
if not scheduler or not hasattr(scheduler, "_tasks"):
|
|
return
|
|
if self._scheduler_tasks_original is None:
|
|
self._realtime_shadow_tasks_enabled = False
|
|
return
|
|
scheduler._tasks[:] = [list(frame_tasks) for frame_tasks in self._scheduler_tasks_original]
|
|
self._realtime_shadow_tasks_enabled = False
|
|
|
|
def set_realtime_shadow_updates(self, enabled):
|
|
"""Public toggle for aggressive per-frame shadow updates."""
|
|
self.realtime_shadow_updates = bool(enabled)
|
|
if self.realtime_shadow_updates:
|
|
self._enable_realtime_shadow_tasks()
|
|
else:
|
|
self._disable_realtime_shadow_tasks()
|
|
|
|
def load_font(self):
|
|
"""Load custom font for ImGui"""
|
|
io = imgui.get_io()
|
|
|
|
# Load Chinese Glyph Ranges
|
|
glyph_ranges = None
|
|
try:
|
|
if hasattr(io.fonts, 'get_glyph_ranges_chinese_full'):
|
|
glyph_ranges = io.fonts.get_glyph_ranges_chinese_full()
|
|
elif hasattr(io.fonts, 'get_glyph_ranges_chinese_simplified_common'):
|
|
glyph_ranges = io.fonts.get_glyph_ranges_chinese_simplified_common()
|
|
except Exception as e:
|
|
print(f"[SSBOEditor] Warning: Could not get Chinese glyph ranges: {e}")
|
|
|
|
try:
|
|
if self.font_path and os.path.exists(self.font_path):
|
|
io.fonts.clear()
|
|
# If glyph_ranges is None, it uses default (Basic Latin)
|
|
if glyph_ranges:
|
|
io.fonts.add_font_from_file_ttf(self.font_path, 18.0, glyph_ranges=glyph_ranges)
|
|
else:
|
|
io.fonts.add_font_from_file_ttf(self.font_path, 18.0)
|
|
else:
|
|
# Fallback to default or common font
|
|
default_font = os.path.join(os.path.dirname(os.path.dirname(__file__)), "font", "msyh.ttc")
|
|
if os.path.exists(default_font):
|
|
io.fonts.clear()
|
|
io.fonts.add_font_from_file_ttf(default_font, 18.0, glyph_ranges=glyph_ranges)
|
|
else:
|
|
io.fonts.clear()
|
|
io.fonts.add_font_default()
|
|
except Exception as e:
|
|
print(f"[SSBOEditor] Font load error: {e}")
|
|
io.fonts.clear()
|
|
io.fonts.add_font_default()
|
|
|
|
def load_model(self, model_path):
|
|
"""Load and process a model using hybrid static/dynamic chunks."""
|
|
print(f"[SSBOEditor] Loading model: {model_path}")
|
|
fn = Filename.fromOsSpecific(model_path)
|
|
source_model = self.base.loader.loadModel(fn)
|
|
model_name = os.path.basename(model_path)
|
|
if model_name:
|
|
source_model.set_name(model_name)
|
|
|
|
self.controller = ObjectController()
|
|
count = self.controller.bake_ids_and_collect(source_model)
|
|
self.model = self.controller.model
|
|
|
|
self.model.reparent_to(self.base.render)
|
|
|
|
# Keep this off by default for better overall FPS/scaling with visibility.
|
|
self.set_realtime_shadow_updates(self.realtime_shadow_updates)
|
|
|
|
# NO rp.set_effect() — use RP default rendering for max FPS
|
|
# NO SSBO creation — vertex positions are baked
|
|
|
|
# Setup GPU Picking (uses simple vertex-color shader)
|
|
self.setup_gpu_picking()
|
|
# Keep pick clone aligned with source model transform.
|
|
self._sync_pick_model_transform()
|
|
|
|
print(f"[SSBOEditor] Model loaded. Total objects: {count}")
|
|
|
|
# No custom effect needed — RP default rendering for maximum FPS
|
|
|
|
def _inject_ssbo_into_shadow_state(self, effect_path):
|
|
"""Inject SSBO inputs into RP shadow tag state"""
|
|
try:
|
|
if not hasattr(self.rp.tag_mgr, 'containers'): return
|
|
|
|
shadow_container = self.rp.tag_mgr.containers.get("shadow")
|
|
if not shadow_container: return
|
|
|
|
tag_value = self.model.get_tag(shadow_container.tag_name)
|
|
if not tag_value: return
|
|
|
|
effect = Effect.load(effect_path, {})
|
|
if effect is None: return
|
|
|
|
shadow_shader = effect.get_shader_obj("shadow")
|
|
if shadow_shader is None: return
|
|
|
|
# Since inputs are now on Nodes (Chunks), we just need to ensure the shader is applied.
|
|
# extra_inputs is no longer needed if the inputs are on the nodes themselves?
|
|
# Wait, RP might override state.
|
|
# But specific shader inputs on NodePath have priority over State inputs usually?
|
|
# Let's try applying without extra inputs first.
|
|
|
|
self.rp.tag_mgr.apply_state(
|
|
"shadow", self.model, shadow_shader,
|
|
tag_value, 65)
|
|
|
|
print(f"[SSBO Shadow] Re-applied shadow state (tag='{tag_value}')")
|
|
except Exception as e:
|
|
print(f"[SSBO Shadow] Error injecting shadow state: {e}")
|
|
|
|
def setup_gpu_picking(self):
|
|
"""Setup GPU Picking (Basic implementation)"""
|
|
# ... (Buffer setup code remains same) ...
|
|
win_props = WindowProperties()
|
|
win_props.set_size(1, 1)
|
|
fb_props = FrameBufferProperties()
|
|
fb_props.set_rgba_bits(8, 8, 8, 8)
|
|
fb_props.set_depth_bits(16)
|
|
|
|
self.pick_buffer = self.base.graphicsEngine.make_output(
|
|
self.base.pipe, "pick_buffer", -100,
|
|
fb_props, win_props,
|
|
GraphicsPipe.BF_refuse_window,
|
|
self.base.win.get_gsg(), self.base.win
|
|
)
|
|
|
|
if not self.pick_buffer:
|
|
print("[GPU Picking] Failed to create buffer!")
|
|
return
|
|
|
|
self.pick_texture = Texture()
|
|
self.pick_texture.set_minfilter(Texture.FT_nearest)
|
|
self.pick_texture.set_magfilter(Texture.FT_nearest)
|
|
self.pick_buffer.add_render_texture(self.pick_texture, GraphicsOutput.RTM_copy_ram)
|
|
|
|
self.pick_cam = Camera("pick_camera")
|
|
self.pick_cam.set_camera_mask(self.pick_mask)
|
|
self.pick_cam_np = self.base.cam.attach_new_node(self.pick_cam)
|
|
self.pick_lens = self.base.camLens.make_copy()
|
|
self.pick_cam.set_lens(self.pick_lens)
|
|
|
|
dr = self.pick_buffer.make_display_region()
|
|
dr.set_camera(self.pick_cam_np)
|
|
|
|
# Load pick shader
|
|
current_dir = os.path.dirname(os.path.abspath(__file__))
|
|
pick_vert_path = os.path.join(current_dir, "shaders", "pick_id.vert")
|
|
pick_frag_path = os.path.join(current_dir, "shaders", "pick_id.frag")
|
|
|
|
try:
|
|
# Read shader source directly from OS filesystem to avoid
|
|
# Panda3D VFS case-mismatch issues on Windows.
|
|
with open(pick_vert_path, 'r', encoding='utf-8') as f:
|
|
vert_src = f.read().replace('\r', '')
|
|
with open(pick_frag_path, 'r', encoding='utf-8') as f:
|
|
frag_src = f.read().replace('\r', '')
|
|
pick_shader = Shader.make(Shader.SL_GLSL, vert_src, frag_src)
|
|
pick_scene = getattr(self.controller, "pick_model", None) or self.model
|
|
if pick_scene and not pick_scene.is_empty():
|
|
pick_scene.show(self.pick_mask)
|
|
self.pick_cam.set_scene(pick_scene or self._empty_pick_scene)
|
|
initial_state = NodePath("initial")
|
|
initial_state.set_shader(pick_shader, 100)
|
|
# Remove global SSBO input, Chunks have their own inputs
|
|
# initial_state.set_shader_input("transforms", ssbo)
|
|
self.pick_cam.set_initial_state(initial_state.get_state())
|
|
except Exception as e:
|
|
print(f"[GPU Picking] Warning: pick shaders failed to load: {e}")
|
|
print("Picking disabled.")
|
|
return
|
|
|
|
self.pick_buffer.set_active(False)
|
|
self.pick_buffer.set_clear_color(Vec4(0, 0, 0, 0))
|
|
self.pick_buffer.set_clear_color_active(True)
|
|
|
|
def _sync_pick_model_transform(self):
|
|
"""Sync pick-scene clone to current source model world transform."""
|
|
if not self.controller or not self.model:
|
|
return
|
|
pick_model = getattr(self.controller, "pick_model", None)
|
|
if pick_model is None:
|
|
return
|
|
try:
|
|
if pick_model.isEmpty():
|
|
return
|
|
except Exception:
|
|
try:
|
|
if pick_model.is_empty():
|
|
return
|
|
except Exception:
|
|
pass
|
|
try:
|
|
if hasattr(self.controller, "get_model_world_mat"):
|
|
world_mat = self.controller.get_model_world_mat()
|
|
else:
|
|
world_mat = LMatrix4f(self.model.getNetTransform().getMat())
|
|
try:
|
|
pick_model.set_mat(world_mat)
|
|
except Exception:
|
|
pick_model.setMat(world_mat)
|
|
except Exception:
|
|
pass
|
|
|
|
def _refresh_ssbo_proxy_center(self):
|
|
"""Update proxy center when source model transform changes."""
|
|
if self._ssbo_transform_active:
|
|
return
|
|
if not self.controller or not self._ssbo_selected_local_indices:
|
|
return
|
|
if self._ssbo_gizmo_proxy is None:
|
|
return
|
|
try:
|
|
if self._ssbo_gizmo_proxy.isEmpty():
|
|
return
|
|
except Exception:
|
|
return
|
|
try:
|
|
center = self.controller.get_selection_center(self._ssbo_selected_local_indices)
|
|
self._ssbo_gizmo_proxy.set_pos(center)
|
|
except Exception:
|
|
pass
|
|
|
|
def _is_model_attached(self):
|
|
"""Whether the SSBO render root is still attached to scene graph."""
|
|
if not self.model or self.model.is_empty():
|
|
return False
|
|
parent = self.model.get_parent()
|
|
return bool(parent) and not parent.is_empty()
|
|
|
|
def _sync_pick_scene_binding(self):
|
|
"""Switch pick camera scene based on current model attachment state."""
|
|
if not hasattr(self, "pick_cam") or not self.pick_cam:
|
|
return
|
|
|
|
if self._is_model_attached() and self.controller:
|
|
target_scene = getattr(self.controller, "pick_model", None) or self.model
|
|
else:
|
|
target_scene = self._empty_pick_scene
|
|
|
|
if not target_scene:
|
|
target_scene = self._empty_pick_scene
|
|
|
|
if self.pick_cam.get_scene() != target_scene:
|
|
self.pick_cam.set_scene(target_scene)
|
|
|
|
def pick_object(self, mx, my):
|
|
if (not self.pick_buffer or not self.pick_texture or not self.pick_lens or
|
|
not self.controller or not self.model):
|
|
return False
|
|
self._sync_pick_model_transform()
|
|
|
|
self.pick_lens.set_fov(0.1)
|
|
self.pick_lens.set_film_offset(0, 0)
|
|
self.pick_cam.set_lens(self.pick_lens)
|
|
|
|
near_point = Point3()
|
|
far_point = Point3()
|
|
self.base.camLens.extrude(Point2(mx, my), near_point, far_point)
|
|
|
|
self.pick_cam_np.set_pos(0, 0, 0)
|
|
self.pick_cam_np.look_at(far_point)
|
|
|
|
# Ensure pick transforms are up-to-date before rendering the pick buffer.
|
|
# The per-frame sync task may not have run yet for this frame.
|
|
self._sync_pick_transforms()
|
|
|
|
self.pick_buffer.set_active(True)
|
|
self.base.graphicsEngine.render_frame()
|
|
self.pick_buffer.set_active(False)
|
|
|
|
ram_image = self.pick_texture.get_ram_image_as("RGBA")
|
|
if ram_image:
|
|
data = memoryview(ram_image)
|
|
if len(data) >= 4:
|
|
r, g, b, a = data[0], data[1], data[2], data[3]
|
|
if a > 0 and b == 0:
|
|
hit_id = r + (g << 8)
|
|
node_key = self.controller.id_to_name.get(hit_id)
|
|
if node_key:
|
|
print(f"[Pick] Hit: ID={hit_id} -> {node_key}")
|
|
self.select_node(node_key)
|
|
return
|
|
|
|
self.clear_selection()
|
|
|
|
|
|
def on_mouse_click(self):
|
|
io = imgui.get_io()
|
|
if io.want_capture_mouse: return
|
|
# Skip SSBO picking when user is interacting with the TransformGizmo,
|
|
# otherwise pick_object would clear the selection and detach the gizmo
|
|
# before the gizmo's own mouse handler fires.
|
|
if self._transform_gizmo and self._transform_gizmo.is_hovering:
|
|
return
|
|
if self.base.mouseWatcherNode.has_mouse():
|
|
self._sync_pick_model_transform()
|
|
self._refresh_ssbo_proxy_center()
|
|
mpos = self.base.mouseWatcherNode.get_mouse()
|
|
self.pick_object(mpos.x, mpos.y)
|
|
|
|
def toggle_debug(self):
|
|
self.debug_mode = not self.debug_mode
|
|
|
|
def bind_transform_gizmo(self, gizmo):
|
|
"""Bind a TransformGizmo so it follows SSBO selection."""
|
|
self._transform_gizmo = gizmo
|
|
|
|
def _start_pick_sync_task(self):
|
|
"""Start a per-frame task that syncs pick transforms for selected objects."""
|
|
self.base.task_mgr.remove("ssbo_pick_sync")
|
|
self.base.task_mgr.add(self._pick_sync_task, "ssbo_pick_sync")
|
|
|
|
def _stop_pick_sync_task(self):
|
|
"""Stop the per-frame pick sync task."""
|
|
self.base.task_mgr.remove("ssbo_pick_sync")
|
|
|
|
def _pick_sync_task(self, task):
|
|
"""Per-frame: keep pick model transforms in sync with render model."""
|
|
self._sync_pick_transforms()
|
|
return task.cont
|
|
|
|
def _reset_pick_sync_cache(self):
|
|
self._last_group_sync_mat = None
|
|
self._last_single_sync_gid = None
|
|
self._last_single_sync_mat = None
|
|
|
|
def _matrices_close(self, a, b, eps=1e-5):
|
|
"""Small helper for robust matrix change detection."""
|
|
for r in range(4):
|
|
ra = a.get_row(r)
|
|
rb = b.get_row(r)
|
|
if (abs(ra[0] - rb[0]) > eps or
|
|
abs(ra[1] - rb[1]) > eps or
|
|
abs(ra[2] - rb[2]) > eps or
|
|
abs(ra[3] - rb[3]) > eps):
|
|
return False
|
|
return True
|
|
|
|
def _sync_pick_root_transform(self):
|
|
"""
|
|
Keep pick root aligned with the render root transform.
|
|
This covers transforms applied to the whole imported model
|
|
(for example, moving box.glb from scene hierarchy).
|
|
"""
|
|
if not self.controller or not self.model or not self._is_model_attached():
|
|
return
|
|
|
|
pick_root = getattr(self.controller, "pick_model", None)
|
|
if not pick_root:
|
|
return
|
|
|
|
if self.model.is_empty() or pick_root.is_empty():
|
|
return
|
|
|
|
pick_root.set_mat(self.base.render, self.model.get_mat(self.base.render))
|
|
|
|
def _sync_pick_transforms(self):
|
|
"""Sync pick model transforms to match render model transforms."""
|
|
if not self.controller:
|
|
return
|
|
self._sync_pick_root_transform()
|
|
if not self.selected_ids:
|
|
return
|
|
|
|
# Group selection can contain thousands of objects.
|
|
# Only resync when proxy transform has changed.
|
|
proxy = getattr(self, "_group_proxy", None)
|
|
if proxy and not proxy.is_empty() and len(self.selected_ids) > 1:
|
|
proxy_world = proxy.get_mat(self.base.render)
|
|
if self._last_group_sync_mat and self._matrices_close(proxy_world, self._last_group_sync_mat):
|
|
return
|
|
self._last_group_sync_mat = LMatrix4f(proxy_world)
|
|
else:
|
|
self._last_group_sync_mat = None
|
|
|
|
if len(self.selected_ids) == 1:
|
|
gid = self.selected_ids[0]
|
|
obj_np = self.controller.id_to_object_np.get(gid)
|
|
pick_np = self.controller.id_to_pick_np.get(gid)
|
|
if not (obj_np and pick_np and not obj_np.is_empty() and not pick_np.is_empty()):
|
|
return
|
|
obj_world_mat = obj_np.get_mat(self.base.render)
|
|
if (
|
|
self._last_single_sync_gid == gid and
|
|
self._last_single_sync_mat and
|
|
self._matrices_close(obj_world_mat, self._last_single_sync_mat)
|
|
):
|
|
return
|
|
self._last_single_sync_gid = gid
|
|
self._last_single_sync_mat = LMatrix4f(obj_world_mat)
|
|
pick_world_mat = pick_np.get_mat(self.base.render)
|
|
if not self._matrices_close(obj_world_mat, pick_world_mat):
|
|
pick_np.set_mat(self.base.render, obj_world_mat)
|
|
chunk_id = self.controller.id_to_chunk.get(gid)
|
|
if chunk_id is not None and chunk_id in self.controller.chunks:
|
|
self.controller.chunks[chunk_id]["dirty"] = True
|
|
return
|
|
|
|
self._last_single_sync_gid = None
|
|
self._last_single_sync_mat = None
|
|
for gid in self.selected_ids:
|
|
obj_np = self.controller.id_to_object_np.get(gid)
|
|
pick_np = self.controller.id_to_pick_np.get(gid)
|
|
if obj_np and pick_np and not obj_np.is_empty() and not pick_np.is_empty():
|
|
obj_world_mat = obj_np.get_mat(self.base.render)
|
|
pick_world_mat = pick_np.get_mat(self.base.render)
|
|
if not self._matrices_close(obj_world_mat, pick_world_mat):
|
|
# Sync by world transform so this stays correct even when
|
|
# the model root itself has been moved in scene hierarchy.
|
|
pick_np.set_mat(self.base.render, obj_world_mat)
|
|
chunk_id = self.controller.id_to_chunk.get(gid)
|
|
if chunk_id is not None and chunk_id in self.controller.chunks:
|
|
self.controller.chunks[chunk_id]["dirty"] = True
|
|
|
|
def _update_outline_for_selection(self):
|
|
if not self._outline_manager:
|
|
return
|
|
if not self.controller or not self.selected_ids:
|
|
self._outline_manager.clear()
|
|
return
|
|
|
|
is_root_selection = (
|
|
self.controller and
|
|
self.selected_name == getattr(self.controller, "tree_root_key", None)
|
|
)
|
|
if is_root_selection:
|
|
self._outline_manager.clear()
|
|
return
|
|
|
|
targets = []
|
|
target_limit = max(1, int(getattr(self._outline_manager, "max_targets", 64)))
|
|
for gid in self.selected_ids:
|
|
obj_np = self.controller.id_to_object_np.get(gid)
|
|
if obj_np and not obj_np.is_empty():
|
|
targets.append(obj_np)
|
|
if len(targets) >= target_limit:
|
|
break
|
|
|
|
self._outline_manager.set_targets(targets)
|
|
|
|
def clear_selection(self):
|
|
self._stop_pick_sync_task()
|
|
self._reset_pick_sync_cache()
|
|
self._cleanup_group_proxy()
|
|
self.selected_name = None
|
|
self.selected_ids = []
|
|
if self._outline_manager:
|
|
self._outline_manager.clear()
|
|
if self.controller:
|
|
self.controller.set_active_ids([])
|
|
if self._transform_gizmo:
|
|
self._transform_gizmo.detach()
|
|
|
|
def on_model_deleted(self, deleted_node):
|
|
"""Called by app deletion flow when SSBO root model is deleted."""
|
|
if not deleted_node or deleted_node.is_empty() or not self.model:
|
|
return
|
|
if deleted_node != self.model:
|
|
return
|
|
self.clear_selection()
|
|
self._sync_pick_scene_binding()
|
|
|
|
def _cleanup_group_proxy(self):
|
|
"""Reparent objects back to their chunk and remove the group proxy."""
|
|
proxy = getattr(self, '_group_proxy', None)
|
|
if not proxy:
|
|
return
|
|
originals = getattr(self, '_group_original_parents', {})
|
|
# Sync pick transforms and mark chunks dirty before reparenting
|
|
for gid in originals:
|
|
obj_np = self.controller.id_to_object_np.get(gid)
|
|
pick_np = self.controller.id_to_pick_np.get(gid)
|
|
if obj_np and pick_np and not obj_np.is_empty() and not pick_np.is_empty():
|
|
pick_np.set_mat(self.base.render, obj_np.get_mat(self.base.render))
|
|
chunk_id = self.controller.id_to_chunk.get(gid)
|
|
if chunk_id is not None and chunk_id in self.controller.chunks:
|
|
self.controller.chunks[chunk_id]["dirty"] = True
|
|
# Reparent objects back to their original chunk parents
|
|
for gid, parent_np in originals.items():
|
|
obj_np = self.controller.id_to_object_np.get(gid)
|
|
if obj_np and not obj_np.is_empty() and parent_np and not parent_np.is_empty():
|
|
obj_np.wrt_reparent_to(parent_np)
|
|
if not proxy.is_empty():
|
|
proxy.remove_node()
|
|
self._group_proxy = None
|
|
self._group_original_parents = {}
|
|
|
|
def update_selection_mask(self):
|
|
pass # No selection mask texture needed without custom shader
|
|
|
|
def select_node(self, key):
|
|
# Clean up previous group proxy before changing selection
|
|
self._cleanup_group_proxy()
|
|
self._reset_pick_sync_cache()
|
|
|
|
self.selected_name = key
|
|
self.selected_ids = self.controller.name_to_ids.get(key, [])
|
|
is_root_selection = (
|
|
self.controller and
|
|
key == getattr(self.controller, "tree_root_key", None)
|
|
)
|
|
|
|
# Root selection should stay lightweight:
|
|
# keep static chunks active and transform the model root directly.
|
|
if is_root_selection:
|
|
self.controller.set_active_ids([])
|
|
if self._outline_manager:
|
|
self._outline_manager.clear()
|
|
if self._transform_gizmo and self.model and not self.model.is_empty():
|
|
self._transform_gizmo.attach(self.model)
|
|
else:
|
|
if self._transform_gizmo:
|
|
self._transform_gizmo.detach()
|
|
self._stop_pick_sync_task()
|
|
return
|
|
|
|
self.controller.set_active_ids(self.selected_ids)
|
|
self._update_outline_for_selection()
|
|
|
|
if not self._transform_gizmo or not self.selected_ids:
|
|
if self._transform_gizmo:
|
|
self._transform_gizmo.detach()
|
|
return
|
|
|
|
if len(self.selected_ids) == 1:
|
|
# Single object: attach gizmo directly
|
|
obj_np = self.controller.id_to_object_np.get(self.selected_ids[0])
|
|
if obj_np and not obj_np.is_empty():
|
|
self._transform_gizmo.attach(obj_np)
|
|
self._start_pick_sync_task()
|
|
return
|
|
|
|
# Multiple objects (parent node): create a group proxy so all children
|
|
# follow the gizmo transform together.
|
|
from panda3d.core import Vec3
|
|
proxy = self.base.render.attach_new_node("ssbo_group_proxy")
|
|
center = Vec3(0, 0, 0)
|
|
valid = []
|
|
for gid in self.selected_ids:
|
|
obj_np = self.controller.id_to_object_np.get(gid)
|
|
if obj_np and not obj_np.is_empty():
|
|
center += obj_np.get_pos(self.base.render)
|
|
valid.append(gid)
|
|
if not valid:
|
|
proxy.remove_node()
|
|
return
|
|
|
|
center /= len(valid)
|
|
proxy.set_pos(self.base.render, center)
|
|
|
|
self._group_proxy = proxy
|
|
self._group_original_parents = {}
|
|
for gid in valid:
|
|
obj_np = self.controller.id_to_object_np[gid]
|
|
self._group_original_parents[gid] = obj_np.get_parent()
|
|
obj_np.wrt_reparent_to(proxy)
|
|
|
|
self._transform_gizmo.attach(proxy)
|
|
# For huge groups, avoid per-frame full sync; we still sync on demand
|
|
# right before picking via pick_object().
|
|
if len(valid) <= self._pick_sync_bg_limit:
|
|
self._start_pick_sync_task()
|
|
else:
|
|
self._stop_pick_sync_task()
|
|
|
|
def _rebuild_filtered_tree_rows(self):
|
|
"""
|
|
Build a flattened tree-row list with depth info for rendering in ImGui,
|
|
while preserving source-model parent/child hierarchy.
|
|
"""
|
|
self.filtered_nodes = []
|
|
if not self.controller or not self.controller.tree_root_key:
|
|
return
|
|
|
|
search_lower = self.search_text.strip().lower()
|
|
|
|
def walk(key, depth):
|
|
node = self.controller.tree_nodes.get(key)
|
|
if not node:
|
|
return False, []
|
|
|
|
# Skip redundant wrapper nodes (e.g. ROOT under model file root),
|
|
# while preserving child hierarchy and selection mapping.
|
|
if self.controller.should_hide_tree_node(key):
|
|
merged_rows = []
|
|
merged_visible = False
|
|
for child_key in node["children"]:
|
|
visible, rows = walk(child_key, depth)
|
|
if visible:
|
|
merged_visible = True
|
|
merged_rows.extend(rows)
|
|
return merged_visible, merged_rows
|
|
|
|
display = self.controller.display_names.get(key, key)
|
|
obj_count = len(self.controller.name_to_ids.get(key, []))
|
|
name_match = (not search_lower) or (search_lower in display.lower())
|
|
|
|
child_rows = []
|
|
child_match = False
|
|
for child_key in node["children"]:
|
|
visible, rows = walk(child_key, depth + 1)
|
|
if visible:
|
|
child_match = True
|
|
child_rows.extend(rows)
|
|
|
|
visible = (not search_lower) or name_match or child_match
|
|
if not visible:
|
|
return False, []
|
|
|
|
row = (key, depth, display, obj_count)
|
|
return True, [row] + child_rows
|
|
|
|
_, rows = walk(self.controller.tree_root_key, 0)
|
|
self.filtered_nodes = rows
|
|
|
|
def focus_on_selected(self):
|
|
if self.selected_name and self.selected_ids:
|
|
first_id = self.selected_ids[0]
|
|
pos = self.controller.get_world_pos(first_id)
|
|
dist = 100
|
|
self.base.camera.set_pos(pos.x, pos.y - dist, pos.z + dist * 0.5)
|
|
self.base.camera.look_at(pos)
|
|
|
|
def draw_imgui(self):
|
|
if not self.controller: return
|
|
|
|
imgui.set_next_window_pos((10, 10), imgui.Cond_.first_use_ever)
|
|
imgui.set_next_window_size((350, 600), imgui.Cond_.first_use_ever)
|
|
|
|
expanded, opened = imgui.begin("Scene Tree (Component)")
|
|
if expanded:
|
|
imgui.text(f"FPS: {globalClock.getAverageFrameRate():.1f}")
|
|
imgui.separator()
|
|
|
|
changed, self.search_text = imgui.input_text("Search", self.search_text, 256)
|
|
|
|
if imgui.begin_child("ObjectList", (0, 380), child_flags=imgui.ChildFlags_.borders):
|
|
if self.search_text != self.last_search_text or not self.filtered_nodes:
|
|
self.last_search_text = self.search_text
|
|
self._rebuild_filtered_tree_rows()
|
|
|
|
count = len(self.filtered_nodes)
|
|
clipper = imgui.ListClipper()
|
|
clipper.begin(count)
|
|
while clipper.step():
|
|
for i in range(clipper.display_start, clipper.display_end):
|
|
key, depth, display, geom_count = self.filtered_nodes[i]
|
|
indent = " " * depth
|
|
label = f"{indent}{display} ({geom_count})##{key}"
|
|
is_selected = (key == self.selected_name)
|
|
if imgui.selectable(label, is_selected)[0]:
|
|
self.select_node(key)
|
|
imgui.end_child()
|
|
|
|
imgui.separator()
|
|
if self.selected_name:
|
|
selected_display = self.controller.display_names.get(self.selected_name, self.selected_name)
|
|
imgui.text_colored((1, 0.8, 0.2, 1), f"Selected: {selected_display}")
|
|
if imgui.button("Focus (F)"): self.focus_on_selected()
|
|
imgui.end()
|
|
|
|
# swap_transforms_task removed - motion blur disabled for performance
|
|
|
|
def update_task(self, task):
|
|
dt = globalClock.getDt()
|
|
io = imgui.get_io()
|
|
self._sync_pick_model_transform()
|
|
self._refresh_ssbo_proxy_center()
|
|
self._sync_pick_scene_binding()
|
|
# Scene-hierarchy transforms may move the whole SSBO model root; keep pick root in sync.
|
|
self._sync_pick_root_transform()
|
|
|
|
if io.want_capture_keyboard: return task.cont
|
|
|
|
if self.selected_ids and self.controller:
|
|
speed = 50 * dt
|
|
acc = Vec3(0, 0, 0)
|
|
if self.keys.get('arrow_up'): acc.z += speed
|
|
if self.keys.get('arrow_down'): acc.z -= speed
|
|
if self.keys.get('arrow_left'): acc.x -= speed
|
|
if self.keys.get('arrow_right'): acc.x += speed
|
|
if self.keys.get('z'): acc.y += speed
|
|
if self.keys.get('x'): acc.y -= speed
|
|
|
|
if acc.length_squared() > 0:
|
|
is_root_selection = (
|
|
self.selected_name == getattr(self.controller, "tree_root_key", None)
|
|
)
|
|
if is_root_selection and self.model and not self.model.is_empty():
|
|
next_pos = self.model.get_pos() + acc
|
|
if hasattr(self.model, "set_fluid_pos"):
|
|
self.model.set_fluid_pos(next_pos)
|
|
else:
|
|
self.model.set_pos(next_pos)
|
|
self._sync_pick_root_transform()
|
|
else:
|
|
for idx in self.selected_ids:
|
|
self.controller.move_object(idx, acc)
|
|
|
|
return task.cont
|