3966 lines
160 KiB
Python
3966 lines
160 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,
|
||
KeyboardButton
|
||
)
|
||
|
||
# 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._source_child_base_mats = {}
|
||
self._runtime_owner_base_mats = {}
|
||
self._model_root_last_snapshot_mat = LMatrix4f.ident_mat()
|
||
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.transform_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
|
||
self._group_proxy_initial_mat = None
|
||
self._group_proxy_source_initial_net_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 _get_model_file_size(self, model_path):
|
||
try:
|
||
return int(os.path.getsize(model_path))
|
||
except Exception:
|
||
return 0
|
||
|
||
def _should_skip_heavy_material_processing(self, *, model_path=None, geom_count=0, node_count=0):
|
||
file_size = self._get_model_file_size(model_path) if model_path else 0
|
||
if file_size >= 200 * 1024 * 1024:
|
||
return True
|
||
if geom_count >= 2048:
|
||
return True
|
||
if node_count >= 4096:
|
||
return True
|
||
return False
|
||
|
||
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, apply_black_fix=True, repair_textures=True):
|
||
"""Load a source model NodePath from disk without touching current runtime state."""
|
||
source_model = None
|
||
last_error = None
|
||
load_path = model_path
|
||
try:
|
||
from scene.gltf_support import ensure_gltf_visual_bam, probe_gltf_metadata
|
||
|
||
gltf_meta = probe_gltf_metadata(model_path)
|
||
if gltf_meta.get("is_gltf"):
|
||
has_anim = gltf_meta.get("has_animations", False)
|
||
if has_anim:
|
||
project_manager = getattr(self.base, "project_manager", None)
|
||
project_root = getattr(project_manager, "current_project_path", "") if project_manager else ""
|
||
cached_visual_path = ensure_gltf_visual_bam(
|
||
model_path,
|
||
project_root=project_root,
|
||
skip_animations=False, # 既然是为了动画,就不跳过
|
||
flatten_nodes=False,
|
||
)
|
||
if cached_visual_path and cached_visual_path != model_path:
|
||
load_path = cached_visual_path
|
||
print(f"[GLTF智能加载] SSBO检测到动画,使用缓存: {cached_visual_path}")
|
||
else:
|
||
print(f"[GLTF智能加载] SSBO识别为静态模型,跳过缓存以获得最大流畅度")
|
||
except Exception as e:
|
||
print(f"[GLTF可见缓存] SSBO回退原始模型加载: {e}")
|
||
loader_options = None
|
||
try:
|
||
from panda3d.core import LoaderOptions
|
||
loader_options = LoaderOptions()
|
||
if hasattr(loader_options, "LF_no_cache"):
|
||
loader_options.setFlags(loader_options.getFlags() | loader_options.LF_no_cache)
|
||
elif hasattr(loader_options, "LFNoCache"):
|
||
loader_options.setFlags(loader_options.getFlags() | loader_options.LFNoCache)
|
||
except Exception:
|
||
loader_options = None
|
||
for fn in self._build_filename_candidates(load_path):
|
||
try:
|
||
if loader_options is not None:
|
||
source_model = self.base.loader.loadModel(fn, loader_options)
|
||
else:
|
||
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}'")
|
||
if apply_black_fix:
|
||
self._fixBlackMaterials(source_model)
|
||
if repair_textures:
|
||
self._repair_missing_textures(source_model, model_path)
|
||
return source_model
|
||
|
||
def _detach_shared_materials_in_subtree(self, root_np):
|
||
"""Clone materials in-place so one imported model instance cannot affect another."""
|
||
try:
|
||
from panda3d.core import Material, MaterialAttrib
|
||
|
||
if not self._node_is_valid(root_np):
|
||
return
|
||
|
||
for np in root_np.find_all_matches("**"):
|
||
if not self._node_is_valid(np):
|
||
continue
|
||
|
||
try:
|
||
if np.hasMaterial():
|
||
material = np.getMaterial()
|
||
if material is not None:
|
||
np.setMaterial(Material(material), 1)
|
||
except Exception:
|
||
pass
|
||
|
||
try:
|
||
panda_node = np.node()
|
||
if panda_node is None or not hasattr(panda_node, "getNumGeoms"):
|
||
continue
|
||
for geom_index in range(panda_node.getNumGeoms()):
|
||
geom_state = panda_node.getGeomState(geom_index)
|
||
if not geom_state.hasAttrib(MaterialAttrib):
|
||
continue
|
||
material_attrib = geom_state.getAttrib(MaterialAttrib)
|
||
material = material_attrib.getMaterial() if material_attrib else None
|
||
if material is None:
|
||
continue
|
||
panda_node.setGeomState(
|
||
geom_index,
|
||
geom_state.setAttrib(MaterialAttrib.make(Material(material))),
|
||
)
|
||
except Exception:
|
||
continue
|
||
except Exception:
|
||
pass
|
||
|
||
def _materialize_explicit_geom_materials(self, root_np):
|
||
"""Bake each GeomNode's effective material into local GeomState and clear inherited node material."""
|
||
try:
|
||
from panda3d.core import Material, MaterialAttrib
|
||
|
||
if not self._node_is_valid(root_np):
|
||
return
|
||
|
||
for geom_np in root_np.find_all_matches("**/+GeomNode"):
|
||
if not self._node_is_valid(geom_np):
|
||
continue
|
||
|
||
try:
|
||
geom_node = geom_np.node()
|
||
except Exception:
|
||
continue
|
||
|
||
net_state = None
|
||
try:
|
||
net_state = geom_np.getNetState()
|
||
except Exception:
|
||
try:
|
||
net_state = geom_np.get_net_state()
|
||
except Exception:
|
||
net_state = None
|
||
|
||
for geom_index in range(geom_node.getNumGeoms()):
|
||
try:
|
||
geom_state = geom_node.getGeomState(geom_index)
|
||
except Exception:
|
||
continue
|
||
|
||
material = None
|
||
try:
|
||
if geom_state.hasAttrib(MaterialAttrib):
|
||
material_attrib = geom_state.getAttrib(MaterialAttrib)
|
||
material = material_attrib.getMaterial() if material_attrib else None
|
||
except Exception:
|
||
material = None
|
||
|
||
if material is None and net_state is not None:
|
||
try:
|
||
if net_state.hasAttrib(MaterialAttrib):
|
||
material_attrib = net_state.getAttrib(MaterialAttrib)
|
||
material = material_attrib.getMaterial() if material_attrib else None
|
||
except Exception:
|
||
material = None
|
||
|
||
if material is None:
|
||
try:
|
||
if geom_np.hasMaterial():
|
||
material = geom_np.getMaterial()
|
||
except Exception:
|
||
material = None
|
||
|
||
if material is None:
|
||
continue
|
||
|
||
try:
|
||
geom_node.setGeomState(
|
||
geom_index,
|
||
geom_state.setAttrib(MaterialAttrib.make(Material(material))),
|
||
)
|
||
except Exception:
|
||
continue
|
||
|
||
try:
|
||
if geom_np.hasMaterial():
|
||
geom_np.clearMaterial()
|
||
except Exception:
|
||
pass
|
||
try:
|
||
geom_np.clearAttrib(MaterialAttrib.getClassType())
|
||
except Exception:
|
||
pass
|
||
except Exception:
|
||
pass
|
||
|
||
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 _get_node_name(self, node, default_name=None):
|
||
if not node:
|
||
return default_name
|
||
try:
|
||
return node.get_name()
|
||
except Exception:
|
||
pass
|
||
try:
|
||
return node.getName()
|
||
except Exception:
|
||
return default_name
|
||
|
||
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
|
||
|
||
@staticmethod
|
||
def _tag_is_enabled(node, tag_name, default=False):
|
||
try:
|
||
if not node or not node.hasTag(tag_name):
|
||
return bool(default)
|
||
value = str(node.getTag(tag_name) or "").strip().lower()
|
||
if value in ("1", "true", "yes", "on"):
|
||
return True
|
||
if value in ("0", "false", "no", "off"):
|
||
return False
|
||
except Exception:
|
||
pass
|
||
return bool(default)
|
||
|
||
def _node_has_saved_animation_info(self, node):
|
||
if not self._node_is_valid(node):
|
||
return False
|
||
for tag_name in ("saved_has_animations", "has_animations"):
|
||
if self._tag_is_enabled(node, tag_name, default=False):
|
||
return True
|
||
try:
|
||
if node.hasTag("gltf_animation_count"):
|
||
return int(str(node.getTag("gltf_animation_count") or "0").strip() or "0") > 0
|
||
except Exception:
|
||
pass
|
||
return False
|
||
|
||
def _should_skip_scene_package_child_for_ssbo(self, node):
|
||
if not self._node_is_valid(node):
|
||
return False
|
||
if self._node_has_saved_animation_info(node):
|
||
return True
|
||
try:
|
||
if node.hasTag("ssbo_managed"):
|
||
return not self._tag_is_enabled(node, "ssbo_managed", default=True)
|
||
except Exception:
|
||
pass
|
||
return False
|
||
|
||
def _capture_source_child_base_mats(self):
|
||
"""Capture baseline local mats for each top-level source child."""
|
||
self._source_child_base_mats = {}
|
||
self._model_root_last_snapshot_mat = LMatrix4f.ident_mat()
|
||
root = self.source_model_root
|
||
if not root:
|
||
return
|
||
for child in self._iter_children(root):
|
||
if not self._node_is_valid(child):
|
||
continue
|
||
name = self._get_node_name(child, None)
|
||
if not name:
|
||
continue
|
||
try:
|
||
self._source_child_base_mats[name] = LMatrix4f(child.get_mat())
|
||
except Exception:
|
||
try:
|
||
self._source_child_base_mats[name] = LMatrix4f(child.getMat())
|
||
except Exception:
|
||
continue
|
||
|
||
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 _invert_matrix_in_place(self, mat_value):
|
||
inv_mat = LMatrix4f(mat_value)
|
||
try:
|
||
inv_mat.invertInPlace()
|
||
return inv_mat
|
||
except Exception:
|
||
try:
|
||
inv_mat.invert_in_place()
|
||
return inv_mat
|
||
except Exception:
|
||
return None
|
||
|
||
def _capture_runtime_owner_base_mats(self):
|
||
"""Capture runtime model-space matrices keyed by hierarchy owner key."""
|
||
self._runtime_owner_base_mats = {}
|
||
controller = getattr(self, "controller", None)
|
||
if not controller or not self.model:
|
||
return
|
||
|
||
id_to_object_np = getattr(controller, "id_to_object_np", {}) or {}
|
||
id_to_name = getattr(controller, "id_to_name", {}) or {}
|
||
if not id_to_object_np or not id_to_name:
|
||
return
|
||
|
||
root_key = getattr(controller, "tree_root_key", None)
|
||
for global_id, obj_np in id_to_object_np.items():
|
||
if not self._node_is_valid(obj_np):
|
||
continue
|
||
owner_key = str(id_to_name.get(global_id, "") or "").strip()
|
||
if not owner_key or owner_key == root_key:
|
||
continue
|
||
if owner_key in self._runtime_owner_base_mats:
|
||
continue
|
||
try:
|
||
self._runtime_owner_base_mats[owner_key] = LMatrix4f(obj_np.get_mat(self.model))
|
||
except Exception:
|
||
try:
|
||
self._runtime_owner_base_mats[owner_key] = LMatrix4f(obj_np.getMat(self.model))
|
||
except Exception:
|
||
continue
|
||
|
||
def _snapshot_top_level_transforms_to_source_root(self):
|
||
"""Persist current runtime transforms back into the source scene tree."""
|
||
if not self.controller or not self.model or not self.source_model_root:
|
||
return
|
||
|
||
selected_key = self.selected_name
|
||
is_top_level_like_selection = False
|
||
if selected_key:
|
||
is_top_level_like_selection = bool(
|
||
self._selection_equivalent_top_level_key(selected_key)
|
||
or self._is_top_level_source_child_selection(selected_key)
|
||
or self._is_displayed_top_level_selection(selected_key)
|
||
or self._should_use_model_root_for_top_level_selection(selected_key)
|
||
)
|
||
|
||
selected_top_synced = self._snapshot_selected_top_level_transform_to_source()
|
||
|
||
# Top-level model transforms live on the aggregated runtime root/proxy.
|
||
# Batch-syncing descendant runtime objects in the same pass writes an
|
||
# opposite transform into the children, so the reopened scene visually
|
||
# cancels back to the original pose.
|
||
if not is_top_level_like_selection:
|
||
self._snapshot_runtime_transforms_to_source_root()
|
||
|
||
if not selected_top_synced:
|
||
self._snapshot_active_selection_transform_to_source()
|
||
self._snapshot_model_root_transform_to_source_root()
|
||
|
||
def _snapshot_selected_top_level_transform_to_source(self):
|
||
"""Explicitly persist currently selected top-level model transform, including wrapper selections."""
|
||
if not self.controller or not self.model or not self.source_model_root:
|
||
return False
|
||
|
||
selected_key = self.selected_name
|
||
root_key = getattr(self.controller, "tree_root_key", None)
|
||
if not selected_key or selected_key == root_key:
|
||
return False
|
||
|
||
top_key = self._selection_equivalent_top_level_key(selected_key)
|
||
if not top_key:
|
||
is_top_level_like_selection = bool(
|
||
self._is_top_level_source_child_selection(selected_key)
|
||
or self._is_displayed_top_level_selection(selected_key)
|
||
)
|
||
if is_top_level_like_selection:
|
||
top_key = selected_key
|
||
if not top_key:
|
||
return False
|
||
|
||
source_node = self._resolve_source_node_by_tree_key(top_key)
|
||
if not self._node_is_valid(source_node):
|
||
return False
|
||
|
||
scene_node = self.get_selection_scene_node()
|
||
if not self._node_is_valid(scene_node):
|
||
return False
|
||
|
||
# Single-model top-level selections are edited through self.model.
|
||
# In that case, use the dedicated model-root delta snapshot path.
|
||
if scene_node == self.model:
|
||
return self._snapshot_model_root_transform_to_source_root()
|
||
|
||
try:
|
||
current_net_mat = LMatrix4f(scene_node.get_mat(self.model))
|
||
except Exception:
|
||
try:
|
||
current_net_mat = LMatrix4f(scene_node.getMat(self.model))
|
||
except Exception:
|
||
return False
|
||
|
||
try:
|
||
reference_node = self._get_transform_snapshot_reference_node(source_node)
|
||
source_node.set_mat(reference_node, current_net_mat)
|
||
source_node.setTag("scene_transform_dirty", "true")
|
||
try:
|
||
self._cache_top_level_source_child_base_mat(source_node, source_node.get_mat())
|
||
except Exception:
|
||
try:
|
||
self._cache_top_level_source_child_base_mat(source_node, source_node.getMat())
|
||
except Exception:
|
||
pass
|
||
return True
|
||
except Exception:
|
||
try:
|
||
source_node.setMat(reference_node, current_net_mat)
|
||
source_node.setTag("scene_transform_dirty", "true")
|
||
try:
|
||
self._cache_top_level_source_child_base_mat(source_node, source_node.get_mat())
|
||
except Exception:
|
||
try:
|
||
self._cache_top_level_source_child_base_mat(source_node, source_node.getMat())
|
||
except Exception:
|
||
pass
|
||
return True
|
||
except Exception:
|
||
return False
|
||
|
||
def _snapshot_runtime_transforms_to_source_root(self):
|
||
"""Persist runtime object transforms for all owner keys back into source tree."""
|
||
controller = getattr(self, "controller", None)
|
||
if not controller or not self.model or not self.source_model_root:
|
||
return 0
|
||
|
||
id_to_object_np = getattr(controller, "id_to_object_np", {}) or {}
|
||
id_to_name = getattr(controller, "id_to_name", {}) or {}
|
||
if not id_to_object_np or not id_to_name:
|
||
return 0
|
||
|
||
owner_net_mats = {}
|
||
for global_id, obj_np in id_to_object_np.items():
|
||
if not self._node_is_valid(obj_np):
|
||
continue
|
||
owner_key = str(id_to_name.get(global_id, "") or "").strip()
|
||
if not owner_key:
|
||
continue
|
||
if owner_key == getattr(controller, "tree_root_key", None):
|
||
continue
|
||
if owner_key in owner_net_mats:
|
||
continue
|
||
try:
|
||
owner_net_mats[owner_key] = LMatrix4f(obj_np.get_mat(self.model))
|
||
except Exception:
|
||
try:
|
||
owner_net_mats[owner_key] = LMatrix4f(obj_np.getMat(self.model))
|
||
except Exception:
|
||
continue
|
||
|
||
if not owner_net_mats:
|
||
return 0
|
||
|
||
synced = 0
|
||
ordered_owner_items = sorted(
|
||
owner_net_mats.items(),
|
||
key=lambda item: str(item[0]).count("/"),
|
||
)
|
||
for owner_key, current_net_mat in ordered_owner_items:
|
||
source_node = self._resolve_source_node_by_tree_key(owner_key)
|
||
if not self._node_is_valid(source_node):
|
||
continue
|
||
|
||
try:
|
||
# Use robust Panda3D set_mat(ref, mat) to handle all coordinate conversions.
|
||
# This ensures the source node's net transform relative to the source root
|
||
# exactly matches the runtime node's net transform relative to self.model.
|
||
reference_node = self._get_transform_snapshot_reference_node(source_node)
|
||
source_node.set_mat(reference_node, current_net_mat)
|
||
source_node.setTag("scene_transform_dirty", "true")
|
||
|
||
# Update top-level child baseline cache for structural sync
|
||
try:
|
||
local_mat = source_node.get_mat()
|
||
except Exception:
|
||
local_mat = source_node.getMat()
|
||
self._cache_top_level_source_child_base_mat(source_node, local_mat)
|
||
|
||
# Mark as synced for the next incremental update
|
||
self._runtime_owner_base_mats[owner_key] = LMatrix4f(current_net_mat)
|
||
synced += 1
|
||
except Exception as e:
|
||
print(f"[SSBO] Snapshot failed for {owner_key}: {e}")
|
||
continue
|
||
return synced
|
||
|
||
def _snapshot_runtime_materials_to_source_root(self):
|
||
"""Persist current runtime material tags back into the source scene tree."""
|
||
controller = getattr(self, "controller", None)
|
||
if not controller or not self.model or not self.source_model_root:
|
||
return 0
|
||
|
||
id_to_object_np = getattr(controller, "id_to_object_np", {}) or {}
|
||
id_to_name = getattr(controller, "id_to_name", {}) or {}
|
||
if not id_to_object_np or not id_to_name:
|
||
return 0
|
||
|
||
synced = 0
|
||
material_tag_prefixes = ("material_", "scene_material_")
|
||
|
||
# We need to find all unique owner keys and their corresponding runtime nodes
|
||
# that actually hold the updated material tags.
|
||
owner_to_runtime = {}
|
||
for global_id, obj_np in id_to_object_np.items():
|
||
if not self._node_is_valid(obj_np):
|
||
continue
|
||
owner_key = str(id_to_name.get(global_id, "") or "").strip()
|
||
if not owner_key or owner_key == getattr(controller, "tree_root_key", None):
|
||
continue
|
||
|
||
# If multiple objects share an owner key, any of them having a dirty material tag
|
||
# should probably work, but we take the first one or one that is marked dirty.
|
||
if owner_key not in owner_to_runtime:
|
||
owner_to_runtime[owner_key] = obj_np
|
||
elif obj_np.hasTag("scene_material_dirty") and obj_np.getTag("scene_material_dirty").lower() == "true":
|
||
owner_to_runtime[owner_key] = obj_np
|
||
|
||
for owner_key, runtime_node in owner_to_runtime.items():
|
||
source_node = self._resolve_source_node_by_tree_key(owner_key)
|
||
if not self._node_is_valid(source_node):
|
||
continue
|
||
|
||
# Check if runtime node has any material tags to sync
|
||
tags_to_sync = {}
|
||
has_dirty = False
|
||
for tag_key in runtime_node.getTagKeys():
|
||
if any(tag_key.startswith(p) for p in material_tag_prefixes):
|
||
tags_to_sync[tag_key] = runtime_node.getTag(tag_key)
|
||
if tag_key == "scene_material_dirty" and tags_to_sync[tag_key].lower() == "true":
|
||
has_dirty = True
|
||
|
||
# Even if not explicitly marked dirty, if it has material tags, we sync them.
|
||
if tags_to_sync:
|
||
for t_key, t_val in tags_to_sync.items():
|
||
source_node.setTag(t_key, t_val)
|
||
synced += 1
|
||
|
||
return synced
|
||
|
||
def _snapshot_model_root_transform_to_source_root(self):
|
||
"""Apply only the aggregated runtime-root delta onto top-level source children."""
|
||
if not self.controller or not self.model or not self.source_model_root:
|
||
return False
|
||
|
||
# Persist whole-model transforms that live on the aggregated runtime root.
|
||
# Child/object sync above only captures transforms relative to self.model,
|
||
# so moving the whole imported model root would otherwise be lost on save.
|
||
model_root_mat = None
|
||
try:
|
||
model_root_mat = LMatrix4f(self.model.get_mat(self.base.render))
|
||
except Exception:
|
||
try:
|
||
model_root_mat = LMatrix4f(self.model.getMat(self.base.render))
|
||
except Exception:
|
||
model_root_mat = None
|
||
|
||
if model_root_mat is None:
|
||
return False
|
||
|
||
source_children = self._get_source_root_children()
|
||
if not source_children:
|
||
return False
|
||
|
||
# Single imported model: the editor manipulates self.model directly.
|
||
# Persist that absolute root transform straight onto the only top-level
|
||
# source child. Using delta composition here loses the saved root pose
|
||
# after reopen because there is no sibling model to preserve relative to.
|
||
if len(source_children) == 1 and self._node_is_valid(source_children[0]):
|
||
source_child = source_children[0]
|
||
try:
|
||
source_child.set_mat(model_root_mat)
|
||
source_child.setTag("scene_transform_dirty", "true")
|
||
self._cache_top_level_source_child_base_mat(source_child, model_root_mat)
|
||
self._model_root_last_snapshot_mat = LMatrix4f(model_root_mat)
|
||
return True
|
||
except Exception:
|
||
try:
|
||
source_child.setMat(model_root_mat)
|
||
source_child.setTag("scene_transform_dirty", "true")
|
||
self._cache_top_level_source_child_base_mat(source_child, model_root_mat)
|
||
self._model_root_last_snapshot_mat = LMatrix4f(model_root_mat)
|
||
return True
|
||
except Exception:
|
||
return False
|
||
|
||
previous_root_mat = getattr(self, "_model_root_last_snapshot_mat", None)
|
||
if previous_root_mat is None:
|
||
previous_root_mat = LMatrix4f.ident_mat()
|
||
|
||
model_root_delta = LMatrix4f(previous_root_mat)
|
||
try:
|
||
model_root_delta.invertInPlace()
|
||
except Exception:
|
||
try:
|
||
model_root_delta.invert_in_place()
|
||
except Exception:
|
||
return False
|
||
model_root_delta *= model_root_mat
|
||
|
||
applied = False
|
||
|
||
for source_child in source_children:
|
||
if not self._node_is_valid(source_child):
|
||
continue
|
||
try:
|
||
composed_mat = LMatrix4f(source_child.get_mat())
|
||
except Exception:
|
||
try:
|
||
composed_mat = LMatrix4f(source_child.getMat())
|
||
except Exception:
|
||
continue
|
||
composed_mat *= model_root_delta
|
||
try:
|
||
source_child.set_mat(composed_mat)
|
||
source_child.setTag("scene_transform_dirty", "true")
|
||
self._cache_top_level_source_child_base_mat(source_child, composed_mat)
|
||
applied = True
|
||
except Exception:
|
||
try:
|
||
source_child.setMat(composed_mat)
|
||
source_child.setTag("scene_transform_dirty", "true")
|
||
self._cache_top_level_source_child_base_mat(source_child, composed_mat)
|
||
applied = True
|
||
except Exception:
|
||
continue
|
||
if applied:
|
||
self._model_root_last_snapshot_mat = LMatrix4f(model_root_mat)
|
||
return applied
|
||
|
||
def _cache_top_level_source_child_base_mat(self, source_node, local_mat=None):
|
||
"""Keep top-level source-child baselines in sync with explicit subtree edits."""
|
||
if not self._node_is_valid(source_node) or not self._node_is_valid(self.source_model_root):
|
||
return False
|
||
|
||
try:
|
||
source_parent = source_node.get_parent()
|
||
except Exception:
|
||
try:
|
||
source_parent = source_node.getParent()
|
||
except Exception:
|
||
return False
|
||
|
||
if source_parent != self.source_model_root:
|
||
return False
|
||
|
||
child_name = self._get_node_name(source_node, None)
|
||
if not child_name:
|
||
return False
|
||
|
||
if local_mat is None:
|
||
try:
|
||
local_mat = LMatrix4f(source_node.get_mat())
|
||
except Exception:
|
||
try:
|
||
local_mat = LMatrix4f(source_node.getMat())
|
||
except Exception:
|
||
return False
|
||
else:
|
||
local_mat = LMatrix4f(local_mat)
|
||
|
||
self._source_child_base_mats[child_name] = local_mat
|
||
return True
|
||
|
||
def _snapshot_active_selection_transform_to_source(self):
|
||
"""Persist only the actively edited selection back into the source tree."""
|
||
if not self.controller or not self.model or not self.source_model_root:
|
||
return False
|
||
|
||
selected_key = self.selected_name
|
||
root_key = getattr(self.controller, "tree_root_key", None)
|
||
if not selected_key or selected_key == root_key:
|
||
return False
|
||
|
||
normalized_top_key = self._selection_equivalent_top_level_key(selected_key)
|
||
if normalized_top_key:
|
||
selected_key = normalized_top_key
|
||
|
||
scene_node = self.get_selection_scene_node()
|
||
source_node = self.get_selection_source_node()
|
||
if normalized_top_key:
|
||
normalized_source_node = self._resolve_source_node_by_tree_key(normalized_top_key)
|
||
if self._node_is_valid(normalized_source_node):
|
||
source_node = normalized_source_node
|
||
if not self._node_is_valid(scene_node) or not self._node_is_valid(source_node):
|
||
return False
|
||
|
||
# When a single imported model is selected via the aggregated runtime
|
||
# root, its whole-model transform lives on self.model rather than on the
|
||
# source child itself. Writing self.model's local identity back here
|
||
# would poison the top-level baseline and later structural edits
|
||
# (delete/attach/rebuild) would reapply a stale root transform.
|
||
if (
|
||
scene_node == self.model
|
||
and self._should_use_model_root_for_top_level_selection(selected_key)
|
||
):
|
||
return self._snapshot_model_root_transform_to_source_root()
|
||
|
||
is_top_level_like_selection = bool(
|
||
self._is_top_level_source_child_selection(selected_key)
|
||
or self._is_displayed_top_level_selection(selected_key)
|
||
)
|
||
|
||
# For regular non-root selections, runtime owner-key snapshot is the most
|
||
# reliable and fast path.
|
||
if not is_top_level_like_selection:
|
||
synced = self._snapshot_runtime_transforms_to_source_root()
|
||
if synced > 0:
|
||
return True
|
||
|
||
current_net_mat = None
|
||
proxy = getattr(self, "_group_proxy", None)
|
||
if (
|
||
proxy
|
||
and scene_node == proxy
|
||
and self._group_proxy_initial_mat is not None
|
||
and self._group_proxy_source_initial_net_mat is not None
|
||
):
|
||
try:
|
||
current_proxy_mat = LMatrix4f(proxy.get_mat(self.model))
|
||
except Exception:
|
||
try:
|
||
current_proxy_mat = LMatrix4f(proxy.getMat(self.model))
|
||
except Exception:
|
||
current_proxy_mat = None
|
||
if current_proxy_mat is not None:
|
||
proxy_delta_mat = LMatrix4f(self._group_proxy_initial_mat)
|
||
try:
|
||
proxy_delta_mat.invertInPlace()
|
||
except Exception:
|
||
try:
|
||
proxy_delta_mat.invert_in_place()
|
||
except Exception:
|
||
proxy_delta_mat = None
|
||
if proxy_delta_mat is not None:
|
||
proxy_delta_mat *= current_proxy_mat
|
||
current_net_mat = LMatrix4f(self._group_proxy_source_initial_net_mat)
|
||
current_net_mat *= proxy_delta_mat
|
||
# Make repeated snapshots idempotent until the selection is cleared.
|
||
self._group_proxy_initial_mat = LMatrix4f(current_proxy_mat)
|
||
self._group_proxy_source_initial_net_mat = LMatrix4f(current_net_mat)
|
||
|
||
if current_net_mat is None:
|
||
try:
|
||
current_net_mat = LMatrix4f(scene_node.get_mat(self.model))
|
||
except Exception:
|
||
try:
|
||
current_net_mat = LMatrix4f(scene_node.getMat(self.model))
|
||
except Exception:
|
||
current_net_mat = None
|
||
if current_net_mat is None:
|
||
return False
|
||
|
||
try:
|
||
# Corrected: use Panda3D native world-to-local projection relative to source root
|
||
reference_node = self._get_transform_snapshot_reference_node(source_node)
|
||
source_node.set_mat(reference_node, current_net_mat)
|
||
source_node.setTag("scene_transform_dirty", "true")
|
||
try:
|
||
self._cache_top_level_source_child_base_mat(source_node, source_node.get_mat())
|
||
except Exception:
|
||
try:
|
||
self._cache_top_level_source_child_base_mat(source_node, source_node.getMat())
|
||
except Exception:
|
||
pass
|
||
return True
|
||
except Exception as e:
|
||
print(f"[SSBO] Snapshot failed: {e}")
|
||
return False
|
||
return True
|
||
|
||
def _resolve_source_node_by_tree_key(self, tree_key):
|
||
"""Resolve controller tree key (e.g. 0/1/2) to source_model_root node."""
|
||
if not self.source_model_root or not tree_key:
|
||
return None
|
||
parts = str(tree_key).split("/")
|
||
if not parts or parts[0] != "0":
|
||
return None
|
||
node = self.source_model_root
|
||
for part in parts[1:]:
|
||
try:
|
||
child_index = int(part)
|
||
except Exception:
|
||
return None
|
||
try:
|
||
node = node.get_child(child_index)
|
||
except Exception:
|
||
try:
|
||
node = node.getChild(child_index)
|
||
except Exception:
|
||
return None
|
||
if not self._node_is_valid(node):
|
||
return None
|
||
return node
|
||
|
||
def _is_node_in_subtree(self, node, subtree_root):
|
||
if not self._node_is_valid(node) or not self._node_is_valid(subtree_root):
|
||
return False
|
||
current = node
|
||
while self._node_is_valid(current):
|
||
if current == subtree_root:
|
||
return True
|
||
try:
|
||
current = current.get_parent()
|
||
except Exception:
|
||
try:
|
||
current = current.getParent()
|
||
except Exception:
|
||
return False
|
||
return False
|
||
|
||
def _get_transform_snapshot_reference_node(self, source_node):
|
||
"""
|
||
Pick the correct reference node when snapshotting runtime transforms.
|
||
|
||
In the single-top-level-model path, the runtime moves that only top-level
|
||
child transform onto self.model. Descendant runtime matrices are then
|
||
relative to the sole source child, not to source_model_root. Saving those
|
||
descendants against source_model_root bakes the inverse root transform into
|
||
children, so reopening restores children but loses the whole-model pose.
|
||
"""
|
||
source_root = self.source_model_root
|
||
if not self._node_is_valid(source_node) or not self._node_is_valid(source_root):
|
||
return source_root
|
||
|
||
source_children = self._get_source_root_children()
|
||
if len(source_children) != 1 or not self._node_is_valid(source_children[0]):
|
||
return source_root
|
||
|
||
sole_source_child = source_children[0]
|
||
if source_node == sole_source_child:
|
||
return source_root
|
||
|
||
if self._is_node_in_subtree(source_node, sole_source_child):
|
||
return sole_source_child
|
||
return source_root
|
||
|
||
def _resolve_tree_key_for_source_node(self, node):
|
||
"""Resolve a source-tree NodePath back to the controller tree key."""
|
||
source_root = self._ensure_source_model_root()
|
||
if not self._node_is_valid(node) or not self._node_is_valid(source_root):
|
||
return None
|
||
if node == source_root:
|
||
return getattr(self.controller, "tree_root_key", "0") if self.controller else "0"
|
||
|
||
parts = []
|
||
current = node
|
||
while self._node_is_valid(current) and current != source_root:
|
||
try:
|
||
parent = current.get_parent()
|
||
except Exception:
|
||
try:
|
||
parent = current.getParent()
|
||
except Exception:
|
||
return None
|
||
if not self._node_is_valid(parent):
|
||
return None
|
||
|
||
child_index = None
|
||
children = self._iter_children(parent)
|
||
for index, child in enumerate(children):
|
||
if child == current:
|
||
child_index = index
|
||
break
|
||
if child_index is None:
|
||
return None
|
||
parts.append(str(child_index))
|
||
current = parent
|
||
|
||
if current != source_root:
|
||
return None
|
||
parts.reverse()
|
||
root_key = getattr(self.controller, "tree_root_key", "0") if self.controller else "0"
|
||
return "/".join([root_key] + parts) if parts else root_key
|
||
|
||
def sync_runtime_material_from_source_node(self, source_node):
|
||
"""Update only the runtime objects owned by the given source node."""
|
||
if not self.controller or not self._node_is_valid(source_node):
|
||
return False
|
||
|
||
owner_key = ""
|
||
try:
|
||
owner_key = str(source_node.getTag("imported_node_key") or "").strip()
|
||
except Exception:
|
||
owner_key = ""
|
||
if not owner_key:
|
||
owner_key = self._resolve_tree_key_for_source_node(source_node) or ""
|
||
if not owner_key:
|
||
return False
|
||
|
||
property_helpers = getattr(self.world, "property_helpers", None) if getattr(self, "world", None) else None
|
||
if not property_helpers:
|
||
return False
|
||
|
||
get_material_fn = getattr(property_helpers, "_get_renderable_node_material", None)
|
||
ensure_material_fn = getattr(property_helpers, "_ensure_material_for_node", None)
|
||
clone_material_fn = getattr(property_helpers, "_clone_material_for_node", None)
|
||
apply_geom_fn = getattr(property_helpers, "_apply_material_to_geom_states", None)
|
||
apply_surface_fn = getattr(property_helpers, "_apply_material_surface_state", None)
|
||
refresh_pipeline_fn = getattr(property_helpers, "_refresh_pipeline_material_mode", None)
|
||
|
||
material = get_material_fn(source_node) if callable(get_material_fn) else None
|
||
if material is None and callable(ensure_material_fn):
|
||
material = ensure_material_fn(source_node)
|
||
if material is None:
|
||
return False
|
||
|
||
target_ids = []
|
||
try:
|
||
current_source_node = self.get_selection_source_material_node()
|
||
except Exception:
|
||
current_source_node = None
|
||
if self._node_is_valid(current_source_node) and current_source_node == source_node:
|
||
target_ids = list(getattr(self, "selected_ids", []) or [])
|
||
if not target_ids:
|
||
target_ids = [
|
||
gid
|
||
for gid in getattr(self.controller, "id_to_object_np", {}).keys()
|
||
if self.controller.id_to_name.get(gid) == owner_key
|
||
]
|
||
|
||
updated = False
|
||
for gid in target_ids:
|
||
obj_np = getattr(self.controller, "id_to_object_np", {}).get(gid)
|
||
if not self._node_is_valid(obj_np):
|
||
continue
|
||
|
||
runtime_material = material
|
||
if callable(clone_material_fn):
|
||
try:
|
||
runtime_material = clone_material_fn(material, obj_np)
|
||
except Exception:
|
||
runtime_material = material
|
||
|
||
if callable(apply_geom_fn):
|
||
try:
|
||
apply_geom_fn(obj_np, runtime_material)
|
||
except Exception:
|
||
pass
|
||
if callable(apply_surface_fn):
|
||
try:
|
||
apply_surface_fn(obj_np, runtime_material)
|
||
except Exception:
|
||
pass
|
||
if callable(refresh_pipeline_fn):
|
||
try:
|
||
refresh_pipeline_fn(obj_np, runtime_material)
|
||
except Exception:
|
||
pass
|
||
updated = True
|
||
|
||
return updated
|
||
|
||
def _resolve_tree_key_for_source_node(self, node):
|
||
"""Resolve a source-tree NodePath back to its controller tree key."""
|
||
if not self._node_is_valid(node) or not self._node_is_valid(self.source_model_root):
|
||
return None
|
||
if node == self.source_model_root:
|
||
return getattr(self.controller, "tree_root_key", None) or "0"
|
||
|
||
indices = []
|
||
current = node
|
||
while self._node_is_valid(current) and current != self.source_model_root:
|
||
try:
|
||
parent = current.get_parent()
|
||
except Exception:
|
||
try:
|
||
parent = current.getParent()
|
||
except Exception:
|
||
return None
|
||
if not self._node_is_valid(parent):
|
||
return None
|
||
|
||
child_index = None
|
||
for idx, child in enumerate(self._iter_children(parent)):
|
||
if child == current:
|
||
child_index = idx
|
||
break
|
||
if child_index is None:
|
||
return None
|
||
indices.append(str(child_index))
|
||
current = parent
|
||
|
||
if current != self.source_model_root:
|
||
return None
|
||
indices.reverse()
|
||
return "0/" + "/".join(indices) if indices else "0"
|
||
|
||
def sync_runtime_material_from_source_node(self, source_node):
|
||
"""Push one source-node material change into matching runtime objects only."""
|
||
if not self.controller or not self._node_is_valid(source_node):
|
||
return False
|
||
if not self.is_source_tree_node(source_node):
|
||
return False
|
||
|
||
owner_key = self._resolve_tree_key_for_source_node(source_node)
|
||
if not owner_key:
|
||
return False
|
||
|
||
property_helpers = getattr(self.world, "property_helpers", None) if getattr(self, "world", None) else None
|
||
if property_helpers is None:
|
||
return False
|
||
|
||
get_material_fn = getattr(property_helpers, "_get_renderable_node_material", None)
|
||
ensure_material_fn = getattr(property_helpers, "_ensure_material_for_node", None)
|
||
clone_material_fn = getattr(property_helpers, "_clone_material_for_node", None)
|
||
apply_geom_fn = getattr(property_helpers, "_apply_material_to_geom_states", None)
|
||
apply_surface_fn = getattr(property_helpers, "_apply_material_surface_state", None)
|
||
refresh_pipeline_fn = getattr(property_helpers, "_refresh_pipeline_material_mode", None)
|
||
if not callable(apply_geom_fn) or not callable(apply_surface_fn):
|
||
return False
|
||
|
||
material = None
|
||
if callable(get_material_fn):
|
||
try:
|
||
material = get_material_fn(source_node)
|
||
except Exception:
|
||
material = None
|
||
if material is None and callable(ensure_material_fn):
|
||
try:
|
||
material = ensure_material_fn(source_node)
|
||
except Exception:
|
||
material = None
|
||
if material is None:
|
||
return False
|
||
|
||
matched = False
|
||
for gid, obj_np in (self.controller.id_to_object_np or {}).items():
|
||
try:
|
||
if self.controller.id_to_name.get(gid) != owner_key:
|
||
continue
|
||
except Exception:
|
||
continue
|
||
if not self._node_is_valid(obj_np):
|
||
continue
|
||
runtime_material = material
|
||
if callable(clone_material_fn):
|
||
try:
|
||
runtime_material = clone_material_fn(material, obj_np)
|
||
except Exception:
|
||
runtime_material = material
|
||
try:
|
||
apply_geom_fn(obj_np, runtime_material)
|
||
apply_surface_fn(obj_np, runtime_material)
|
||
if callable(refresh_pipeline_fn):
|
||
refresh_pipeline_fn(obj_np, runtime_material)
|
||
matched = True
|
||
except Exception:
|
||
continue
|
||
return matched
|
||
|
||
def _snapshot_runtime_materials_to_source_root(self):
|
||
"""
|
||
Persist runtime-edited material/geom render state back to source_model_root.
|
||
This keeps project save/load consistent for SSBO editing workflow.
|
||
"""
|
||
controller = self.controller
|
||
if not controller or not self.source_model_root:
|
||
return
|
||
|
||
synced = 0
|
||
effect_tags_synced = set()
|
||
root_effect_tags = {}
|
||
property_helpers = getattr(self.world, "property_helpers", None) if getattr(self, "world", None) else None
|
||
capture_snapshot_fn = getattr(property_helpers, "_capture_node_material_snapshot", None) if property_helpers else None
|
||
normalize_snapshot_fn = getattr(property_helpers, "_normalize_material_snapshot", None) if property_helpers else None
|
||
apply_snapshot_fn = getattr(property_helpers, "_apply_node_material_snapshot", None) if property_helpers else None
|
||
get_materials_fn = getattr(property_helpers, "_get_node_materials", None) if property_helpers else None
|
||
ensure_material_fn = getattr(property_helpers, "_ensure_material_for_node", None) if property_helpers else None
|
||
try:
|
||
model_root = self.model
|
||
if model_root and not model_root.is_empty():
|
||
for tag_name in (
|
||
"material_effect_metallic_enabled",
|
||
"material_effect_default_texture_enabled",
|
||
"material_effect_parallax_enabled",
|
||
"material_render_effect_signature",
|
||
):
|
||
if model_root.hasTag(tag_name):
|
||
root_effect_tags[tag_name] = model_root.getTag(tag_name)
|
||
except Exception:
|
||
root_effect_tags = {}
|
||
|
||
def _node_has_metallic_texture(node_np):
|
||
try:
|
||
stages = node_np.findAllTextureStages()
|
||
for i in range(stages.getNumTextureStages()):
|
||
stage = stages.getTextureStage(i)
|
||
if not stage:
|
||
continue
|
||
try:
|
||
sname = (stage.getName() or "").lower()
|
||
except Exception:
|
||
sname = ""
|
||
# convention in project helpers and RP metallic workflow
|
||
if "metallic" in sname or sname == "p3d_texture5":
|
||
return True
|
||
except Exception:
|
||
pass
|
||
return False
|
||
|
||
def _clone_snapshot_for_target(source_snapshot, target_node):
|
||
if not callable(normalize_snapshot_fn):
|
||
return None
|
||
snapshot = normalize_snapshot_fn(source_snapshot)
|
||
if snapshot is None:
|
||
return None
|
||
|
||
target_materials = []
|
||
try:
|
||
if callable(get_materials_fn):
|
||
target_materials = list(get_materials_fn(target_node) or [])
|
||
except Exception:
|
||
target_materials = []
|
||
if not target_materials and callable(ensure_material_fn):
|
||
try:
|
||
fallback = ensure_material_fn(target_node)
|
||
if fallback is not None:
|
||
target_materials = [fallback]
|
||
except Exception:
|
||
target_materials = []
|
||
|
||
source_entries = snapshot.get("materials", []) or []
|
||
cloned_entries = []
|
||
for idx, entry in enumerate(source_entries):
|
||
target_material = target_materials[idx] if idx < len(target_materials) else None
|
||
cloned_entries.append({
|
||
"material": target_material,
|
||
"base_color": entry.get("base_color"),
|
||
"roughness": entry.get("roughness"),
|
||
"metallic": entry.get("metallic"),
|
||
"ior": entry.get("ior"),
|
||
"emission": entry.get("emission"),
|
||
})
|
||
|
||
node_state = snapshot.get("node_state", {}) or {}
|
||
textures = dict(node_state.get("textures", {}) or {})
|
||
effect_tags = dict(node_state.get("effect_tags", {}) or {})
|
||
|
||
return {
|
||
"materials": cloned_entries,
|
||
"node_state": {
|
||
"textures": textures,
|
||
"effect_tags": effect_tags,
|
||
},
|
||
}
|
||
|
||
grouped_entries = {}
|
||
|
||
def _snapshot_score(snapshot, obj_np):
|
||
score = 0
|
||
try:
|
||
snapshot = normalize_snapshot_fn(snapshot) if callable(normalize_snapshot_fn) else snapshot
|
||
except Exception:
|
||
pass
|
||
if isinstance(snapshot, dict):
|
||
node_state = snapshot.get("node_state", {}) or {}
|
||
textures = node_state.get("textures", {}) or {}
|
||
effect_tags = node_state.get("effect_tags", {}) or {}
|
||
score += len([value for value in textures.values() if value]) * 100
|
||
score += len([value for value in effect_tags.values() if value]) * 50
|
||
for entry in snapshot.get("materials", []) or []:
|
||
if entry.get("base_color") is not None:
|
||
score += 5
|
||
for scalar_name in ("roughness", "metallic", "ior"):
|
||
if entry.get(scalar_name) is not None:
|
||
score += 3
|
||
if entry.get("emission") is not None:
|
||
score += 2
|
||
try:
|
||
if _node_has_metallic_texture(obj_np):
|
||
score += 25
|
||
except Exception:
|
||
pass
|
||
return score
|
||
|
||
for gid, obj_np in controller.id_to_object_np.items():
|
||
if not self._node_is_valid(obj_np):
|
||
continue
|
||
owner_key = controller.id_to_name.get(gid)
|
||
if not owner_key:
|
||
continue
|
||
source_node = self._resolve_source_node_by_tree_key(owner_key)
|
||
if not self._node_is_valid(source_node):
|
||
continue
|
||
|
||
runtime_snapshot = None
|
||
if callable(capture_snapshot_fn):
|
||
try:
|
||
runtime_snapshot = capture_snapshot_fn(obj_np)
|
||
except Exception:
|
||
runtime_snapshot = None
|
||
|
||
source_snapshot = None
|
||
if callable(capture_snapshot_fn):
|
||
try:
|
||
source_snapshot = capture_snapshot_fn(source_node)
|
||
except Exception:
|
||
source_snapshot = None
|
||
|
||
source_node_key = id(source_node)
|
||
entry = grouped_entries.get(source_node_key)
|
||
runtime_score = _snapshot_score(runtime_snapshot, obj_np)
|
||
source_score = _snapshot_score(source_snapshot, source_node)
|
||
# Saving should prefer the visible runtime state on ties; otherwise a
|
||
# stale source snapshot can overwrite a freshly edited child material.
|
||
if runtime_snapshot is not None and runtime_score >= source_score:
|
||
chosen_snapshot = runtime_snapshot
|
||
else:
|
||
chosen_snapshot = source_snapshot
|
||
candidate_score = max(runtime_score, source_score)
|
||
if entry is None or candidate_score > entry["score"]:
|
||
grouped_entries[source_node_key] = {
|
||
"gid": gid,
|
||
"obj_np": obj_np,
|
||
"source_node": source_node,
|
||
"snapshot": chosen_snapshot,
|
||
"score": candidate_score,
|
||
}
|
||
|
||
for source_node_key, entry in grouped_entries.items():
|
||
gid = entry["gid"]
|
||
obj_np = entry["obj_np"]
|
||
source_node = entry["source_node"]
|
||
source_snapshot = entry["snapshot"]
|
||
|
||
if source_node_key not in effect_tags_synced:
|
||
inferred_metallic = _node_has_metallic_texture(obj_np)
|
||
for tag_name in (
|
||
"material_effect_metallic_enabled",
|
||
"material_effect_default_texture_enabled",
|
||
"material_effect_parallax_enabled",
|
||
"material_render_effect_signature",
|
||
):
|
||
try:
|
||
if obj_np.hasTag(tag_name):
|
||
source_node.setTag(tag_name, obj_np.getTag(tag_name))
|
||
elif tag_name == "material_effect_metallic_enabled" and inferred_metallic:
|
||
source_node.setTag(tag_name, "1")
|
||
elif tag_name in root_effect_tags:
|
||
source_node.setTag(tag_name, root_effect_tags[tag_name])
|
||
except Exception:
|
||
pass
|
||
texture_slot_tags = (
|
||
"material_texture_diffuse",
|
||
"material_texture_normal",
|
||
"material_texture_ior",
|
||
"material_texture_roughness",
|
||
"material_texture_parallax",
|
||
"material_texture_metallic",
|
||
"material_texture_emission",
|
||
"material_texture_ao",
|
||
"material_texture_alpha",
|
||
"material_texture_detail",
|
||
"material_texture_gloss",
|
||
)
|
||
for tag_name in texture_slot_tags:
|
||
try:
|
||
if obj_np.hasTag(tag_name):
|
||
source_node.setTag(tag_name, obj_np.getTag(tag_name))
|
||
elif source_node.hasTag(tag_name):
|
||
source_node.clearTag(tag_name)
|
||
except Exception:
|
||
pass
|
||
effect_tags_synced.add(source_node_key)
|
||
|
||
if source_snapshot is not None and callable(apply_snapshot_fn):
|
||
try:
|
||
target_snapshot = _clone_snapshot_for_target(source_snapshot, source_node)
|
||
if target_snapshot is not None:
|
||
apply_snapshot_fn(source_node, target_snapshot)
|
||
source_node.setTag("scene_material_dirty", "true")
|
||
synced += 1
|
||
continue
|
||
except Exception:
|
||
pass
|
||
|
||
runtime_geom_state = None
|
||
try:
|
||
geom_paths = obj_np.findAllMatches("**/+GeomNode")
|
||
if geom_paths and geom_paths.getNumPaths() > 0:
|
||
runtime_geom_state = geom_paths.getPath(0).getNetState()
|
||
except Exception:
|
||
runtime_geom_state = None
|
||
if runtime_geom_state is None:
|
||
continue
|
||
|
||
try:
|
||
src_gnode = source_node.node()
|
||
set_src_state = getattr(src_gnode, "set_geom_state", None) or getattr(src_gnode, "setGeomState", None)
|
||
get_src_count = getattr(src_gnode, "get_num_geoms", None) or getattr(src_gnode, "getNumGeoms", None)
|
||
src_geom_count = int(get_src_count()) if callable(get_src_count) else 0
|
||
except Exception:
|
||
continue
|
||
|
||
src_geom_index = controller.id_to_geom_index.get(gid, 0)
|
||
if src_geom_index < 0 or src_geom_index >= src_geom_count:
|
||
continue
|
||
|
||
try:
|
||
if callable(set_src_state):
|
||
set_src_state(src_geom_index, runtime_geom_state)
|
||
source_node.setTag("scene_material_dirty", "true")
|
||
synced += 1
|
||
except Exception:
|
||
continue
|
||
|
||
if synced:
|
||
print(f"[SSBOEditor] Synced runtime material states back to source tree: {synced}")
|
||
|
||
def _restore_saved_material_bindings_from_tags(self, root_np):
|
||
"""Rebind saved texture/effect tags back onto loaded source nodes."""
|
||
if not self._node_is_valid(root_np):
|
||
return 0
|
||
|
||
property_helpers = getattr(self.base, "property_helpers", None)
|
||
capture_snapshot_fn = getattr(property_helpers, "_capture_node_material_snapshot", None) if property_helpers else None
|
||
apply_snapshot_fn = getattr(property_helpers, "_apply_node_material_snapshot", None) if property_helpers else None
|
||
texture_slots_fn = getattr(property_helpers, "_get_material_texture_slots", None) if property_helpers else None
|
||
if not callable(capture_snapshot_fn) or not callable(apply_snapshot_fn):
|
||
return 0
|
||
|
||
texture_types = []
|
||
if callable(texture_slots_fn):
|
||
try:
|
||
texture_types = list((texture_slots_fn() or {}).keys())
|
||
except Exception:
|
||
texture_types = []
|
||
if not texture_types:
|
||
texture_types = [
|
||
"diffuse", "normal", "ior", "roughness", "parallax",
|
||
"metallic", "emission", "ao", "alpha", "detail", "gloss",
|
||
]
|
||
|
||
effect_tag_names = (
|
||
"material_effect_metallic_enabled",
|
||
"material_effect_default_texture_enabled",
|
||
"material_effect_parallax_enabled",
|
||
"material_render_effect_signature",
|
||
)
|
||
|
||
restored = 0
|
||
try:
|
||
descendant_nodes = list(root_np.find_all_matches("**"))
|
||
except Exception:
|
||
try:
|
||
descendant_nodes = list(root_np.findAllMatches("**"))
|
||
except Exception:
|
||
descendant_nodes = []
|
||
|
||
for node in [root_np] + descendant_nodes:
|
||
if not self._node_is_valid(node):
|
||
continue
|
||
|
||
has_texture_tags = any(node.hasTag(f"material_texture_{texture_type}") for texture_type in texture_types)
|
||
has_effect_tags = any(node.hasTag(tag_name) for tag_name in effect_tag_names)
|
||
if not (has_texture_tags or has_effect_tags):
|
||
continue
|
||
|
||
try:
|
||
snapshot = capture_snapshot_fn(node)
|
||
except Exception:
|
||
snapshot = None
|
||
if not snapshot:
|
||
continue
|
||
|
||
try:
|
||
apply_snapshot_fn(node, snapshot)
|
||
restored += 1
|
||
except Exception:
|
||
continue
|
||
|
||
if restored:
|
||
print(f"[SSBOEditor] Restored saved material bindings from tags: {restored}")
|
||
return restored
|
||
|
||
def _clear_runtime_state(self, preserve_source_models=False, snapshot_transform=True):
|
||
"""Remove runtime SSBO controller/model state while optionally keeping source snapshots."""
|
||
self.clear_selection(snapshot_transform=snapshot_transform)
|
||
self._cleanup_group_proxy()
|
||
self._reset_pick_sync_cache()
|
||
self._teardown_gpu_picking()
|
||
|
||
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.transform_ids = []
|
||
self.last_import_tree_key = None
|
||
self.last_import_root_name = None
|
||
self._model_root_last_snapshot_mat = LMatrix4f.ident_mat()
|
||
self._runtime_owner_base_mats = {}
|
||
if not preserve_source_models:
|
||
self.source_model = None
|
||
self.source_model_root = None
|
||
self._source_child_base_mats = {}
|
||
self._sync_pick_scene_binding()
|
||
|
||
def _get_source_root_children(self):
|
||
root = self.source_model_root
|
||
if not root:
|
||
return []
|
||
return [child for child in self._iter_children(root) if self._node_is_valid(child)]
|
||
|
||
def _rebuild_or_clear_runtime_from_current_source(self, highlight_root_name=None):
|
||
if self._get_source_root_children():
|
||
self.source_model = self.source_model_root
|
||
self._rebuild_runtime_from_source_root(highlight_root_name=highlight_root_name)
|
||
return self.model
|
||
|
||
self.last_import_tree_key = None
|
||
self.last_import_root_name = None
|
||
self.source_model = self.source_model_root
|
||
self._sync_pick_scene_binding()
|
||
return None
|
||
|
||
def find_source_child_by_name(self, child_name):
|
||
if not child_name:
|
||
return None
|
||
for child in self._get_source_root_children():
|
||
if self._get_node_name(child) == child_name:
|
||
return child
|
||
return None
|
||
|
||
def detach_source_child(self, child_name=None, child_np=None):
|
||
target = child_np if self._node_is_valid(child_np) else self.find_source_child_by_name(child_name)
|
||
if not self._node_is_valid(target):
|
||
return None
|
||
|
||
if self.controller and self.model:
|
||
self._snapshot_top_level_transforms_to_source_root()
|
||
self._clear_runtime_state(preserve_source_models=True, snapshot_transform=False)
|
||
|
||
try:
|
||
target.detach_node()
|
||
except Exception:
|
||
try:
|
||
target.detachNode()
|
||
except Exception:
|
||
return None
|
||
|
||
self._rebuild_or_clear_runtime_from_current_source()
|
||
return target
|
||
|
||
def delete_selected_source_node(self):
|
||
"""Delete the current SSBO selection from source_model_root and rebuild runtime."""
|
||
if not self.controller or self.selected_name is None:
|
||
return False
|
||
|
||
target = self._resolve_source_node_by_tree_key(self.selected_name)
|
||
if not self._node_is_valid(target):
|
||
return False
|
||
|
||
source_root = self._ensure_source_model_root()
|
||
if target == source_root:
|
||
children = self._get_source_root_children()
|
||
if not children:
|
||
return False
|
||
if self.controller and self.model:
|
||
self._snapshot_top_level_transforms_to_source_root()
|
||
self._clear_runtime_state(preserve_source_models=True, snapshot_transform=False)
|
||
for child in children:
|
||
try:
|
||
child.detach_node()
|
||
except Exception:
|
||
try:
|
||
child.detachNode()
|
||
except Exception:
|
||
continue
|
||
self.clear_selection(sync_world_selection=False, snapshot_transform=False)
|
||
self._rebuild_or_clear_runtime_from_current_source()
|
||
return True
|
||
|
||
if self.controller and self.model:
|
||
self._snapshot_top_level_transforms_to_source_root()
|
||
self._clear_runtime_state(preserve_source_models=True, snapshot_transform=False)
|
||
try:
|
||
target.detach_node()
|
||
except Exception:
|
||
try:
|
||
target.detachNode()
|
||
except Exception:
|
||
return False
|
||
|
||
self.clear_selection(sync_world_selection=False, snapshot_transform=False)
|
||
self._rebuild_or_clear_runtime_from_current_source()
|
||
return True
|
||
|
||
def attach_source_child(self, child_np, highlight_root_name=None):
|
||
if not self._node_is_valid(child_np):
|
||
return None
|
||
|
||
if self.controller and self.model:
|
||
self._snapshot_top_level_transforms_to_source_root()
|
||
self._clear_runtime_state(preserve_source_models=True, snapshot_transform=False)
|
||
|
||
source_root = self._ensure_source_model_root()
|
||
target_name = highlight_root_name or self._get_node_name(child_np, "imported_model")
|
||
self._set_node_name(child_np, target_name)
|
||
|
||
try:
|
||
child_np.reparent_to(source_root)
|
||
except Exception:
|
||
try:
|
||
child_np.reparentTo(source_root)
|
||
except Exception:
|
||
return None
|
||
|
||
self._rebuild_or_clear_runtime_from_current_source(highlight_root_name=target_name)
|
||
return self.model
|
||
|
||
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)
|
||
runtime_root_mat = None
|
||
source_children = self._get_source_root_children()
|
||
if len(source_children) == 1 and self._node_is_valid(source_children[0]):
|
||
try:
|
||
working_children = [child for child in self._iter_children(working_root) if self._node_is_valid(child)]
|
||
except Exception:
|
||
working_children = []
|
||
if len(working_children) == 1 and self._node_is_valid(working_children[0]):
|
||
working_child = working_children[0]
|
||
try:
|
||
runtime_root_mat = LMatrix4f(working_child.get_mat(working_root))
|
||
except Exception:
|
||
try:
|
||
runtime_root_mat = LMatrix4f(working_child.getMat(working_root))
|
||
except Exception:
|
||
runtime_root_mat = None
|
||
if runtime_root_mat is not None:
|
||
try:
|
||
working_child.set_mat(LMatrix4f.ident_mat())
|
||
except Exception:
|
||
try:
|
||
working_child.setMat(LMatrix4f.ident_mat())
|
||
except Exception:
|
||
runtime_root_mat = None
|
||
self.controller = ObjectController()
|
||
try:
|
||
geom_count = len(list(working_root.find_all_matches("**/+GeomNode")))
|
||
except Exception:
|
||
geom_count = 0
|
||
try:
|
||
node_count = len(list(working_root.find_all_matches("**")))
|
||
except Exception:
|
||
node_count = 0
|
||
skip_heavy_material_processing = self._should_skip_heavy_material_processing(
|
||
geom_count=geom_count,
|
||
node_count=node_count,
|
||
)
|
||
if skip_heavy_material_processing:
|
||
print(
|
||
f"[SSBOEditor] Skip heavy material processing for large scene "
|
||
f"(nodes={node_count}, geoms={geom_count})"
|
||
)
|
||
else:
|
||
self._detach_shared_materials_in_subtree(working_root)
|
||
self._materialize_explicit_geom_materials(working_root)
|
||
# Large scenes should prefer the old flat import path.
|
||
# Hybrid mode keeps per-child editing, but for vegetation/campus-like GLB
|
||
# scenes it explodes into tens of thousands of runtime objects/chunks and
|
||
# the editor becomes unusable or crashes.
|
||
# 提高使用 hybrid 模式的阈值。
|
||
# 对于类似 jyc.glb 的场景,hybrid 模式的对象分块(chunks)比一个巨大的 flat 节点拥有更好的剔除性能和帧率。
|
||
# 只有在极端规模(如 > 20000 节点或 > 10000 几何体)的情况下才强制退回到 flat 模式以节省内存。
|
||
prefer_flat_mode = (
|
||
geom_count > 10000
|
||
or node_count > 20000
|
||
)
|
||
use_hybrid_mode = geom_count > 0 and not prefer_flat_mode
|
||
if prefer_flat_mode:
|
||
print(
|
||
f"[SSBOEditor] Large scene uses flat runtime path "
|
||
f"(nodes={node_count}, geoms={geom_count})"
|
||
)
|
||
if use_hybrid_mode:
|
||
count = self.controller.bake_ids_and_collect_hybrid(working_root)
|
||
else:
|
||
count = self.controller.bake_ids_and_collect(
|
||
working_root,
|
||
lightweight=prefer_flat_mode,
|
||
)
|
||
self.model = self.controller.model
|
||
self.model.reparent_to(self.base.render)
|
||
if runtime_root_mat is not None:
|
||
try:
|
||
self.model.set_mat(runtime_root_mat)
|
||
except Exception:
|
||
try:
|
||
self.model.setMat(runtime_root_mat)
|
||
except Exception:
|
||
pass
|
||
self._capture_runtime_owner_base_mats()
|
||
|
||
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
|
||
|
||
mode_name = "hybrid" if use_hybrid_mode else "flat"
|
||
print(f"[SSBOEditor] Model loaded. Total objects: {count}, mode={mode_name}, geoms={geom_count}")
|
||
|
||
def load_model(
|
||
self,
|
||
model_path,
|
||
keep_source_model=False,
|
||
append=False,
|
||
scene_package_import=False,
|
||
rebuild_runtime=True,
|
||
):
|
||
"""Load and process one model into the aggregated SSBO scene."""
|
||
print(f"[SSBOEditor] Loading model: {model_path}")
|
||
skip_heavy_material_processing = self._should_skip_heavy_material_processing(model_path=model_path)
|
||
# scene_package_import: loading saved scene.bam from project.
|
||
# Keep stored material/texture states intact; repair heuristics can
|
||
# misclassify valid packed/relative texture refs and cause dark materials.
|
||
should_repair_textures = (not scene_package_import) and (not skip_heavy_material_processing)
|
||
should_fix_black_materials = (not scene_package_import) and (not skip_heavy_material_processing)
|
||
if skip_heavy_material_processing:
|
||
model_size_mb = self._get_model_file_size(model_path) / (1024.0 * 1024.0)
|
||
print(
|
||
f"[SSBOEditor] Large model fast path enabled for {os.path.basename(model_path)} "
|
||
f"({model_size_mb:.1f} MB)"
|
||
)
|
||
source_model = self._load_source_model_from_path(
|
||
model_path,
|
||
apply_black_fix=should_fix_black_materials,
|
||
repair_textures=should_repair_textures,
|
||
)
|
||
model_name = os.path.basename(model_path)
|
||
if model_name and not scene_package_import:
|
||
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, snapshot_transform=False)
|
||
else:
|
||
self._clear_runtime_state(preserve_source_models=False)
|
||
|
||
source_root = self._ensure_source_model_root()
|
||
|
||
# 项目场景包导入(scene.bam)时,避免再包一层 "scene.bam" 根节点,
|
||
# 直接把其顶层子节点并入 source_root,保持场景树与保存时一致。
|
||
if scene_package_import:
|
||
imported_roots = []
|
||
skipped_legacy_roots = 0
|
||
children = []
|
||
try:
|
||
children = [c for c in source_model.get_children() if c and not c.is_empty()]
|
||
except Exception:
|
||
try:
|
||
children = [c for c in source_model.getChildren() if c and not c.isEmpty()]
|
||
except Exception:
|
||
children = []
|
||
|
||
for child in children:
|
||
try:
|
||
child_name = self._get_node_name(child, "")
|
||
if child_name in {"render", "render2d", "aspect2d"}:
|
||
continue
|
||
if self._should_skip_scene_package_child_for_ssbo(child):
|
||
skipped_legacy_roots += 1
|
||
print(f"[SSBOSceneLoad] 跳过普通/动画模型: {child_name}")
|
||
continue
|
||
imported_child = child.copyTo(source_root)
|
||
if not skip_heavy_material_processing:
|
||
self._detach_shared_materials_in_subtree(imported_child)
|
||
imported_child.setTag("ssbo_managed", "true")
|
||
imported_roots.append(imported_child)
|
||
except Exception:
|
||
continue
|
||
|
||
if not imported_roots and skipped_legacy_roots == 0:
|
||
fallback_name = self._get_node_name(source_model, "scene_root")
|
||
unique_root_name = self._make_unique_source_child_name(fallback_name)
|
||
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)
|
||
imported_root.setTag("ssbo_managed", "true")
|
||
if not skip_heavy_material_processing:
|
||
self._detach_shared_materials_in_subtree(imported_root)
|
||
imported_roots = [imported_root]
|
||
|
||
self.source_model = source_root
|
||
self._restore_saved_material_bindings_from_tags(source_root)
|
||
self._capture_source_child_base_mats()
|
||
if rebuild_runtime:
|
||
if self._get_source_root_children():
|
||
self._rebuild_runtime_from_source_root(highlight_root_name=None)
|
||
else:
|
||
self._rebuild_or_clear_runtime_from_current_source()
|
||
if len(imported_roots) == 1:
|
||
return imported_roots[0]
|
||
return source_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)
|
||
imported_root.setTag("ssbo_managed", "true")
|
||
if not skip_heavy_material_processing:
|
||
self._detach_shared_materials_in_subtree(imported_root)
|
||
|
||
if keep_source_model and not append:
|
||
self.source_model = imported_root
|
||
else:
|
||
self.source_model = source_root
|
||
|
||
self._capture_source_child_base_mats()
|
||
if rebuild_runtime:
|
||
self._rebuild_runtime_from_source_root(highlight_root_name=unique_root_name)
|
||
return imported_root
|
||
|
||
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)"""
|
||
self._teardown_gpu_picking()
|
||
|
||
controller = getattr(self, "controller", None)
|
||
if controller and not getattr(controller, "supports_gpu_picking", True):
|
||
print("[GPU Picking] Disabled for lightweight large-scene runtime.")
|
||
return
|
||
|
||
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 _teardown_gpu_picking(self):
|
||
"""Release old GPU picking resources before rebuilding them."""
|
||
pick_cam_np = getattr(self, "pick_cam_np", None)
|
||
if pick_cam_np is not None:
|
||
try:
|
||
if not pick_cam_np.is_empty():
|
||
pick_cam_np.remove_node()
|
||
except Exception:
|
||
try:
|
||
if not pick_cam_np.isEmpty():
|
||
pick_cam_np.removeNode()
|
||
except Exception:
|
||
pass
|
||
self.pick_cam_np = None
|
||
self.pick_cam = None
|
||
self.pick_lens = None
|
||
|
||
pick_buffer = getattr(self, "pick_buffer", None)
|
||
if pick_buffer is not None:
|
||
try:
|
||
self.base.graphicsEngine.remove_window(pick_buffer)
|
||
except Exception:
|
||
try:
|
||
self.base.graphicsEngine.removeWindow(pick_buffer)
|
||
except Exception:
|
||
pass
|
||
self.pick_buffer = None
|
||
self.pick_texture = None
|
||
|
||
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()
|
||
|
||
def _resolve_pick_selection_key(node_key):
|
||
resolved_key = node_key
|
||
try:
|
||
mouse_watcher = getattr(self.base, "mouseWatcherNode", None)
|
||
if mouse_watcher and mouse_watcher.is_button_down(KeyboardButton.control()):
|
||
parts = str(node_key).split("/")
|
||
if len(parts) >= 2 and parts[0] == getattr(self.controller, "tree_root_key", "0"):
|
||
resolved_key = "/".join(parts[:2])
|
||
except Exception:
|
||
pass
|
||
return resolved_key
|
||
|
||
try:
|
||
self.pick_lens.setAspectRatio(self.base.camLens.getAspectRatio())
|
||
except Exception:
|
||
pass
|
||
|
||
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:
|
||
selection_key = _resolve_pick_selection_key(node_key)
|
||
print(f"[Pick] Hit: ID={hit_id} -> {node_key} (select={selection_key})")
|
||
self.select_node(selection_key)
|
||
return True
|
||
|
||
return False
|
||
|
||
|
||
def on_mouse_click(self):
|
||
# 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():
|
||
try:
|
||
win_width, win_height = self.base.win.getSize()
|
||
mpos = self.base.mouseWatcherNode.get_mouse()
|
||
window_x = (float(mpos.x) + 1.0) * 0.5 * float(win_width)
|
||
window_y = (1.0 - float(mpos.y)) * 0.5 * float(win_height)
|
||
process_imgui_click = getattr(self.base, "processImGuiMouseClick", None)
|
||
if callable(process_imgui_click) and process_imgui_click(window_x, window_y):
|
||
return
|
||
except Exception:
|
||
pass
|
||
self._sync_pick_model_transform()
|
||
self._refresh_ssbo_proxy_center()
|
||
mpos = self.base.mouseWatcherNode.get_mouse()
|
||
if self.pick_object(mpos.x, mpos.y):
|
||
return
|
||
|
||
# In SSBO picking mode, a miss should clear the current SSBO selection.
|
||
# Falling back to legacy collision picking here tends to immediately
|
||
# re-hit broad helper/collision shells from the selected root model,
|
||
# which makes "click blank space to deselect" fail for top-level nodes.
|
||
if self.has_active_selection():
|
||
self.clear_selection()
|
||
return
|
||
|
||
try:
|
||
win_width, win_height = self.base.win.getSize()
|
||
window_x = (float(mpos.x) + 1.0) * 0.5 * float(win_width)
|
||
window_y = (1.0 - float(mpos.y)) * 0.5 * float(win_height)
|
||
event_handler = getattr(self.base, "event_handler", None)
|
||
if event_handler and hasattr(event_handler, "mousePressEventLeft"):
|
||
event_handler.mousePressEventLeft({
|
||
"x": window_x,
|
||
"y": window_y,
|
||
})
|
||
return
|
||
except Exception as e:
|
||
print(f"[SSBOEditor] Legacy pick fallback failed: {e}")
|
||
|
||
self.clear_selection()
|
||
|
||
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()
|
||
transform_ids = self.get_selection_transform_ids()
|
||
if not transform_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(transform_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(transform_ids) == 1:
|
||
gid = transform_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 transform_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 sync_scene_nodes_to_pick(self, nodes):
|
||
"""Sync transformed scene nodes to both pick data and visible static chunks."""
|
||
if not self.controller:
|
||
return
|
||
|
||
self._sync_pick_scene_binding()
|
||
self._sync_pick_root_transform()
|
||
|
||
valid_nodes = []
|
||
for node in nodes or []:
|
||
if not self._node_is_valid(node):
|
||
continue
|
||
duplicate = False
|
||
for existing in valid_nodes:
|
||
try:
|
||
if existing == node:
|
||
duplicate = True
|
||
break
|
||
except Exception:
|
||
pass
|
||
if not duplicate:
|
||
valid_nodes.append(node)
|
||
|
||
if not valid_nodes:
|
||
self._reset_pick_sync_cache()
|
||
return
|
||
|
||
affected_chunks = set()
|
||
|
||
for gid, obj_np in self.controller.id_to_object_np.items():
|
||
if not self._node_is_valid(obj_np):
|
||
continue
|
||
|
||
matched = False
|
||
for target in valid_nodes:
|
||
try:
|
||
if obj_np == target:
|
||
matched = True
|
||
break
|
||
except Exception:
|
||
pass
|
||
if not matched:
|
||
continue
|
||
|
||
is_attached = False
|
||
try:
|
||
is_attached = bool(obj_np.has_parent())
|
||
except Exception:
|
||
try:
|
||
is_attached = bool(obj_np.hasParent())
|
||
except Exception:
|
||
is_attached = False
|
||
|
||
is_visible = False
|
||
if is_attached:
|
||
try:
|
||
is_visible = not obj_np.is_hidden()
|
||
except Exception:
|
||
try:
|
||
is_visible = not obj_np.isHidden()
|
||
except Exception:
|
||
is_visible = True
|
||
|
||
pick_np = self.controller.id_to_pick_np.get(gid)
|
||
if pick_np and not pick_np.is_empty():
|
||
if is_visible:
|
||
try:
|
||
pick_np.show()
|
||
except Exception:
|
||
pass
|
||
try:
|
||
pick_np.set_mat(self.base.render, obj_np.get_mat(self.base.render))
|
||
except Exception:
|
||
try:
|
||
pick_np.setMat(self.base.render, obj_np.getMat(self.base.render))
|
||
except Exception:
|
||
pass
|
||
else:
|
||
try:
|
||
pick_np.hide()
|
||
except Exception:
|
||
pass
|
||
|
||
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
|
||
affected_chunks.add(chunk_id)
|
||
|
||
for chunk_id in affected_chunks:
|
||
chunk = self.controller.chunks.get(chunk_id)
|
||
if not chunk or chunk.get("dynamic_enabled"):
|
||
continue
|
||
try:
|
||
self.controller._rebuild_static_chunk(chunk_id)
|
||
except Exception:
|
||
pass
|
||
|
||
self._reset_pick_sync_cache()
|
||
|
||
def _update_outline_for_selection(self):
|
||
if not self._outline_manager:
|
||
return
|
||
transform_ids = self.get_selection_transform_ids()
|
||
if not self.controller or not transform_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 transform_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_flat_selection_mode(self):
|
||
if not self.controller:
|
||
return False
|
||
return not bool(getattr(self.controller, "id_to_object_np", {}) or {})
|
||
|
||
def get_selection_transform_ids(self):
|
||
if not self.controller or self.selected_name is None:
|
||
return []
|
||
return list(getattr(self, "transform_ids", []) or [])
|
||
|
||
def _is_root_selection(self):
|
||
return bool(
|
||
self.controller and
|
||
self.selected_name == getattr(self.controller, "tree_root_key", None)
|
||
)
|
||
|
||
def _is_top_level_source_child_selection(self, key=None):
|
||
if not self.controller:
|
||
return False
|
||
selected_key = key if key is not None else self.selected_name
|
||
if not selected_key or selected_key == getattr(self.controller, "tree_root_key", None):
|
||
return False
|
||
root_key = getattr(self.controller, "tree_root_key", None)
|
||
root_node = self.controller.tree_nodes.get(root_key, {}) if root_key else {}
|
||
top_level_children = root_node.get("children", []) or []
|
||
return selected_key in top_level_children
|
||
|
||
def _is_displayed_top_level_selection(self, key=None):
|
||
if not self.controller:
|
||
return False
|
||
selected_key = key if key is not None else self.selected_name
|
||
root_key = getattr(self.controller, "tree_root_key", None)
|
||
if not selected_key or not root_key or selected_key == root_key:
|
||
return False
|
||
|
||
current_key = selected_key
|
||
visited = set()
|
||
while current_key and current_key not in visited:
|
||
visited.add(current_key)
|
||
node = self.controller.tree_nodes.get(current_key)
|
||
if not node:
|
||
return False
|
||
parent_key = node.get("parent")
|
||
if parent_key == root_key:
|
||
return True
|
||
if not parent_key:
|
||
return False
|
||
if not self.controller.should_hide_tree_node(parent_key):
|
||
return False
|
||
current_key = parent_key
|
||
return False
|
||
|
||
def _resolve_top_level_source_child_key(self, key=None):
|
||
"""Return the direct child key under tree_root that owns the selection key."""
|
||
if not self.controller:
|
||
return None
|
||
selected_key = key if key is not None else self.selected_name
|
||
root_key = getattr(self.controller, "tree_root_key", None)
|
||
if not selected_key or not root_key or selected_key == root_key:
|
||
return None
|
||
|
||
current_key = selected_key
|
||
visited = set()
|
||
while current_key and current_key not in visited:
|
||
visited.add(current_key)
|
||
node = self.controller.tree_nodes.get(current_key)
|
||
if not node:
|
||
return None
|
||
parent_key = node.get("parent")
|
||
if parent_key == root_key:
|
||
return current_key
|
||
current_key = parent_key
|
||
return None
|
||
|
||
def _selection_equivalent_top_level_key(self, key=None):
|
||
"""Normalize wrapper selections to their equivalent top-level model key."""
|
||
if not self.controller:
|
||
return None
|
||
selected_key = key if key is not None else self.selected_name
|
||
if not selected_key:
|
||
return None
|
||
|
||
top_key = self._resolve_top_level_source_child_key(selected_key)
|
||
if not top_key:
|
||
return None
|
||
if selected_key == top_key:
|
||
return top_key
|
||
|
||
try:
|
||
selected_ids = set(self.controller.name_to_ids.get(selected_key, []) or [])
|
||
top_ids = set(self.controller.name_to_ids.get(top_key, []) or [])
|
||
except Exception:
|
||
return None
|
||
if not selected_ids or not top_ids:
|
||
return None
|
||
if selected_ids == top_ids:
|
||
return top_key
|
||
return None
|
||
|
||
def _should_use_model_root_for_top_level_selection(self, key=None):
|
||
if not self.controller:
|
||
return False
|
||
selected_key = key if key is not None else self.selected_name
|
||
root_key = getattr(self.controller, "tree_root_key", None)
|
||
if not selected_key or not root_key or selected_key == root_key:
|
||
return False
|
||
if not (
|
||
self._is_top_level_source_child_selection(selected_key)
|
||
or self._is_displayed_top_level_selection(selected_key)
|
||
):
|
||
return False
|
||
try:
|
||
root_node = self.controller.tree_nodes.get(root_key, {}) if root_key else {}
|
||
top_level_children = list(root_node.get("children", []) or [])
|
||
return len(top_level_children) <= 1
|
||
except Exception:
|
||
return False
|
||
|
||
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
|
||
|
||
has_dynamic_objects = bool(getattr(self.controller, "id_to_object_np", {}) or {})
|
||
transform_ids = self.get_selection_transform_ids()
|
||
|
||
if self._is_root_selection():
|
||
return self.model if self._node_is_valid(self.model) else None
|
||
|
||
if len(transform_ids) > 1:
|
||
proxy = getattr(self, "_group_proxy", None)
|
||
if proxy and self._node_is_valid(proxy):
|
||
return proxy
|
||
|
||
if self._should_use_model_root_for_top_level_selection():
|
||
return self.model if self._node_is_valid(self.model) else None
|
||
|
||
fallback_node = None
|
||
try:
|
||
fallback_node = getattr(self.controller, "key_to_node", {}).get(self.selected_name)
|
||
except Exception:
|
||
fallback_node = None
|
||
if self._node_is_valid(fallback_node):
|
||
return fallback_node
|
||
|
||
if not has_dynamic_objects:
|
||
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_runtime_material_node(self):
|
||
"""Return the runtime node that material editing should target."""
|
||
if self.controller and self.selected_name is not None:
|
||
fallback_node = getattr(self.controller, "key_to_node", {}).get(self.selected_name)
|
||
if self._node_is_valid(fallback_node):
|
||
return fallback_node
|
||
if not self.controller or not self.selected_ids:
|
||
return None
|
||
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_source_material_node(self):
|
||
"""Return the exact source-tree node for the currently edited material target."""
|
||
if not self.controller or not self.selected_ids:
|
||
return None
|
||
owner_key = self.controller.id_to_name.get(self.selected_ids[0])
|
||
if not owner_key:
|
||
return None
|
||
source_node = self._resolve_source_node_by_tree_key(owner_key)
|
||
if self._node_is_valid(source_node):
|
||
return source_node
|
||
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.get_selection_transform_ids()),
|
||
"is_root": self._is_root_selection(),
|
||
"is_group": len(self.get_selection_transform_ids()) > 1 and not self._is_root_selection(),
|
||
}
|
||
|
||
def estimate_selection_cost(self, key=None):
|
||
"""Estimate how expensive a selection would be in hybrid SSBO mode."""
|
||
if not self.controller:
|
||
return {
|
||
"key": key,
|
||
"object_count": 0,
|
||
"chunk_count": 0,
|
||
"is_root": False,
|
||
"is_top_level_like": False,
|
||
}
|
||
|
||
selected_key = key if key is not None else self.selected_name
|
||
if selected_key is None:
|
||
return {
|
||
"key": None,
|
||
"object_count": 0,
|
||
"chunk_count": 0,
|
||
"is_root": False,
|
||
"is_top_level_like": False,
|
||
}
|
||
|
||
transform_ids = list(getattr(self.controller, "name_to_ids", {}).get(selected_key, []) or [])
|
||
chunk_ids = set()
|
||
for transform_id in transform_ids:
|
||
chunk_id = getattr(self.controller, "id_to_chunk", {}).get(transform_id)
|
||
if isinstance(chunk_id, (tuple, list)):
|
||
chunk_id = chunk_id[0] if chunk_id else None
|
||
if chunk_id is not None:
|
||
chunk_ids.add(chunk_id)
|
||
|
||
return {
|
||
"key": selected_key,
|
||
"object_count": len(transform_ids),
|
||
"chunk_count": len(chunk_ids),
|
||
"is_root": bool(selected_key == getattr(self.controller, "tree_root_key", None)),
|
||
"is_top_level_like": bool(
|
||
self._is_top_level_source_child_selection(selected_key)
|
||
or self._is_displayed_top_level_selection(selected_key)
|
||
),
|
||
}
|
||
|
||
def get_selection_key(self):
|
||
if not self.controller or self.selected_name is None:
|
||
return None
|
||
return self.selected_name
|
||
|
||
def get_selection_source_node(self):
|
||
if not self.controller or self.selected_name is None:
|
||
return None
|
||
if self._should_use_model_root_for_top_level_selection():
|
||
source_children = self._get_source_root_children()
|
||
if len(source_children) == 1 and self._node_is_valid(source_children[0]):
|
||
return source_children[0]
|
||
source_node = self._resolve_source_node_by_tree_key(self.selected_name)
|
||
if self._node_is_valid(source_node):
|
||
return source_node
|
||
fallback_scene_node = self.get_selection_scene_node()
|
||
if self.is_source_tree_node(fallback_scene_node):
|
||
return fallback_scene_node
|
||
return None
|
||
|
||
def is_source_tree_node(self, node):
|
||
if not self._node_is_valid(node) or not self._node_is_valid(self.source_model_root):
|
||
return False
|
||
current = node
|
||
while self._node_is_valid(current):
|
||
if current == self.source_model_root:
|
||
return True
|
||
try:
|
||
current = current.get_parent()
|
||
except Exception:
|
||
try:
|
||
current = current.getParent()
|
||
except Exception:
|
||
return False
|
||
return False
|
||
|
||
def refresh_runtime_from_source(self, preserve_selection=True):
|
||
if not self._get_source_root_children():
|
||
return False
|
||
|
||
selected_key = self.selected_name if preserve_selection else None
|
||
try:
|
||
# Loading/rebuilding from an already authoritative source tree must not
|
||
# snapshot the transient runtime state back into source again.
|
||
# Otherwise scene-open can overwrite freshly restored transforms with
|
||
# stale/identity runtime values before the rebuild happens.
|
||
self._clear_runtime_state(preserve_source_models=True, snapshot_transform=False)
|
||
self._rebuild_runtime_from_source_root(highlight_root_name=None)
|
||
self.force_static_chunk_idle_state()
|
||
if preserve_selection and selected_key and self.controller:
|
||
try:
|
||
if (
|
||
selected_key == getattr(self.controller, "tree_root_key", None)
|
||
or selected_key in getattr(self.controller, "tree_nodes", {})
|
||
or selected_key in getattr(self.controller, "name_to_ids", {})
|
||
):
|
||
self.select_node(selected_key)
|
||
except Exception:
|
||
pass
|
||
return True
|
||
except Exception as e:
|
||
print(f"[SSBOEditor] 从 source 刷新 runtime 失败: {e}")
|
||
return False
|
||
|
||
def get_source_tree_stats(self):
|
||
root = getattr(self, "source_model_root", None)
|
||
stats = {
|
||
"valid": False,
|
||
"has_parent": False,
|
||
"hidden": False,
|
||
"stashed": False,
|
||
"descendants": 0,
|
||
"geom_nodes": 0,
|
||
"top_children": 0,
|
||
"parent_name": "",
|
||
}
|
||
if not self._node_is_valid(root):
|
||
return stats
|
||
|
||
stats["valid"] = True
|
||
try:
|
||
stats["has_parent"] = bool(root.has_parent())
|
||
except Exception:
|
||
pass
|
||
try:
|
||
stats["hidden"] = bool(root.is_hidden())
|
||
except Exception:
|
||
pass
|
||
try:
|
||
stats["stashed"] = bool(root.is_stashed())
|
||
except Exception:
|
||
pass
|
||
try:
|
||
parent = root.get_parent()
|
||
if parent and not parent.is_empty():
|
||
stats["parent_name"] = parent.get_name() or ""
|
||
except Exception:
|
||
pass
|
||
try:
|
||
stats["descendants"] = root.find_all_matches("**").get_num_paths()
|
||
except Exception:
|
||
pass
|
||
try:
|
||
stats["geom_nodes"] = len(list(root.find_all_matches("**/+GeomNode")))
|
||
except Exception:
|
||
pass
|
||
try:
|
||
stats["top_children"] = len([child for child in root.get_children() if not child.is_empty()])
|
||
except Exception:
|
||
pass
|
||
return stats
|
||
|
||
def force_static_chunk_idle_state(self):
|
||
"""Force hybrid chunk runtime back to fully static idle mode."""
|
||
controller = getattr(self, "controller", None)
|
||
chunks = getattr(controller, "chunks", None) if controller else None
|
||
if not controller or not isinstance(chunks, dict) or not chunks:
|
||
return False
|
||
|
||
rebuilt = 0
|
||
for chunk_id in sorted(chunks):
|
||
try:
|
||
controller._rebuild_static_chunk(chunk_id)
|
||
controller._set_chunk_dynamic(chunk_id, False)
|
||
rebuilt += 1
|
||
except Exception:
|
||
continue
|
||
|
||
if rebuilt:
|
||
print(f"[SSBOEditor] Forced static idle chunks: {rebuilt}")
|
||
return rebuilt > 0
|
||
|
||
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.is_source_tree_node(node):
|
||
source_key = self._resolve_tree_key_for_source_node(node)
|
||
if source_key:
|
||
return source_key
|
||
if self.model and node == self.model:
|
||
return getattr(self.controller, "tree_root_key", None)
|
||
try:
|
||
for key, mapped_node in getattr(self.controller, "key_to_node", {}).items():
|
||
if mapped_node == node:
|
||
return key
|
||
except Exception:
|
||
pass
|
||
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:
|
||
if target_key == self.selected_name:
|
||
return
|
||
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, snapshot_transform=True):
|
||
if snapshot_transform:
|
||
selected_top_synced = self._snapshot_selected_top_level_transform_to_source()
|
||
if not selected_top_synced:
|
||
self._snapshot_active_selection_transform_to_source()
|
||
self._stop_pick_sync_task()
|
||
self._reset_pick_sync_cache()
|
||
self._cleanup_group_proxy()
|
||
self.selected_name = None
|
||
self.selected_ids = []
|
||
self.transform_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 = {}
|
||
self._group_proxy_initial_mat = None
|
||
self._group_proxy_source_initial_net_mat = None
|
||
|
||
def update_selection_mask(self):
|
||
pass # No selection mask texture needed without custom shader
|
||
|
||
def select_node(self, key, sync_world_selection=True):
|
||
selected_top_synced = self._snapshot_selected_top_level_transform_to_source()
|
||
if not selected_top_synced:
|
||
self._snapshot_active_selection_transform_to_source()
|
||
# Clean up previous group proxy before changing selection
|
||
self._cleanup_group_proxy()
|
||
self._reset_pick_sync_cache()
|
||
|
||
self.selected_name = key
|
||
preferred_ids_fn = getattr(self.controller, "get_preferred_selection_ids", None)
|
||
if callable(preferred_ids_fn):
|
||
self.selected_ids = preferred_ids_fn(key)
|
||
else:
|
||
self.selected_ids = self.controller.name_to_ids.get(key, [])
|
||
self.transform_ids = list(self.controller.name_to_ids.get(key, []) or [])
|
||
has_dynamic_objects = bool(getattr(self.controller, "id_to_object_np", {}) or {})
|
||
is_root_selection = (
|
||
self.controller and
|
||
key == getattr(self.controller, "tree_root_key", None)
|
||
)
|
||
is_displayed_top_level_selection = self._is_displayed_top_level_selection(key)
|
||
use_model_root_for_top_level_selection = self._should_use_model_root_for_top_level_selection(key)
|
||
if sync_world_selection:
|
||
self._clear_editor_selection_visuals()
|
||
|
||
# Root selection should stay lightweight, but top-level imported model
|
||
# groups still need a stable proxy so their local transform can be
|
||
# snapshotted back into source_root correctly.
|
||
if (
|
||
is_root_selection
|
||
or use_model_root_for_top_level_selection
|
||
or not has_dynamic_objects
|
||
):
|
||
self.controller.set_active_ids([])
|
||
if self._outline_manager and not (not has_dynamic_objects and not is_root_selection):
|
||
self._outline_manager.clear()
|
||
fallback_scene_node = self.get_selection_scene_node()
|
||
if sync_world_selection:
|
||
self._sync_editor_selection_reference(fallback_scene_node)
|
||
if self._outline_manager and not is_root_selection and self._node_is_valid(fallback_scene_node):
|
||
self._outline_manager.set_targets([fallback_scene_node])
|
||
if sync_world_selection and not has_dynamic_objects:
|
||
selection = getattr(self.base, "selection", None)
|
||
if selection and self._node_is_valid(fallback_scene_node):
|
||
try:
|
||
selection.updateSelection(fallback_scene_node)
|
||
except Exception:
|
||
pass
|
||
if self._transform_gizmo:
|
||
if is_root_selection and self.model and not self.model.is_empty():
|
||
self._transform_gizmo.attach(self.model)
|
||
elif self._node_is_valid(fallback_scene_node):
|
||
self._transform_gizmo.attach(fallback_scene_node)
|
||
else:
|
||
self._transform_gizmo.detach()
|
||
self._stop_pick_sync_task()
|
||
return
|
||
|
||
self.controller.set_active_ids(self.transform_ids)
|
||
self._update_outline_for_selection()
|
||
|
||
if not self._transform_gizmo or not self.transform_ids:
|
||
if self._transform_gizmo:
|
||
self._transform_gizmo.detach()
|
||
return
|
||
|
||
if len(self.transform_ids) == 1:
|
||
# Single object: attach gizmo directly
|
||
obj_np = self.controller.id_to_object_np.get(self.transform_ids[0])
|
||
if obj_np and not obj_np.is_empty():
|
||
if sync_world_selection:
|
||
self._sync_editor_selection_reference(obj_np)
|
||
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.transform_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 = {}
|
||
self._group_proxy_initial_mat = None
|
||
self._group_proxy_source_initial_net_mat = None
|
||
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)
|
||
|
||
source_node = self.get_selection_source_node()
|
||
if self._node_is_valid(source_node):
|
||
try:
|
||
self._group_proxy_initial_mat = LMatrix4f(proxy.get_mat(self.model))
|
||
except Exception:
|
||
try:
|
||
self._group_proxy_initial_mat = LMatrix4f(proxy.getMat(self.model))
|
||
except Exception:
|
||
self._group_proxy_initial_mat = None
|
||
try:
|
||
self._group_proxy_source_initial_net_mat = LMatrix4f(
|
||
source_node.get_mat(self.source_model_root)
|
||
)
|
||
except Exception:
|
||
try:
|
||
self._group_proxy_source_initial_net_mat = LMatrix4f(
|
||
source_node.getMat(self.source_model_root)
|
||
)
|
||
except Exception:
|
||
self._group_proxy_source_initial_net_mat = None
|
||
|
||
if sync_world_selection:
|
||
self._sync_editor_selection_reference(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):
|
||
transform_ids = self.get_selection_transform_ids()
|
||
if self.selected_name and transform_ids:
|
||
first_id = transform_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
|
||
|
||
transform_ids = self.get_selection_transform_ids()
|
||
if transform_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 transform_ids:
|
||
self.controller.move_object(idx, acc)
|
||
|
||
return task.cont
|