1810 lines
71 KiB
Python
1810 lines
71 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, Material, MaterialAttrib, ColorAttrib, TextureAttrib, PNMImage
|
|
)
|
|
|
|
# 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.source_model = None
|
|
self.source_model_root = None
|
|
self.last_import_tree_key = None
|
|
self.last_import_root_name = 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 _fixBlackMaterials(self, model):
|
|
try:
|
|
from panda3d.core import MaterialAttrib, Material, GeomNode
|
|
|
|
for geom_path in model.findAllMatches('**/+GeomNode'):
|
|
geom_node = geom_path.node()
|
|
|
|
node_state = geom_path.getState()
|
|
if node_state.hasAttrib(MaterialAttrib.getClassType()):
|
|
mat_attrib = node_state.getAttrib(MaterialAttrib.getClassType())
|
|
mat = mat_attrib.getMaterial()
|
|
if mat:
|
|
is_black = False
|
|
if mat.hasBaseColor():
|
|
c = mat.getBaseColor()
|
|
if c.x <= 0.05 and c.y <= 0.05 and c.z <= 0.05:
|
|
is_black = True
|
|
elif mat.hasDiffuse():
|
|
c = mat.getDiffuse()
|
|
if c.x <= 0.05 and c.y <= 0.05 and c.z <= 0.05:
|
|
is_black = True
|
|
|
|
if is_black or not (mat.hasBaseColor() or mat.hasDiffuse()):
|
|
new_mat = Material(mat)
|
|
new_mat.setBaseColor((0.8, 0.8, 0.8, 1.0))
|
|
new_mat.setDiffuse((0.8, 0.8, 0.8, 1.0))
|
|
geom_path.setState(node_state.setAttrib(MaterialAttrib.make(new_mat)))
|
|
|
|
for i in range(geom_node.getNumGeoms()):
|
|
geom_state = geom_node.getGeomState(i)
|
|
if geom_state.hasAttrib(MaterialAttrib.getClassType()):
|
|
mat_attrib = geom_state.getAttrib(MaterialAttrib.getClassType())
|
|
mat = mat_attrib.getMaterial()
|
|
if mat:
|
|
is_black = False
|
|
if mat.hasBaseColor():
|
|
c = mat.getBaseColor()
|
|
if c.x <= 0.05 and c.y <= 0.05 and c.z <= 0.05:
|
|
is_black = True
|
|
elif mat.hasDiffuse():
|
|
c = mat.getDiffuse()
|
|
if c.x <= 0.05 and c.y <= 0.05 and c.z <= 0.05:
|
|
is_black = True
|
|
|
|
if is_black or not (mat.hasBaseColor() or mat.hasDiffuse()):
|
|
new_mat = Material(mat)
|
|
new_mat.setBaseColor((0.8, 0.8, 0.8, 1.0))
|
|
new_mat.setDiffuse((0.8, 0.8, 0.8, 1.0))
|
|
geom_node.setGeomState(i, geom_state.setAttrib(MaterialAttrib.make(new_mat)))
|
|
else:
|
|
new_mat = Material()
|
|
new_mat.setBaseColor((0.8, 0.8, 0.8, 1.0))
|
|
new_mat.setDiffuse((0.8, 0.8, 0.8, 1.0))
|
|
new_mat.setSpecular((0.2, 0.2, 0.2, 1.0))
|
|
new_mat.setRoughness(0.8)
|
|
geom_node.setGeomState(i, geom_state.addAttrib(MaterialAttrib.make(new_mat)))
|
|
|
|
model.clearColor()
|
|
|
|
except Exception as e:
|
|
print(f'修复黑色模型材质时出错: {e}')
|
|
|
|
def _load_source_model_from_path(self, model_path):
|
|
"""Load a source model NodePath from disk without touching current runtime state."""
|
|
source_model = None
|
|
last_error = None
|
|
for fn in self._build_filename_candidates(model_path):
|
|
try:
|
|
source_model = self.base.loader.loadModel(fn)
|
|
if source_model and not source_model.is_empty():
|
|
break
|
|
except Exception as e:
|
|
last_error = e
|
|
source_model = None
|
|
if not source_model or source_model.is_empty():
|
|
if last_error:
|
|
raise RuntimeError(f"Failed to load model '{model_path}': {last_error}")
|
|
raise RuntimeError(f"Failed to load model '{model_path}'")
|
|
self._fixBlackMaterials(source_model)
|
|
self._repair_missing_textures(source_model, model_path)
|
|
return source_model
|
|
|
|
def _set_node_name(self, node, name):
|
|
if not node:
|
|
return
|
|
try:
|
|
node.set_name(name)
|
|
return
|
|
except Exception:
|
|
pass
|
|
try:
|
|
node.setName(name)
|
|
except Exception:
|
|
pass
|
|
|
|
def _iter_children(self, node):
|
|
if not node:
|
|
return []
|
|
try:
|
|
return list(node.get_children())
|
|
except Exception:
|
|
try:
|
|
return list(node.getChildren())
|
|
except Exception:
|
|
return []
|
|
|
|
def _ensure_source_model_root(self):
|
|
root = self.source_model_root
|
|
if root:
|
|
try:
|
|
if not root.is_empty():
|
|
return root
|
|
except Exception:
|
|
try:
|
|
if not root.isEmpty():
|
|
return root
|
|
except Exception:
|
|
pass
|
|
self.source_model_root = NodePath("ssbo_source_scene_root")
|
|
return self.source_model_root
|
|
|
|
def _make_unique_source_child_name(self, desired_name):
|
|
root = self._ensure_source_model_root()
|
|
existing_names = set()
|
|
for child in self._iter_children(root):
|
|
try:
|
|
existing_names.add(child.get_name())
|
|
except Exception:
|
|
try:
|
|
existing_names.add(child.getName())
|
|
except Exception:
|
|
continue
|
|
|
|
base_name = desired_name or "imported_model"
|
|
if base_name not in existing_names:
|
|
return base_name
|
|
|
|
stem, ext = os.path.splitext(base_name)
|
|
index = 2
|
|
while True:
|
|
candidate = f"{stem}_{index}{ext}"
|
|
if candidate not in existing_names:
|
|
return candidate
|
|
index += 1
|
|
|
|
def _get_top_level_group_keys(self):
|
|
if not self.controller or not getattr(self.controller, "tree_root_key", None):
|
|
return []
|
|
root_key = self.controller.tree_root_key
|
|
root_node = self.controller.tree_nodes.get(root_key)
|
|
if not root_node:
|
|
return []
|
|
return list(root_node.get("children", []))
|
|
|
|
def _snapshot_top_level_transforms_to_source_root(self):
|
|
"""Persist current top-level imported model transforms back into the source scene root."""
|
|
if not self.controller or not self.model or not self.source_model_root:
|
|
return
|
|
|
|
source_children = {}
|
|
for child in self._iter_children(self.source_model_root):
|
|
try:
|
|
source_children[child.get_name()] = child
|
|
except Exception:
|
|
try:
|
|
source_children[child.getName()] = child
|
|
except Exception:
|
|
continue
|
|
|
|
for key in self._get_top_level_group_keys():
|
|
display_name = self.controller.display_names.get(key, key)
|
|
source_child = source_children.get(display_name)
|
|
if not source_child:
|
|
continue
|
|
|
|
group_ids = self.controller.name_to_ids.get(key, [])
|
|
if not group_ids:
|
|
continue
|
|
|
|
representative_id = None
|
|
for gid in group_ids:
|
|
obj_np = self.controller.id_to_object_np.get(gid)
|
|
if obj_np and not obj_np.is_empty():
|
|
representative_id = gid
|
|
break
|
|
if representative_id is None:
|
|
continue
|
|
|
|
try:
|
|
current_mat = LMatrix4f(self.controller.id_to_object_np[representative_id].get_mat(self.model))
|
|
except Exception:
|
|
try:
|
|
current_mat = LMatrix4f(self.controller.id_to_object_np[representative_id].getMat(self.model))
|
|
except Exception:
|
|
continue
|
|
|
|
if representative_id >= len(self.controller.global_transforms):
|
|
continue
|
|
original_mat = LMatrix4f(self.controller.global_transforms[representative_id])
|
|
inv_original = LMatrix4f(original_mat)
|
|
try:
|
|
inv_original.invertInPlace()
|
|
except Exception:
|
|
try:
|
|
inv_original.invert_in_place()
|
|
except Exception:
|
|
continue
|
|
|
|
delta_mat = current_mat * inv_original
|
|
try:
|
|
source_child.set_mat(delta_mat * source_child.get_mat())
|
|
except Exception:
|
|
try:
|
|
source_child.setMat(delta_mat * source_child.getMat())
|
|
except Exception:
|
|
continue
|
|
|
|
def _clear_runtime_state(self, preserve_source_models=False):
|
|
"""Remove runtime SSBO controller/model state while optionally keeping source snapshots."""
|
|
self.clear_selection()
|
|
self._cleanup_group_proxy()
|
|
self._reset_pick_sync_cache()
|
|
|
|
controller = self.controller
|
|
pick_model = getattr(controller, "pick_model", None) if controller else None
|
|
model = self.model
|
|
|
|
removable_nodes = [pick_model, model]
|
|
if not preserve_source_models:
|
|
removable_nodes.extend([self.source_model, self.source_model_root])
|
|
|
|
for node in removable_nodes:
|
|
if not node:
|
|
continue
|
|
try:
|
|
if not node.is_empty():
|
|
node.remove_node()
|
|
except Exception:
|
|
try:
|
|
if not node.isEmpty():
|
|
node.removeNode()
|
|
except Exception:
|
|
pass
|
|
|
|
self.controller = None
|
|
self.model = None
|
|
self.selected_name = None
|
|
self.selected_ids = []
|
|
self.last_import_tree_key = None
|
|
self.last_import_root_name = None
|
|
if not preserve_source_models:
|
|
self.source_model = None
|
|
self.source_model_root = None
|
|
self._sync_pick_scene_binding()
|
|
|
|
def _rebuild_runtime_from_source_root(self, highlight_root_name=None):
|
|
root = self._ensure_source_model_root()
|
|
working_holder = NodePath("ssbo_source_scene_work")
|
|
working_root = root.copy_to(working_holder)
|
|
|
|
self.controller = ObjectController()
|
|
count = self.controller.bake_ids_and_collect(working_root)
|
|
self.model = self.controller.model
|
|
self.model.reparent_to(self.base.render)
|
|
|
|
self.set_realtime_shadow_updates(self.realtime_shadow_updates)
|
|
self.setup_gpu_picking()
|
|
self._sync_pick_model_transform()
|
|
|
|
self.last_import_tree_key = None
|
|
self.last_import_root_name = highlight_root_name
|
|
if highlight_root_name:
|
|
root_key = getattr(self.controller, "tree_root_key", None)
|
|
root_node = self.controller.tree_nodes.get(root_key, {}) if root_key else {}
|
|
for child_key in root_node.get("children", []):
|
|
if self.controller.display_names.get(child_key) == highlight_root_name:
|
|
self.last_import_tree_key = child_key
|
|
break
|
|
|
|
try:
|
|
if not working_holder.is_empty():
|
|
working_holder.remove_node()
|
|
except Exception:
|
|
try:
|
|
if not working_holder.isEmpty():
|
|
working_holder.removeNode()
|
|
except Exception:
|
|
pass
|
|
|
|
print(f"[SSBOEditor] Model loaded. Total objects: {count}")
|
|
|
|
def load_model(self, model_path, keep_source_model=False, append=False):
|
|
"""Load and process one model into the aggregated SSBO scene."""
|
|
print(f"[SSBOEditor] Loading model: {model_path}")
|
|
source_model = self._load_source_model_from_path(model_path)
|
|
model_name = os.path.basename(model_path)
|
|
if model_name:
|
|
self._set_node_name(source_model, model_name)
|
|
|
|
if append and self.source_model_root:
|
|
if self.controller and self.model:
|
|
self._snapshot_top_level_transforms_to_source_root()
|
|
self._clear_runtime_state(preserve_source_models=True)
|
|
else:
|
|
self._clear_runtime_state(preserve_source_models=False)
|
|
|
|
source_root = self._ensure_source_model_root()
|
|
unique_root_name = self._make_unique_source_child_name(model_name or "imported_model")
|
|
self._set_node_name(source_model, unique_root_name)
|
|
imported_root = source_model.copyTo(source_root)
|
|
self._set_node_name(imported_root, unique_root_name)
|
|
|
|
if keep_source_model and not append:
|
|
self.source_model = imported_root
|
|
else:
|
|
self.source_model = source_root
|
|
|
|
self._rebuild_runtime_from_source_root(highlight_root_name=unique_root_name)
|
|
|
|
def _build_filename_candidates(self, path_text):
|
|
"""Build Filename candidates with wide-char first for Windows CJK paths."""
|
|
candidates = []
|
|
seen = set()
|
|
for ctor_name in ("fromOsSpecificW", "from_os_specific_w", "fromOsSpecific", "from_os_specific"):
|
|
ctor = getattr(Filename, ctor_name, None)
|
|
if not ctor:
|
|
continue
|
|
try:
|
|
fn = ctor(path_text)
|
|
key = fn.get_fullpath()
|
|
if key in seen:
|
|
continue
|
|
seen.add(key)
|
|
candidates.append(fn)
|
|
except Exception:
|
|
continue
|
|
if not candidates:
|
|
try:
|
|
candidates.append(Filename(path_text))
|
|
except Exception:
|
|
pass
|
|
return candidates
|
|
|
|
def _load_texture_from_path(self, texture_path):
|
|
"""Load texture with robust path constructors."""
|
|
for fn in self._build_filename_candidates(texture_path):
|
|
try:
|
|
tex = self.base.loader.loadTexture(fn)
|
|
if tex:
|
|
return tex
|
|
except Exception:
|
|
continue
|
|
return None
|
|
|
|
def _build_texture_search_dirs(self, model_path):
|
|
"""Build candidate directories for missing texture recovery."""
|
|
dirs = []
|
|
model_dir = os.path.dirname(os.path.abspath(model_path))
|
|
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
|
|
def add_dir(path):
|
|
if not path:
|
|
return
|
|
path = os.path.normpath(path)
|
|
if path in dirs:
|
|
return
|
|
if os.path.isdir(path):
|
|
dirs.append(path)
|
|
|
|
add_dir(model_dir)
|
|
|
|
try:
|
|
if os.path.isdir(model_dir):
|
|
for item in os.listdir(model_dir):
|
|
if item.lower().endswith('.fbm'):
|
|
fbm_dir = os.path.join(model_dir, item)
|
|
if os.path.isdir(fbm_dir):
|
|
add_dir(fbm_dir)
|
|
for sub in ("textures", "texture", "tex", "assets", "materials"):
|
|
add_dir(os.path.join(fbm_dir, sub))
|
|
except Exception:
|
|
pass
|
|
|
|
for sub in ("textures", "texture", "tex", "assets", "materials"):
|
|
add_dir(os.path.join(model_dir, sub))
|
|
|
|
parent = model_dir
|
|
for _ in range(2):
|
|
parent = os.path.dirname(parent)
|
|
if not parent:
|
|
break
|
|
for sub in ("textures", "texture", "tex", "assets", "materials"):
|
|
add_dir(os.path.join(parent, sub))
|
|
|
|
add_dir(os.path.join(project_root, "Resources"))
|
|
add_dir(os.path.join(project_root, "Resources", "textures"))
|
|
add_dir(os.path.join(project_root, "Resources", "materials"))
|
|
add_dir(os.path.join(project_root, "Resources", "models"))
|
|
return dirs
|
|
|
|
def _index_texture_files(self, dirs, limit=30000):
|
|
"""Index texture files by basename for fast lookup."""
|
|
texture_exts = {".png", ".jpg", ".jpeg", ".tga", ".bmp", ".dds", ".ktx", ".ktx2", ".webp"}
|
|
index = {}
|
|
scanned = 0
|
|
for root_dir in dirs:
|
|
try:
|
|
for root, _, files in os.walk(root_dir):
|
|
for filename in files:
|
|
ext = os.path.splitext(filename)[1].lower()
|
|
if ext not in texture_exts:
|
|
continue
|
|
key = filename.lower()
|
|
if key not in index:
|
|
index[key] = os.path.join(root, filename)
|
|
scanned += 1
|
|
if scanned >= limit:
|
|
return index
|
|
except Exception:
|
|
continue
|
|
return index
|
|
|
|
def _repair_missing_textures(self, model_np, model_path):
|
|
"""
|
|
Repair broken texture paths by basename search; if unresolved, clear that
|
|
missing texture binding to avoid black PBR sampling.
|
|
"""
|
|
if not model_np or model_np.is_empty():
|
|
return
|
|
|
|
search_dirs = self._build_texture_search_dirs(model_path)
|
|
texture_index = self._index_texture_files(search_dirs)
|
|
|
|
fixed = 0
|
|
cleared = 0
|
|
white_tex = self._get_white_fallback_texture()
|
|
nodes = [model_np] + list(model_np.find_all_matches("**"))
|
|
for node in nodes:
|
|
if not node or node.is_empty():
|
|
continue
|
|
|
|
try:
|
|
stages = node.find_all_texture_stages()
|
|
except Exception:
|
|
try:
|
|
stages = node.findAllTextureStages()
|
|
except Exception:
|
|
continue
|
|
|
|
try:
|
|
stage_count = stages.get_num_texture_stages()
|
|
stage_at = stages.get_texture_stage
|
|
except Exception:
|
|
try:
|
|
stage_count = stages.getNumTextureStages()
|
|
stage_at = stages.getTextureStage
|
|
except Exception:
|
|
continue
|
|
|
|
for i in range(stage_count):
|
|
stage = stage_at(i)
|
|
if not stage:
|
|
continue
|
|
|
|
try:
|
|
tex = node.get_texture(stage)
|
|
except Exception:
|
|
tex = node.getTexture(stage)
|
|
if not tex:
|
|
continue
|
|
|
|
if self._texture_is_valid(tex):
|
|
continue
|
|
|
|
basename = self._extract_texture_basename(tex)
|
|
if not basename:
|
|
continue
|
|
|
|
replacement = texture_index.get(basename.lower())
|
|
if replacement:
|
|
try:
|
|
new_tex = self._load_texture_from_path(replacement)
|
|
if new_tex:
|
|
try:
|
|
node.set_texture(stage, new_tex, 1)
|
|
except Exception:
|
|
node.setTexture(stage, new_tex, 1)
|
|
fixed += 1
|
|
continue
|
|
except Exception:
|
|
pass
|
|
|
|
# Missing texture with no replacement: CLEAR the texture binding.
|
|
# Do NOT use a white fallback texture, because if this is a Metallic or Roughness
|
|
# map, pure white will force Metallic=1.0 and Roughness=1.0, which turns models black!
|
|
try:
|
|
try:
|
|
node.clear_texture(stage)
|
|
except Exception:
|
|
node.clearTexture(stage)
|
|
cleared += 1
|
|
except Exception:
|
|
pass
|
|
|
|
# GeomState-level texture bindings (common in imported FBX/GLTF):
|
|
# inspect and apply the same fallback on this GeomNode path.
|
|
try:
|
|
gnode = node.node()
|
|
get_num_geoms = getattr(gnode, "get_num_geoms", None) or getattr(gnode, "getNumGeoms", None)
|
|
get_geom_state = getattr(gnode, "get_geom_state", None) or getattr(gnode, "getGeomState", None)
|
|
geom_count = int(get_num_geoms()) if callable(get_num_geoms) else 0
|
|
except Exception:
|
|
geom_count = 0
|
|
|
|
for gi in range(geom_count):
|
|
try:
|
|
state = get_geom_state(gi)
|
|
except Exception:
|
|
continue
|
|
if state is None:
|
|
continue
|
|
|
|
tattr = None
|
|
try:
|
|
if state.has_attrib(TextureAttrib.get_class_type()):
|
|
tattr = state.get_attrib(TextureAttrib.get_class_type())
|
|
except Exception:
|
|
try:
|
|
if state.hasAttrib(TextureAttrib.getClassType()):
|
|
tattr = state.getAttrib(TextureAttrib.getClassType())
|
|
except Exception:
|
|
tattr = None
|
|
if not tattr:
|
|
continue
|
|
|
|
try:
|
|
num_on = tattr.get_num_on_stages()
|
|
get_stage = tattr.get_on_stage
|
|
get_tex = tattr.get_on_texture
|
|
except Exception:
|
|
try:
|
|
num_on = tattr.getNumOnStages()
|
|
get_stage = tattr.getOnStage
|
|
get_tex = tattr.getOnTexture
|
|
except Exception:
|
|
continue
|
|
|
|
for si in range(int(num_on)):
|
|
try:
|
|
stage = get_stage(si)
|
|
tex = get_tex(stage)
|
|
except Exception:
|
|
continue
|
|
if not stage or not tex:
|
|
continue
|
|
if self._texture_is_valid(tex):
|
|
continue
|
|
|
|
basename = self._extract_texture_basename(tex)
|
|
replacement = texture_index.get(basename.lower()) if basename else None
|
|
if replacement:
|
|
try:
|
|
new_tex = self._load_texture_from_path(replacement)
|
|
if new_tex:
|
|
try:
|
|
node.set_texture(stage, new_tex, 100000)
|
|
except Exception:
|
|
node.setTexture(stage, new_tex, 100000)
|
|
fixed += 1
|
|
continue
|
|
except Exception:
|
|
pass
|
|
|
|
try:
|
|
try:
|
|
node.clear_texture(stage)
|
|
except Exception:
|
|
node.clearTexture(stage)
|
|
cleared += 1
|
|
except Exception:
|
|
pass
|
|
|
|
if fixed or cleared:
|
|
print(f"[SSBOEditor] Texture repair: fixed={fixed}, cleared_missing={cleared}")
|
|
self._apply_nonblack_material_fallback(model_np)
|
|
|
|
def _texture_is_valid(self, tex):
|
|
if not tex:
|
|
return False
|
|
tex_path = self._extract_texture_os_path(tex)
|
|
if tex_path and os.path.exists(tex_path):
|
|
return True
|
|
if tex_path:
|
|
# Some valid textures use Panda VFS virtual paths; keep them RAM-checkable.
|
|
path_norm = tex_path.replace("\\", "/")
|
|
if path_norm.startswith("/$$") or path_norm.startswith("$$"):
|
|
pass
|
|
else:
|
|
# File-backed texture with missing source path should be considered invalid,
|
|
# even when Panda keeps a tiny fallback image in RAM.
|
|
return False
|
|
try:
|
|
if tex.has_ram_image():
|
|
return tex.get_ram_image_size() > 0
|
|
except Exception:
|
|
try:
|
|
if tex.hasRamImage():
|
|
return tex.getRamImageSize() > 0
|
|
except Exception:
|
|
pass
|
|
return False
|
|
|
|
def _extract_texture_os_path(self, tex):
|
|
tex_path = ""
|
|
try:
|
|
if tex.has_fullpath():
|
|
fullpath = tex.get_fullpath()
|
|
try:
|
|
tex_path = fullpath.to_os_specific()
|
|
except Exception:
|
|
try:
|
|
tex_path = fullpath.toOsSpecific()
|
|
except Exception:
|
|
tex_path = str(fullpath)
|
|
except Exception:
|
|
try:
|
|
if tex.hasFullpath():
|
|
fullpath = tex.getFullpath()
|
|
try:
|
|
tex_path = fullpath.toOsSpecific()
|
|
except Exception:
|
|
tex_path = str(fullpath)
|
|
except Exception:
|
|
tex_path = ""
|
|
|
|
tex_path = str(tex_path or "").strip()
|
|
if not tex_path:
|
|
return ""
|
|
|
|
tex_path = os.path.normpath(tex_path)
|
|
if os.path.exists(tex_path):
|
|
return tex_path
|
|
|
|
# Convert Panda internal drive path (/d/foo/bar) to Windows path if needed.
|
|
if len(tex_path) >= 3 and tex_path[0] in ("/", "\\") and tex_path[1].isalpha() and tex_path[2] in ("/", "\\"):
|
|
drive_path = f"{tex_path[1]}:{tex_path[2:]}"
|
|
drive_path = os.path.normpath(drive_path)
|
|
if os.path.exists(drive_path):
|
|
return drive_path
|
|
return tex_path
|
|
|
|
def _extract_texture_basename(self, tex):
|
|
tex_path = self._extract_texture_os_path(tex)
|
|
if tex_path:
|
|
return os.path.basename(tex_path.replace("\\", "/"))
|
|
try:
|
|
name = tex.get_name()
|
|
except Exception:
|
|
try:
|
|
name = tex.getName()
|
|
except Exception:
|
|
name = ""
|
|
return os.path.basename(str(name).replace("\\", "/"))
|
|
|
|
def _get_white_fallback_texture(self):
|
|
tex = getattr(self, "_ssbo_white_fallback_tex", None)
|
|
if tex:
|
|
return tex
|
|
try:
|
|
img = PNMImage(2, 2, 4)
|
|
img.fill(1.0, 1.0, 1.0)
|
|
img.alpha_fill(1.0)
|
|
tex = Texture("ssbo_white_fallback")
|
|
tex.load(img)
|
|
tex.set_minfilter(Texture.FT_nearest)
|
|
tex.set_magfilter(Texture.FT_nearest)
|
|
self._ssbo_white_fallback_tex = tex
|
|
return tex
|
|
except Exception:
|
|
return None
|
|
|
|
def _node_has_valid_texture(self, node):
|
|
if not node or node.is_empty():
|
|
return False
|
|
try:
|
|
stages = node.find_all_texture_stages()
|
|
stage_count = stages.get_num_texture_stages()
|
|
stage_at = stages.get_texture_stage
|
|
except Exception:
|
|
try:
|
|
stages = node.findAllTextureStages()
|
|
stage_count = stages.getNumTextureStages()
|
|
stage_at = stages.getTextureStage
|
|
except Exception:
|
|
return False
|
|
for i in range(stage_count):
|
|
stage = stage_at(i)
|
|
if not stage:
|
|
continue
|
|
try:
|
|
tex = node.get_texture(stage)
|
|
except Exception:
|
|
tex = node.getTexture(stage)
|
|
if self._texture_is_valid(tex):
|
|
return True
|
|
|
|
# GeomState TextureAttrib bindings can hold the effective textures.
|
|
try:
|
|
gnode = node.node()
|
|
get_num_geoms = getattr(gnode, "get_num_geoms", None) or getattr(gnode, "getNumGeoms", None)
|
|
get_geom_state = getattr(gnode, "get_geom_state", None) or getattr(gnode, "getGeomState", None)
|
|
geom_count = int(get_num_geoms()) if callable(get_num_geoms) else 0
|
|
except Exception:
|
|
geom_count = 0
|
|
|
|
for gi in range(geom_count):
|
|
try:
|
|
state = get_geom_state(gi)
|
|
except Exception:
|
|
continue
|
|
if state is None:
|
|
continue
|
|
|
|
tattr = None
|
|
try:
|
|
if state.has_attrib(TextureAttrib.get_class_type()):
|
|
tattr = state.get_attrib(TextureAttrib.get_class_type())
|
|
except Exception:
|
|
try:
|
|
if state.hasAttrib(TextureAttrib.getClassType()):
|
|
tattr = state.getAttrib(TextureAttrib.getClassType())
|
|
except Exception:
|
|
tattr = None
|
|
if not tattr:
|
|
continue
|
|
|
|
try:
|
|
num_on = tattr.get_num_on_stages()
|
|
get_stage = tattr.get_on_stage
|
|
get_tex = tattr.get_on_texture
|
|
except Exception:
|
|
try:
|
|
num_on = tattr.getNumOnStages()
|
|
get_stage = tattr.getOnStage
|
|
get_tex = tattr.getOnTexture
|
|
except Exception:
|
|
continue
|
|
|
|
for si in range(int(num_on)):
|
|
try:
|
|
stage = get_stage(si)
|
|
tex = get_tex(stage)
|
|
except Exception:
|
|
continue
|
|
if self._texture_is_valid(tex):
|
|
return True
|
|
return False
|
|
|
|
def _is_node_material_dark(self, node):
|
|
"""Heuristic: detect near-black material/color state."""
|
|
if not node or node.is_empty():
|
|
return False
|
|
try:
|
|
state = node.get_state()
|
|
except Exception:
|
|
try:
|
|
state = node.getState()
|
|
except Exception:
|
|
return True
|
|
|
|
def _vec_dark(v):
|
|
try:
|
|
return max(float(v[0]), float(v[1]), float(v[2])) < 0.08
|
|
except Exception:
|
|
try:
|
|
return max(float(v.x), float(v.y), float(v.z)) < 0.08
|
|
except Exception:
|
|
return False
|
|
|
|
# Material color
|
|
mat = None
|
|
try:
|
|
if state.has_attrib(MaterialAttrib.get_class_type()):
|
|
mat_attr = state.get_attrib(MaterialAttrib.get_class_type())
|
|
mat = mat_attr.get_material()
|
|
except Exception:
|
|
try:
|
|
if state.hasAttrib(MaterialAttrib.getClassType()):
|
|
mat_attr = state.getAttrib(MaterialAttrib.getClassType())
|
|
mat = mat_attr.getMaterial()
|
|
except Exception:
|
|
mat = None
|
|
|
|
if mat:
|
|
for has_name, get_name in (
|
|
("has_base_color", "get_base_color"),
|
|
("has_diffuse", "get_diffuse"),
|
|
("hasBaseColor", "getBaseColor"),
|
|
("hasDiffuse", "getDiffuse"),
|
|
):
|
|
has_fn = getattr(mat, has_name, None)
|
|
get_fn = getattr(mat, get_name, None)
|
|
if callable(has_fn) and callable(get_fn):
|
|
try:
|
|
if has_fn():
|
|
return _vec_dark(get_fn())
|
|
except Exception:
|
|
continue
|
|
return False
|
|
|
|
# ColorAttrib fallback
|
|
try:
|
|
if state.has_attrib(ColorAttrib.get_class_type()):
|
|
color_attr = state.get_attrib(ColorAttrib.get_class_type())
|
|
if not color_attr.is_off():
|
|
return _vec_dark(color_attr.get_color())
|
|
except Exception:
|
|
try:
|
|
if state.hasAttrib(ColorAttrib.getClassType()):
|
|
color_attr = state.getAttrib(ColorAttrib.getClassType())
|
|
if not color_attr.isOff():
|
|
return _vec_dark(color_attr.getColor())
|
|
except Exception:
|
|
pass
|
|
return True
|
|
|
|
def _apply_nonblack_material_fallback(self, model_np):
|
|
"""If a geom has no texture and is effectively black, apply a neutral material."""
|
|
if not model_np or model_np.is_empty():
|
|
return
|
|
neutral = Material()
|
|
neutral.set_base_color((0.75, 0.75, 0.75, 1.0)) if hasattr(neutral, "set_base_color") else neutral.setBaseColor((0.75, 0.75, 0.75, 1.0))
|
|
neutral.set_diffuse((0.75, 0.75, 0.75, 1.0)) if hasattr(neutral, "set_diffuse") else neutral.setDiffuse((0.75, 0.75, 0.75, 1.0))
|
|
neutral.set_ambient((0.22, 0.22, 0.22, 1.0)) if hasattr(neutral, "set_ambient") else neutral.setAmbient((0.22, 0.22, 0.22, 1.0))
|
|
neutral.set_specular((0.1, 0.1, 0.1, 1.0)) if hasattr(neutral, "set_specular") else neutral.setSpecular((0.1, 0.1, 0.1, 1.0))
|
|
neutral.set_shininess(8.0) if hasattr(neutral, "set_shininess") else neutral.setShininess(8.0)
|
|
|
|
patched = 0
|
|
for geom_np in model_np.find_all_matches("**/+GeomNode"):
|
|
if self._node_has_valid_texture(geom_np):
|
|
continue
|
|
if not self._is_node_material_dark(geom_np):
|
|
continue
|
|
try:
|
|
try:
|
|
geom_np.set_material(neutral, 1)
|
|
except Exception:
|
|
geom_np.setMaterial(neutral, 1)
|
|
try:
|
|
geom_np.set_color_scale(1.0, 1.0, 1.0, 1.0)
|
|
except Exception:
|
|
geom_np.setColorScale(1.0, 1.0, 1.0, 1.0)
|
|
patched += 1
|
|
except Exception:
|
|
continue
|
|
if patched:
|
|
print(f"[SSBOEditor] Applied non-black fallback material to {patched} geom nodes.")
|
|
|
|
# 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 _node_is_valid(self, node):
|
|
if not node:
|
|
return False
|
|
try:
|
|
return not node.is_empty()
|
|
except Exception:
|
|
try:
|
|
return not node.isEmpty()
|
|
except Exception:
|
|
return False
|
|
|
|
def has_active_selection(self):
|
|
return bool(self.controller and self.selected_name is not None)
|
|
|
|
def _is_root_selection(self):
|
|
return bool(
|
|
self.controller and
|
|
self.selected_name == getattr(self.controller, "tree_root_key", None)
|
|
)
|
|
|
|
def get_selection_scene_node(self):
|
|
"""Return a stable scene node for editor features that need one."""
|
|
if not self.controller or self.selected_name is None:
|
|
return None
|
|
|
|
if self._is_root_selection():
|
|
return self.model if self._node_is_valid(self.model) else None
|
|
|
|
if len(self.selected_ids) == 1:
|
|
obj_np = self.controller.id_to_object_np.get(self.selected_ids[0])
|
|
if self._node_is_valid(obj_np):
|
|
return obj_np
|
|
|
|
return None
|
|
|
|
def get_selection_summary(self):
|
|
if not self.controller or self.selected_name is None:
|
|
return None
|
|
return {
|
|
"key": self.selected_name,
|
|
"display_name": self.controller.display_names.get(self.selected_name, self.selected_name),
|
|
"object_count": len(self.selected_ids),
|
|
"is_root": self._is_root_selection(),
|
|
"is_group": len(self.selected_ids) > 1 and not self._is_root_selection(),
|
|
}
|
|
|
|
def _sync_editor_selection_reference(self, node):
|
|
selection = getattr(self.base, "selection", None)
|
|
if not selection:
|
|
return
|
|
selection.selectedNode = node
|
|
selection.selectedObject = node
|
|
|
|
def _clear_editor_selection_visuals(self):
|
|
selection = getattr(self.base, "selection", None)
|
|
if not selection:
|
|
return
|
|
try:
|
|
selection.clearSelectionBox()
|
|
except Exception:
|
|
pass
|
|
try:
|
|
selection._updateSelectionOutline(None)
|
|
except Exception:
|
|
pass
|
|
try:
|
|
selection.clearGizmo()
|
|
except Exception:
|
|
pass
|
|
|
|
def _find_tree_key_for_scene_node(self, node):
|
|
if not self.controller or not self._node_is_valid(node):
|
|
return None
|
|
if self.model and node == self.model:
|
|
return getattr(self.controller, "tree_root_key", None)
|
|
for gid, obj_np in self.controller.id_to_object_np.items():
|
|
if obj_np == node:
|
|
return self.controller.id_to_name.get(gid)
|
|
return None
|
|
|
|
def sync_scene_selection(self, node):
|
|
"""Mirror scene-tree selection back into SSBO state, or clear stale SSBO state."""
|
|
if not self.controller:
|
|
return
|
|
|
|
target_key = self._find_tree_key_for_scene_node(node)
|
|
if target_key:
|
|
self.select_node(target_key, sync_world_selection=False)
|
|
return
|
|
|
|
if self.has_active_selection():
|
|
self.clear_selection(sync_world_selection=False)
|
|
|
|
def clear_selection(self, sync_world_selection=True):
|
|
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()
|
|
if sync_world_selection:
|
|
self._clear_editor_selection_visuals()
|
|
self._sync_editor_selection_reference(None)
|
|
|
|
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 reset_scene_state(self):
|
|
"""Remove the current SSBO model/controller state before loading another scene."""
|
|
self._clear_runtime_state(preserve_source_models=False)
|
|
|
|
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, sync_world_selection=True):
|
|
# 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)
|
|
)
|
|
if sync_world_selection:
|
|
self._clear_editor_selection_visuals()
|
|
self._sync_editor_selection_reference(self.get_selection_scene_node())
|
|
|
|
# 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")
|
|
try:
|
|
proxy.set_name(self.controller.display_names.get(key, "ssbo_group_proxy"))
|
|
proxy.setTag("is_ssbo_proxy", "1")
|
|
proxy.setTag("ssbo_selection_key", str(key))
|
|
except Exception:
|
|
pass
|
|
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
|