Merge branch 'hu_migrate_on_geng' into geng_migrate_on_imgui_hu

# Conflicts:
#	imgui.ini
#	ui/panels/editor_panels_top.py
This commit is contained in:
ayuan9957 2026-03-18 15:28:22 +08:00
commit 04cb0dc441
14 changed files with 1497 additions and 160 deletions

View File

@ -905,7 +905,14 @@ class MyWorld(PanelDelegates, CoreWorld):
viewport = imgui.get_main_viewport()
dock_flags = imgui.DockNodeFlags_.passthru_central_node
if imgui_internal is not None:
dock_flags |= imgui_internal.DockNodeFlagsPrivate_.no_window_menu_button
# imgui_bundle不同版本下私有Dock标志位的枚举类型可能不兼容
# 直接位或会在Python 3.11的Flag枚举里触发异常
try:
private_flag = imgui_internal.DockNodeFlagsPrivate_.no_window_menu_button
dock_flags = imgui.DockNodeFlags_(int(dock_flags) | int(private_flag))
except Exception:
# 回退忽略私有标志不影响DockSpace主流程
pass
imgui.dock_space_over_viewport(0, viewport, dock_flags)
# 在第一帧应用样式

View File

@ -417,8 +417,10 @@ class ProjectManager:
temp_scene_file = os.path.join(scene_dir, f"{scene_name}.tmp{scene_ext}")
final_gui_info_file = scene_file.replace('.bam', '_gui.json')
temp_gui_info_file = temp_scene_file.replace('.bam', '_gui.json')
final_lui_info_file = scene_file.replace('.bam', '_lui.json')
temp_lui_info_file = temp_scene_file.replace('.bam', '_lui.json')
for temp_path in (temp_scene_file, temp_gui_info_file):
for temp_path in (temp_scene_file, temp_gui_info_file, temp_lui_info_file):
if os.path.exists(temp_path):
try:
os.remove(temp_path)
@ -434,12 +436,15 @@ class ProjectManager:
if os.path.exists(temp_gui_info_file):
os.replace(temp_gui_info_file, final_gui_info_file)
if os.path.exists(temp_lui_info_file):
os.replace(temp_lui_info_file, final_lui_info_file)
return True
except Exception as e:
print(f"原子保存场景失败: {e}")
return False
finally:
for temp_path in (temp_scene_file, temp_gui_info_file):
for temp_path in (temp_scene_file, temp_gui_info_file, temp_lui_info_file):
if os.path.exists(temp_path):
try:
os.remove(temp_path)

View File

@ -12,7 +12,7 @@ from pathlib import Path
from panda3d.core import (
ModelPool, ModelRoot, Filename, NodePath, GeomNode, Material, Vec4, Vec3,
MaterialAttrib, ColorAttrib, Point3, CollisionNode, CollisionSphere, CollisionBox,
BitMask32, TransparencyAttrib, LColor, TransformState, RenderModeAttrib, ShaderAttrib
BitMask32, TransparencyAttrib, LColor, TransformState, RenderModeAttrib, ShaderAttrib, TextureAttrib
)
from panda3d.egg import EggData, EggVertexPool
from direct.actor.Actor import Actor
@ -62,6 +62,104 @@ class SceneManagerIOMixin:
except Exception as e:
print(f"清理残留场景节点失败: {e}")
def _get_scene_sidecar_path(self, filename, suffix):
scene_root, _ = os.path.splitext(os.path.normpath(filename))
return f"{scene_root}{suffix}"
def _reset_lui_runtime_state(self, lui_manager):
if lui_manager is None or getattr(lui_manager, "lui_enabled", True) is False:
return True
apply_fn = getattr(lui_manager, "_apply_lui_structure_snapshot", None)
if callable(apply_fn):
try:
apply_fn({})
return True
except Exception as e:
print(f"清空LUI结构失败: {e}")
return False
clear_fn = getattr(lui_manager, "_clear_lui_structure_runtime", None)
if callable(clear_fn):
try:
clear_fn()
return True
except Exception as e:
print(f"清空LUI结构失败: {e}")
return False
return True
def _save_lui_snapshot_file(self, filename):
lui_manager = getattr(self.world, "lui_manager", None)
if lui_manager is None or getattr(lui_manager, "lui_enabled", True) is False:
print(" 当前会话未启用LUI跳过LUI结构保存")
return True
capture_fn = getattr(lui_manager, "_capture_lui_structure_snapshot", None)
if not callable(capture_fn):
print(" 未找到LUI结构快照接口跳过LUI结构保存")
return True
try:
snapshot = capture_fn() or {}
lui_info_file = self._get_scene_sidecar_path(filename, "_lui.json")
with open(lui_info_file, "w", encoding="utf-8") as f:
json.dump(snapshot, f, ensure_ascii=False, indent=4)
canvas_count = len(snapshot.get("canvases", []) or [])
component_count = len(snapshot.get("components", []) or [])
print(
f"✓ LUI信息已保存: {lui_info_file} "
f"(Canvas: {canvas_count}, Components: {component_count})"
)
return True
except Exception as e:
print(f"✗ 保存LUI信息失败: {e}")
import traceback
traceback.print_exc()
return False
def _load_lui_snapshot_file(self, filename):
lui_manager = getattr(self.world, "lui_manager", None)
if lui_manager is None or getattr(lui_manager, "lui_enabled", True) is False:
print(" 当前会话未启用LUI跳过LUI结构恢复")
return True
lui_info_file = self._get_scene_sidecar_path(filename, "_lui.json")
apply_fn = getattr(lui_manager, "_apply_lui_structure_snapshot", None)
if not os.path.exists(lui_info_file):
print(" 未找到LUI信息文件清空当前LUI结构")
return self._reset_lui_runtime_state(lui_manager)
if not callable(apply_fn):
print(" 未找到LUI结构恢复接口跳过LUI结构恢复")
return True
try:
with open(lui_info_file, "r", encoding="utf-8") as f:
content = f.read().strip()
snapshot = json.loads(content) if content else {}
apply_fn(snapshot)
canvas_count = len(snapshot.get("canvases", []) or [])
component_count = len(snapshot.get("components", []) or [])
print(
f"✓ LUI信息已恢复: {lui_info_file} "
f"(Canvas: {canvas_count}, Components: {component_count})"
)
return True
except json.JSONDecodeError as e:
print(f"✗ LUI信息文件格式错误: {e}")
except Exception as e:
print(f"✗ 加载LUI信息失败: {e}")
import traceback
traceback.print_exc()
return self._reset_lui_runtime_state(lui_manager)
def _collectGUIElementInfo(self, gui_node):
"""收集GUI元素的信息用于保存"""
try:
@ -257,6 +355,23 @@ class SceneManagerIOMixin:
all_nodes.extend(self.Spotlight)
all_nodes.extend(self.Pointlight)
# SSBO模式下先把运行时编辑后的层级变换同步回source_model_root
# 再从source树保存避免把chunk_*运行时结构写入scene.bam。
ssbo_editor = getattr(self.world, "ssbo_editor", None)
if ssbo_editor:
snapshot_fn = getattr(ssbo_editor, "_snapshot_top_level_transforms_to_source_root", None)
if callable(snapshot_fn):
try:
snapshot_fn()
except Exception as e:
print(f"同步SSBO源场景树变换失败: {e}")
snapshot_material_fn = getattr(ssbo_editor, "_snapshot_runtime_materials_to_source_root", None)
if callable(snapshot_material_fn):
try:
snapshot_material_fn()
except Exception as e:
print(f"同步SSBO源场景树材质失败: {e}")
def expand_scene_package_wrappers(nodes):
expanded_nodes = []
ssbo_editor = getattr(self.world, "ssbo_editor", None)
@ -303,12 +418,90 @@ class SceneManagerIOMixin:
expanded_nodes.extend(source_children)
continue
print(f"SSBO场景节点 {node.getName()} 缺少原始场景树,回退为包装根节点保存")
runtime_children = []
for child in node.getChildren():
try:
if not child or child.isEmpty():
continue
if child.getName() in {"render", "render2d", "aspect2d"}:
continue
runtime_children.append(child)
except Exception:
continue
if runtime_children:
print(f"展开SSBO场景节点 {node.getName()} -> 回退使用运行时场景树的 {len(runtime_children)} 个顶层子节点")
expanded_nodes.extend(runtime_children)
continue
print(f"SSBO场景节点 {node.getName()} 缺少可用场景树,回退为包装根节点保存")
expanded_nodes.append(node)
return expanded_nodes
all_nodes = expand_scene_package_wrappers(all_nodes)
def get_material_vector(material, *accessors):
for accessor_name in accessors:
accessor = getattr(material, accessor_name, None)
try:
value = accessor() if callable(accessor) else accessor
except Exception:
continue
if value is None:
continue
try:
return Vec4(
float(value.x),
float(value.y),
float(value.z),
float(getattr(value, "w", 1.0)),
)
except Exception:
continue
return None
def get_material_scalar(material, *accessors):
for accessor_name in accessors:
accessor = getattr(material, accessor_name, None)
try:
value = accessor() if callable(accessor) else accessor
except Exception:
continue
if value is None:
continue
try:
return float(value)
except Exception:
continue
return None
def save_material_tags(node, material):
if not material:
return
vector_tag_map = {
"material_ambient": ("getAmbient", "get_ambient", "ambient"),
"material_diffuse": ("getDiffuse", "get_diffuse", "diffuse"),
"material_specular": ("getSpecular", "get_specular", "specular"),
"material_emission": ("getEmission", "get_emission", "emission"),
"material_basecolor": ("getBaseColor", "get_base_color", "base_color"),
}
scalar_tag_map = {
"material_shininess": ("getShininess", "get_shininess", "shininess"),
"material_roughness": ("getRoughness", "get_roughness", "roughness"),
"material_metallic": ("getMetallic", "get_metallic", "metallic"),
"material_ior": ("getRefractiveIndex", "get_refractive_index", "refractive_index"),
}
for tag_name, accessors in vector_tag_map.items():
value = get_material_vector(material, *accessors)
if value is not None:
node.setTag(tag_name, str(value))
for tag_name, accessors in scalar_tag_map.items():
value = get_material_scalar(material, *accessors)
if value is not None:
node.setTag(tag_name, str(value))
# 创建用于保存GUI信息的JSON文件路径
gui_info_file = filename.replace('.bam', '_gui.json')
@ -350,18 +543,18 @@ class SceneManagerIOMixin:
state = node.getState()
# 如果有材质属性,保存为标签
material = None
if state.hasAttrib(MaterialAttrib.getClassType()):
mat_attrib = state.getAttrib(MaterialAttrib.getClassType())
material = mat_attrib.getMaterial()
if material:
# 保存材质属性到标签
node.setTag("material_ambient", str(material.getAmbient()))
node.setTag("material_diffuse", str(material.getDiffuse()))
node.setTag("material_specular", str(material.getSpecular()))
node.setTag("material_emission", str(material.getEmission()))
node.setTag("material_shininess", str(material.getShininess()))
if material.hasBaseColor():
node.setTag("material_basecolor", str(material.getBaseColor()))
material = mat_attrib.getMaterial() if mat_attrib else None
if material is None:
try:
if node.hasMaterial():
material = node.getMaterial()
except Exception:
material = None
if material:
save_material_tags(node, material)
# 保存特定类型节点的额外信息
if node.hasTag("light_type"):
@ -444,6 +637,31 @@ class SceneManagerIOMixin:
def strip_runtime_render_state(root_np):
for current in [root_np] + list(root_np.findAllMatches("**")):
try:
keep_material_shader = any(
current.hasTag(tag_name)
for tag_name in (
"material_effect_metallic_enabled",
"material_effect_default_texture_enabled",
"material_effect_parallax_enabled",
"material_render_effect_signature",
"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",
)
)
except Exception:
keep_material_shader = False
if keep_material_shader:
continue
try:
current.clearShader()
except Exception:
@ -465,6 +683,8 @@ class SceneManagerIOMixin:
if success:
print(f"✓ 场景保存成功: {filename}")
if not self._save_lui_snapshot_file(filename):
return False
else:
print("✗ 场景保存失败")
@ -805,6 +1025,218 @@ class SceneManagerIOMixin:
print(f"[SSBO] 统一导入失败,回退旧流程: {e}")
use_ssbo_scene_import = False
def parse_saved_color(color_str, default=None):
try:
cleaned = str(color_str).strip()
for prefix in ("LVecBase4f", "Vec4", "LColor"):
cleaned = cleaned.replace(prefix, "")
cleaned = cleaned.strip("() ")
components = [component.strip() for component in cleaned.split(",") if component.strip()]
if len(components) == 3:
components.append("1")
if len(components) != 4:
raise ValueError(f"invalid color component count: {cleaned}")
return Vec4(*map(float, components[:4]))
except Exception:
return default if default is not None else Vec4(1, 1, 1, 1)
def parse_saved_scalar(value):
try:
return float(value)
except Exception:
return None
def set_material_scalar(material, value, *setter_names):
if value is None:
return False
for setter_name in setter_names:
setter = getattr(material, setter_name, None)
if callable(setter):
setter(float(value))
return True
return False
def resolve_existing_material(node_path):
try:
if node_path.hasMaterial():
material = node_path.getMaterial()
if material is not None:
return material
except Exception:
pass
try:
node_state = node_path.getState()
if node_state.hasAttrib(MaterialAttrib.getClassType()):
material_attrib = node_state.getAttrib(MaterialAttrib.getClassType())
material = material_attrib.getMaterial() if material_attrib else None
if material is not None:
return material
except Exception:
pass
geom_paths = []
try:
if isinstance(node_path.node(), GeomNode):
geom_paths.append(node_path)
except Exception:
pass
try:
geom_paths.extend(list(node_path.findAllMatches("**/+GeomNode")))
except Exception:
pass
seen_paths = set()
for geom_path in geom_paths:
try:
key = geom_path.getKey()
except Exception:
key = id(geom_path)
if key in seen_paths:
continue
seen_paths.add(key)
try:
if geom_path.hasMaterial():
material = geom_path.getMaterial()
if material is not None:
return material
except Exception:
pass
try:
geom_node = geom_path.node()
for geom_index in range(geom_node.getNumGeoms()):
geom_state = geom_node.getGeomState(geom_index)
if not geom_state.hasAttrib(MaterialAttrib.getClassType()):
continue
material_attrib = geom_state.getAttrib(MaterialAttrib.getClassType())
material = material_attrib.getMaterial() if material_attrib else None
if material is not None:
return material
except Exception:
pass
try:
net_state = geom_path.getNetState()
if net_state.hasAttrib(MaterialAttrib.getClassType()):
material_attrib = net_state.getAttrib(MaterialAttrib.getClassType())
material = material_attrib.getMaterial() if material_attrib else None
if material is not None:
return material
except Exception:
pass
return None
def restore_saved_material_fallback(node_path):
material_tag_names = (
"material_ambient",
"material_diffuse",
"material_specular",
"material_emission",
"material_shininess",
"material_basecolor",
"material_roughness",
"material_metallic",
"material_ior",
)
if not any(node_path.hasTag(tag_name) for tag_name in material_tag_names):
return False
# BAM already carries the real material object; tags are only a legacy fallback.
if resolve_existing_material(node_path) is not None:
return False
material = Material()
material_changed = False
if node_path.hasTag("material_basecolor"):
base_color = parse_saved_color(node_path.getTag("material_basecolor"))
try:
if hasattr(material, "set_base_color"):
material.set_base_color(base_color)
elif hasattr(material, "setBaseColor"):
material.setBaseColor(base_color)
elif hasattr(material, "setDiffuse"):
material.setDiffuse(base_color)
material_changed = True
except Exception:
pass
if node_path.hasTag("material_emission"):
emission = parse_saved_color(node_path.getTag("material_emission"))
try:
if hasattr(material, "set_emission"):
material.set_emission(emission)
material_changed = True
elif hasattr(material, "setEmission"):
material.setEmission(emission)
material_changed = True
except Exception:
pass
if set_material_scalar(
material,
parse_saved_scalar(node_path.getTag("material_roughness")) if node_path.hasTag("material_roughness") else None,
"set_roughness",
"setRoughness",
):
material_changed = True
if set_material_scalar(
material,
parse_saved_scalar(node_path.getTag("material_metallic")) if node_path.hasTag("material_metallic") else None,
"set_metallic",
"setMetallic",
):
material_changed = True
if set_material_scalar(
material,
parse_saved_scalar(node_path.getTag("material_ior")) if node_path.hasTag("material_ior") else None,
"set_refractive_index",
"setRefractiveIndex",
):
material_changed = True
has_modern_pbr_tags = any(
node_path.hasTag(tag_name)
for tag_name in (
"material_basecolor",
"material_roughness",
"material_metallic",
"material_ior",
)
)
if not has_modern_pbr_tags:
if node_path.hasTag("material_ambient"):
material.setAmbient(parse_saved_color(node_path.getTag("material_ambient")))
material_changed = True
if node_path.hasTag("material_diffuse"):
material.setDiffuse(parse_saved_color(node_path.getTag("material_diffuse")))
material_changed = True
if node_path.hasTag("material_specular"):
material.setSpecular(parse_saved_color(node_path.getTag("material_specular")))
material_changed = True
if set_material_scalar(
material,
parse_saved_scalar(node_path.getTag("material_shininess")) if node_path.hasTag("material_shininess") else None,
"set_shininess",
"setShininess",
):
material_changed = True
if material_changed:
node_path.setMaterial(material, 1)
return True
return False
# 遍历场景中的所有节点
def processNode(nodePath, depth=0):
indent = " " * depth
@ -981,51 +1413,14 @@ class SceneManagerIOMixin:
traceback.print_exc()
# 恢复材质属性
def parseColor(color_str):
"""解析颜色字符串为Vec4"""
try:
color_str = color_str.replace('LVecBase4f', '').strip('()')
r, g, b, a = map(float, color_str.split(','))
return Vec4(r, g, b, a)
except:
return Vec4(1, 1, 1, 1)
if not is_model_root:
# 创建并恢复材质
material = Material()
material_changed = False
if nodePath.hasTag("material_ambient"):
material.setAmbient(parseColor(nodePath.getTag("material_ambient")))
material_changed = True
if nodePath.hasTag("material_diffuse"):
material.setDiffuse(parseColor(nodePath.getTag("material_diffuse")))
material_changed = True
if nodePath.hasTag("material_specular"):
material.setSpecular(parseColor(nodePath.getTag("material_specular")))
material_changed = True
if nodePath.hasTag("material_emission"):
material.setEmission(parseColor(nodePath.getTag("material_emission")))
material_changed = True
if nodePath.hasTag("material_shininess"):
material.setShininess(float(nodePath.getTag("material_shininess")))
material_changed = True
if nodePath.hasTag("material_basecolor"):
material.setBaseColor(parseColor(nodePath.getTag("material_basecolor")))
material_changed = True
if material_changed:
nodePath.setMaterial(material)
# SSBO场景包导入时禁止旧的标签材质回放
# 避免覆盖BAM内真实PBR/贴图状态导致发黑或金属贴图丢失。
if not use_ssbo_scene_import:
restore_saved_material_fallback(nodePath)
# 恢复颜色属性
if nodePath.hasTag("color"):
nodePath.setColor(parseColor(nodePath.getTag("color")))
nodePath.setColor(parse_saved_color(nodePath.getTag("color")))
# 处理特定类型的节点
if nodePath.hasTag("light_type"):
@ -1116,6 +1511,90 @@ class SceneManagerIOMixin:
print("\n开始重建父子关系...")
self._rebuildParentChildRelationships(loaded_nodes)
# 重新应用材质渲染effect尤其是金属性贴图所需的pbr_with_metallic
# 标签会从BAM读取但effect不会自动重建导致重开后金属性样式丢失。
try:
property_helpers = getattr(self.world, "property_helpers", None)
sync_effect_fn = getattr(property_helpers, "_sync_material_render_effect", None) if property_helpers else None
if callable(sync_effect_fn):
effect_synced = 0
effect_nodes = []
ssbo_controller = None
if use_ssbo_scene_import:
ssbo_editor = getattr(self.world, "ssbo_editor", None)
controller = getattr(ssbo_editor, "controller", None) if ssbo_editor else None
ssbo_controller = controller
if controller:
# SSBO模式优先对运行时对象重建effect真实显示对象
effect_nodes = list(controller.id_to_object_np.values())
if not effect_nodes:
effect_nodes = list(loaded_nodes.values())
def _has_metallic_texture(np):
try:
stages = np.findAllTextureStages()
for si in range(stages.getNumTextureStages()):
stage = stages.getTextureStage(si)
if not stage:
continue
name = (stage.getName() or "").lower()
if "metallic" in name:
return True
try:
if int(stage.getSort()) == 5:
return True
except Exception:
pass
except Exception:
pass
return False
for node in effect_nodes:
if not node or node.isEmpty():
continue
# 关键修复:按对象自身纹理槽决定 effect避免根节点标签导致整组对象统一金属性/发黑
has_metallic = _has_metallic_texture(node) or node.hasTag("material_effect_metallic_enabled")
has_any_effect = any(
node.hasTag(tag_name)
for tag_name in (
"material_effect_default_texture_enabled",
"material_effect_parallax_enabled",
"material_render_effect_signature",
)
)
has_effect_tags = has_metallic or has_any_effect
if not has_effect_tags:
continue
try:
if has_metallic:
node.setTag("material_effect_metallic_enabled", "1")
elif node.hasTag("material_effect_metallic_enabled"):
node.clearTag("material_effect_metallic_enabled")
sync_effect_fn(node, force=True, source_node=node)
effect_synced += 1
except Exception:
continue
if effect_synced:
print(f"[SceneLoad] 已重建材质effect节点数量: {effect_synced}")
# 关键修复effect应用在动态对象后需要重新烘焙静态chunk。
# 否则未选中时看到的仍是旧静态副本,点击切到动态对象才会正常。
if use_ssbo_scene_import and ssbo_controller and effect_synced:
rebuilt_chunks = 0
try:
for chunk_id in sorted(ssbo_controller.chunks):
try:
ssbo_controller._rebuild_static_chunk(chunk_id)
ssbo_controller._set_chunk_dynamic(chunk_id, False)
rebuilt_chunks += 1
except Exception:
continue
if rebuilt_chunks:
print(f"[SceneLoad] 已重建静态chunk数量: {rebuilt_chunks}")
except Exception as e:
print(f"[SceneLoad] 重建静态chunk失败: {e}")
except Exception as e:
print(f"[SceneLoad] 重建材质effect失败: {e}")
# 加载GUI信息并重新创建非3D的GUI元素
gui_info_file = filename.replace('.bam', '_gui.json')
if os.path.exists(gui_info_file):
@ -1141,6 +1620,8 @@ class SceneManagerIOMixin:
else:
print(" 未找到GUI信息文件")
self._load_lui_snapshot_file(filename)
# 移除临时场景节点
if not scene.isEmpty():
scene.removeNode()

View File

@ -44,6 +44,7 @@ class ObjectController:
self.id_to_chunk = {} # global_id -> chunk_id
self.id_to_object_np = {} # global_id -> dynamic object nodepath
self.id_to_pick_np = {} # global_id -> pick-scene nodepath
self.id_to_geom_index = {} # global_id -> owner GeomNode geom index
# chunk_id -> {
# "dynamic_np": NodePath,
@ -225,10 +226,21 @@ class ObjectController:
return False
name = (node["name"] or "").strip().lower()
if name != "root":
return False
if name == "root":
return len(node["children"]) > 0
return len(node["children"]) > 0
# Hide scene-package/runtime wrappers in virtual tree:
# - scene.bam container
# - chunk_* dynamic/static batching nodes
# - modelCollision_* helper nodes
if name.endswith(".bam") and len(node["children"]) > 0:
return True
if name.startswith("chunk_"):
return True
if name.startswith("modelcollision_"):
return True
return False
def _encode_id_color(self, vdata, object_id):
if not vdata.has_column("color"):
@ -274,6 +286,28 @@ class ObjectController:
return True
return False
def _copy_node_tags(self, src_np, dst_np):
"""Copy all string tags from source node to rebuilt runtime node."""
if not src_np or not dst_np:
return
try:
for tag_name in src_np.get_tag_keys():
try:
dst_np.set_tag(tag_name, src_np.get_tag(tag_name))
except Exception:
continue
return
except Exception:
pass
try:
for tag_name in src_np.getTagKeys():
try:
dst_np.setTag(tag_name, src_np.getTag(tag_name))
except Exception:
continue
except Exception:
pass
def bake_ids_and_collect(self, model):
"""
Bake IDs into vertex colors, flatten, then build vertex index.
@ -513,7 +547,10 @@ class ObjectController:
static_np = chunk["dynamic_np"].copy_to(self.model)
static_np.set_name(f"chunk_{chunk_id:04d}_static")
static_np.unstash()
static_np.flatten_strong()
# Keep the static representation as a per-object copy instead of flattening.
# Even medium flattening can still collapse or rewrite per-object PBR/effect
# state after save/load, which manifests as black materials until selection
# switches back to the dynamic objects.
chunk["static_np"] = static_np
chunk["dirty"] = False
@ -760,8 +797,10 @@ class ObjectController:
for chunk_id in list(self.active_chunks):
if chunk_id in target_chunks:
continue
if self.chunks[chunk_id]["dirty"]:
self._rebuild_static_chunk(chunk_id)
# Always rebuild static chunk when leaving dynamic edit mode.
# Material/texture edits may not set `dirty`, but still need to
# propagate from dynamic objects to static representation.
self._rebuild_static_chunk(chunk_id)
self._set_chunk_dynamic(chunk_id, False)
# Promote target chunks.
@ -795,25 +834,48 @@ class ObjectController:
owner_key = self._path_to_tree_key.get(str(np), self.tree_root_key)
world_mat = LMatrix4f(np.get_mat(model))
# Preserve the inherited render state, not just the local node state.
# Scene/package reload often stores material textures/effects on parent
# nodes; using only local state drops those bindings and makes rebuilt
# chunk_* runtime objects render black after reopening a project.
try:
node_state = np.get_net_state()
except Exception:
try:
node_state = np.getNetState()
except Exception:
try:
node_state = np.get_state()
except Exception:
try:
node_state = np.getState()
except Exception:
node_state = None
for gi in range(gnode.get_num_geoms()):
# Render geometry stays untouched (keep original material/color behavior).
render_geom = gnode.get_geom(gi).make_copy()
render_gnode = GeomNode(f"obj_{global_id}")
render_gnode.add_geom(render_geom, gnode.get_geom_state(gi))
geom_state = gnode.get_geom_state(gi)
try:
merged_state = node_state.compose(geom_state) if node_state is not None else geom_state
except Exception:
merged_state = geom_state
render_gnode.add_geom(render_geom, merged_state)
# Picking geometry gets encoded ID in vertex color.
pick_geom = gnode.get_geom(gi).make_copy()
pick_vdata = pick_geom.modify_vertex_data()
self._encode_id_color(pick_vdata, global_id)
pick_gnode = GeomNode(f"pick_{global_id}")
pick_gnode.add_geom(pick_geom, gnode.get_geom_state(gi))
pick_gnode.add_geom(pick_geom, merged_state)
world_pos = world_mat.get_row3(3)
chunk_id, chunk = self._allocate_spatial_chunk(scene_root, world_pos)
obj_np = chunk["dynamic_np"].attach_new_node(render_gnode)
obj_np.set_mat(world_mat)
self._copy_node_tags(np, obj_np)
pick_np = pick_root.attach_new_node(pick_gnode)
pick_np.set_mat(world_mat)
@ -821,6 +883,7 @@ class ObjectController:
self.id_to_chunk[global_id] = chunk_id
self.id_to_object_np[global_id] = obj_np
self.id_to_pick_np[global_id] = pick_np
self.id_to_geom_index[global_id] = gi
self.tree_nodes[owner_key]["local_ids"].append(global_id)
self.id_to_name[global_id] = owner_key
self.global_transforms.append(LMatrix4f(world_mat))

View File

@ -66,6 +66,7 @@ class SSBOEditor:
self.model = None
self.source_model = None
self.source_model_root = None
self._source_child_base_mats = {}
self.last_import_tree_key = None
self.last_import_root_name = None
self.ssbo = None
@ -279,7 +280,7 @@ class SSBOEditor:
except Exception as e:
print(f'修复黑色模型材质时出错: {e}')
def _load_source_model_from_path(self, model_path):
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
@ -295,8 +296,10 @@ class SSBOEditor:
if last_error:
raise RuntimeError(f"Failed to load model '{model_path}': {last_error}")
raise RuntimeError(f"Failed to load model '{model_path}'")
self._fixBlackMaterials(source_model)
self._repair_missing_textures(source_model, model_path)
if apply_black_fix:
self._fixBlackMaterials(source_model)
if repair_textures:
self._repair_missing_textures(source_model, model_path)
return source_model
def _set_node_name(self, node, name):
@ -374,6 +377,26 @@ class SSBOEditor:
return candidate
index += 1
def _capture_source_child_base_mats(self):
"""Capture baseline local mats for each top-level source child."""
self._source_child_base_mats = {}
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 []
@ -384,68 +407,413 @@ class SSBOEditor:
return list(root_node.get("children", []))
def _snapshot_top_level_transforms_to_source_root(self):
"""Persist current top-level imported model transforms back into the source scene root."""
"""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
source_children = {}
for child in self._iter_children(self.source_model_root):
try:
source_children[child.get_name()] = child
except Exception:
try:
source_children[child.getName()] = child
except Exception:
continue
for key in self._get_top_level_group_keys():
display_name = self.controller.display_names.get(key, key)
source_child = source_children.get(display_name)
if not source_child:
grouped_entries = {}
for gid, obj_np in self.controller.id_to_object_np.items():
if not self._node_is_valid(obj_np):
continue
group_ids = self.controller.name_to_ids.get(key, [])
if not group_ids:
owner_key = self.controller.id_to_name.get(gid)
if not owner_key or owner_key == getattr(self.controller, "tree_root_key", None):
continue
representative_id = None
for gid in group_ids:
obj_np = self.controller.id_to_object_np.get(gid)
if obj_np and not obj_np.is_empty():
representative_id = gid
break
if representative_id is None:
source_node = self._resolve_source_node_by_tree_key(owner_key)
if not self._node_is_valid(source_node):
continue
try:
current_mat = LMatrix4f(self.controller.id_to_object_np[representative_id].get_mat(self.model))
current_net_mat = LMatrix4f(obj_np.get_mat(self.model))
except Exception:
try:
current_mat = LMatrix4f(self.controller.id_to_object_np[representative_id].getMat(self.model))
current_net_mat = LMatrix4f(obj_np.getMat(self.model))
except Exception:
continue
if representative_id >= len(self.controller.global_transforms):
existing_entry = grouped_entries.get(owner_key)
if existing_entry is None:
grouped_entries[owner_key] = {
"source_node": source_node,
"current_net_mat": current_net_mat,
}
for owner_key in sorted(grouped_entries.keys(), key=lambda key: str(key).count("/")):
entry = grouped_entries.get(owner_key) or {}
source_node = entry.get("source_node")
current_net_mat = entry.get("current_net_mat")
if not self._node_is_valid(source_node) or current_net_mat is None:
continue
original_mat = LMatrix4f(self.controller.global_transforms[representative_id])
inv_original = LMatrix4f(original_mat)
try:
inv_original.invertInPlace()
source_parent = source_node.get_parent()
except Exception:
try:
inv_original.invert_in_place()
source_parent = source_node.getParent()
except Exception:
source_parent = None
parent_net_mat = LMatrix4f.ident_mat()
if self._node_is_valid(source_parent) and source_parent != self.source_model_root:
try:
parent_net_mat = LMatrix4f(source_parent.get_mat(self.source_model_root))
except Exception:
try:
parent_net_mat = LMatrix4f(source_parent.getMat(self.source_model_root))
except Exception:
parent_net_mat = LMatrix4f.ident_mat()
inv_parent_mat = LMatrix4f(parent_net_mat)
try:
inv_parent_mat.invertInPlace()
except Exception:
try:
inv_parent_mat.invert_in_place()
except Exception:
continue
delta_mat = current_mat * inv_original
local_mat = current_net_mat * inv_parent_mat
try:
source_child.set_mat(delta_mat * source_child.get_mat())
source_node.set_mat(local_mat)
except Exception:
try:
source_child.setMat(delta_mat * source_child.getMat())
source_node.setMat(local_mat)
except Exception:
continue
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 _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
source_snapshot = None
if callable(capture_snapshot_fn):
try:
source_snapshot = capture_snapshot_fn(obj_np)
except Exception:
source_snapshot = None
source_node_key = id(source_node)
entry = grouped_entries.get(source_node_key)
candidate_score = _snapshot_score(source_snapshot, obj_np)
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": source_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)
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)
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):
"""Remove runtime SSBO controller/model state while optionally keeping source snapshots."""
self.clear_selection()
@ -482,6 +850,7 @@ class SSBOEditor:
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):
@ -589,12 +958,27 @@ class SSBOEditor:
print(f"[SSBOEditor] Model loaded. Total objects: {count}")
def load_model(self, model_path, keep_source_model=False, append=False):
def load_model(
self,
model_path,
keep_source_model=False,
append=False,
scene_package_import=False,
):
"""Load and process one model into the aggregated SSBO scene."""
print(f"[SSBOEditor] Loading model: {model_path}")
source_model = self._load_source_model_from_path(model_path)
# 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
should_fix_black_materials = not scene_package_import
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:
if model_name and not scene_package_import:
self._set_node_name(source_model, model_name)
if append and self.source_model_root:
@ -605,6 +989,46 @@ class SSBOEditor:
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 = []
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
imported_child = child.copyTo(source_root)
imported_roots.append(imported_child)
except Exception:
continue
if not imported_roots:
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_roots = [imported_root]
self.source_model = source_root
self._restore_saved_material_bindings_from_tags(source_root)
self._capture_source_child_base_mats()
self._rebuild_runtime_from_source_root(highlight_root_name=None)
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)
@ -615,6 +1039,7 @@ class SSBOEditor:
else:
self.source_model = source_root
self._capture_source_child_base_mats()
self._rebuild_runtime_from_source_root(highlight_root_name=unique_root_name)
return imported_root
@ -1372,14 +1797,22 @@ class SSBOEditor:
def on_mouse_click(self):
io = imgui.get_io()
if io.want_capture_mouse: return
# Skip SSBO picking when user is interacting with the TransformGizmo,
# otherwise pick_object would clear the selection and detach the gizmo
# before the gizmo's own mouse handler fires.
if self._transform_gizmo and self._transform_gizmo.is_hovering:
return
if self.base.mouseWatcherNode.has_mouse():
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()
@ -1652,7 +2085,14 @@ class SSBOEditor:
if self._is_root_selection():
return self.model if self._node_is_valid(self.model) else None
if len(self.selected_ids) == 1:
if len(self.selected_ids) > 1:
proxy = getattr(self, "_group_proxy", None)
if proxy and self._node_is_valid(proxy):
# Apply the original first node's material/tags to proxy transparently?
# The property panel gets the proxy node for transform edits.
return proxy
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

View File

@ -324,7 +324,7 @@ class LUIFunctionPropertiesMixin:
manager.luiFunction._draw_text_font_size_editor(manager, index, comp_data, comp_obj)
draw_color = manager.luiFunction._draw_text_color_editor(manager, index, comp_data)
manager.luiFunction._sync_text_or_button_color(comp_obj, comp_type, draw_color)
manager.luiFunction._sync_text_or_button_color(manager, comp_obj, comp_type, draw_color)
if comp_type == 'Button':
manager.luiFunction._draw_button_texture_controls(manager, index, comp_data, comp_obj)
@ -387,9 +387,9 @@ class LUIFunctionPropertiesMixin:
def _sync_text_or_button_color(manager, comp_obj, comp_type, draw_color):
# 保留原行为:每帧同步当前编辑色,避免按钮状态贴图切换后颜色不同步。
if comp_type == 'Button':
manager.luiFunction._apply_button_layout_color(comp_obj, draw_color)
manager.luiFunction._apply_button_layout_color(manager, comp_obj, draw_color)
else:
manager.luiFunction._apply_text_color(comp_obj, draw_color)
manager.luiFunction._apply_text_color(manager, comp_obj, draw_color)
imgui.separator()
imgui.spacing()
@ -436,7 +436,7 @@ class LUIFunctionPropertiesMixin:
lambda: manager.luiFunction._clear_button_custom_textures(manager, comp_data, comp_obj),
)
manager.luiFunction._draw_button_texture_path_labels(comp_data)
manager.luiFunction._draw_button_texture_path_labels(manager, comp_data)
def _draw_button_texture_pick_button(manager, index, comp_data, comp_obj, button_label, variant):
if not imgui.button(button_label, (160, 20)):
@ -1382,7 +1382,7 @@ class LUIFunctionPropertiesMixin:
if changed:
manager._apply_component_change_with_history(
index,
lambda: manager.luiFunction._set_checkbox_checked(comp_data, comp_obj, new_checked),
lambda: manager.luiFunction._set_checkbox_checked(manager, comp_data, comp_obj, new_checked),
)
def _set_checkbox_checked(manager, comp_data, comp_obj, new_checked):
@ -1497,7 +1497,7 @@ class LUIFunctionPropertiesMixin:
sel_id = opt_ids[new_index]
manager._apply_component_change_with_history(
index,
lambda: manager.luiFunction._set_selectbox_selected_option(comp_data, comp_obj, sel_id),
lambda: manager.luiFunction._set_selectbox_selected_option(manager, comp_data, comp_obj, sel_id),
)
imgui.spacing()
@ -1526,7 +1526,7 @@ class LUIFunctionPropertiesMixin:
if dirty:
manager._apply_component_change_with_history(
index,
lambda: manager.luiFunction._apply_selectbox_options(comp_data, comp_obj, new_labels, current_selected),
lambda: manager.luiFunction._apply_selectbox_options(manager, comp_data, comp_obj, new_labels, current_selected),
)
def _set_selectbox_selected_option(manager, comp_data, comp_obj, selected_option_id):
@ -1682,7 +1682,7 @@ class LUIFunctionPropertiesMixin:
if imgui.button("清除音频", (90, 24)):
manager._apply_component_change_with_history(
index,
lambda: manager.luiFunction._clear_audio_for_component(comp_data),
lambda: manager.luiFunction._clear_audio_for_component(manager, comp_data),
)
if imgui.button("Select Audio", (110, 24)):
@ -1928,7 +1928,7 @@ class LUIFunctionPropertiesMixin:
def _clear_video_source(manager, comp_data, comp_obj=None):
if comp_data.get("audio_from_video"):
manager.luiFunction._clear_audio_for_component(comp_data)
manager.luiFunction._clear_audio_for_component(manager, comp_data)
manager.luiFunction._clear_video_audio_runtime(manager, comp_data)
target_obj = comp_obj or comp_data.get("object")
if target_obj is not None:

View File

@ -452,29 +452,22 @@ class AppActions:
def _is_mouse_over_imgui(self):
"""检测鼠标是否在ImGui窗口上"""
try:
# 检查是否有任何ImGui窗口想要捕获鼠标
if hasattr(imgui, 'get_io') and imgui.get_io().want_capture_mouse:
point = self._get_mouse_window_point()
if point and self._is_point_in_known_imgui_rects(point):
return True
# 检查鼠标是否在任何ImGui窗口内
mouse_pos = self.mouseWatcherNode.getMouse()
if not mouse_pos:
return False
# 简单的边界检查(可以根据需要扩展)
display_size = imgui.get_io().display_size
mouse_x = mouse_pos.get_x() * display_size.x / 2 + display_size.x / 2
mouse_y = display_size.y - (mouse_pos.get_y() * display_size.y / 2 + display_size.y / 2)
# 检查是否在常见的ImGui界面区域内
# 这里可以根据实际的ImGui窗口位置进行更精确的检测
if mouse_x < 300 and mouse_y < 200: # 左上角区域(菜单栏)
return True
if mouse_x < 300 and mouse_y > display_size.y - 200: # 左下角区域(工具栏)
return True
if mouse_x > display_size.x - 300 and mouse_y < 200: # 右上角区域
return True
try:
if imgui.is_any_item_active() or imgui.is_any_item_hovered():
return True
except Exception:
pass
try:
if point and imgui.is_any_window_hovered() and self._is_point_in_known_imgui_rects(point):
return True
except Exception:
pass
return False
except Exception as e:
print(f"ImGui界面检测失败: {e}")
@ -484,28 +477,64 @@ class AppActions:
def processImGuiMouseClick(self, x, y):
"""处理ImGui鼠标点击事件返回是否消费了该事件"""
try:
# ImGui优先策略如果ImGui想要捕获鼠标则由ImGui处理
if hasattr(imgui, 'get_io') and imgui.get_io().want_capture_mouse:
point = (float(x), float(y))
if self._is_point_in_known_imgui_rects(point):
return True
# 检查是否有任何ImGui窗口悬停
try:
if imgui.is_any_window_hovered():
if imgui.is_any_item_active() or imgui.is_any_item_hovered():
return True
except AttributeError:
# 如果方法不存在,跳过这个检查
except Exception:
pass
# 检查鼠标是否在ImGui界面区域内
if self._is_mouse_over_imgui():
return True
# 如果以上条件都不满足则让3D场景处理该事件
try:
if imgui.is_any_window_hovered() and self._is_point_in_known_imgui_rects(point):
return True
except Exception:
pass
return False
except Exception as e:
print(f"ImGui鼠标点击处理失败: {e}")
return False
def _get_mouse_window_point(self):
try:
if not self.mouseWatcherNode.hasMouse():
return None
mouse_pos = self.mouseWatcherNode.getMouse()
if not mouse_pos:
return None
display_size = imgui.get_io().display_size
return (
float(mouse_pos.get_x() * display_size.x / 2 + display_size.x / 2),
float(display_size.y - (mouse_pos.get_y() * display_size.y / 2 + display_size.y / 2)),
)
except Exception:
return None
@staticmethod
def _point_in_rect(point, rect):
if not point or not rect:
return False
x, y = point
rx, ry, rw, rh = rect
return rx <= x <= rx + rw and ry <= y <= ry + rh
def _is_point_in_known_imgui_rects(self, point):
for rect_name in (
"_resource_manager_window_rect",
"_scene_tree_window_rect",
"_property_panel_window_rect",
"_script_panel_window_rect",
"_console_window_rect",
"_toolbar_window_rect",
):
if self._point_in_rect(point, getattr(self, rect_name, None)):
return True
return False
# ==================== 消息系统 ====================
@ -1264,7 +1293,16 @@ class AppActions:
except Exception:
source_children = []
if len(source_children) > 1 and not scene_package_import:
if scene_package_import:
if len(source_children) == 1:
try:
model_np.setName(source_children[0].getName())
except Exception:
pass
else:
# 项目场景加载不使用文件名(scene.bam)作为场景树根名称
model_np.setName("场景模型")
elif len(source_children) > 1:
model_np.setName("\u5bfc\u5165\u6a21\u578b")
elif file_path:
model_np.setName(os.path.basename(file_path))
@ -1377,6 +1415,7 @@ class AppActions:
file_path,
keep_source_model=scene_package_import,
append=not scene_package_import,
scene_package_import=scene_package_import,
)
return self._refresh_ssbo_runtime_import_bindings(
file_path=file_path,

View File

@ -19,6 +19,7 @@ class EditorPanelsLeftMixin:
with self.app.style_manager.begin_styled_window("场景树", self.app.showSceneTree, flags) as (_, opened):
if not opened:
self.app.showSceneTree = False
self.app._scene_tree_window_rect = None
return
self.app.showSceneTree = opened
@ -72,6 +73,15 @@ class EditorPanelsLeftMixin:
def _get_scene_tree_models(self):
models = []
# SSBO模式下场景树应以SSBO聚合根为唯一模型入口避免混入scene_manager残留节点
# (如 scene.bam / chunk_* 运行时包装节点)导致树结构异常。
if getattr(self.app, "use_ssbo_mouse_picking", False):
ssbo_editor = getattr(self.app, "ssbo_editor", None)
ssbo_model = getattr(ssbo_editor, "model", None) if ssbo_editor else None
if ssbo_model and not ssbo_model.isEmpty() and ssbo_model.hasParent():
return [ssbo_model]
return []
if hasattr(self.app, "scene_manager") and self.app.scene_manager and hasattr(self.app.scene_manager, "models"):
models.extend([m for m in self.app.scene_manager.models if m and not m.isEmpty()])
@ -421,6 +431,7 @@ class EditorPanelsLeftMixin:
with self.app.style_manager.begin_styled_window("资源管理器", self.app.showResourceManager, flags) as (_, opened):
if not opened:
self.app.showResourceManager = False
self.app._resource_manager_window_rect = None
return
self.app.showResourceManager = opened

View File

@ -143,6 +143,7 @@ class EditorPanelsRightMixin(
with self.app.style_manager.begin_styled_window("属性面板", self.app.showPropertyPanel, flags) as (_, opened):
if not opened:
self.app.showPropertyPanel = False
self.app._property_panel_window_rect = None
return
self.app.showPropertyPanel = opened

View File

@ -8,7 +8,19 @@ class EditorPanelsRightMaterialMixin:
self._material_edit_sessions = {}
return self._material_edit_sessions
def _ensure_node_materials_are_editable(self, node):
ensure_unique_fn = getattr(self.app, "_ensure_unique_materials_for_node", None)
if callable(ensure_unique_fn):
try:
materials = ensure_unique_fn(node)
if materials:
return materials
except Exception:
pass
return self.app._get_node_materials(node)
def _begin_material_edit_session(self, node, session_key):
self._ensure_node_materials_are_editable(node)
sessions = self._ensure_material_edit_sessions()
sessions.setdefault(session_key, self.app._capture_node_material_snapshot(node))
@ -42,12 +54,14 @@ class EditorPanelsRightMaterialMixin:
self._record_material_snapshot_command(node, before_snapshot, after_snapshot)
def _apply_material_change_with_history(self, node, apply_callback):
self._ensure_node_materials_are_editable(node)
before_snapshot = self.app._capture_node_material_snapshot(node)
apply_callback()
after_snapshot = self.app._capture_node_material_snapshot(node)
self._record_material_snapshot_command(node, before_snapshot, after_snapshot)
def _select_texture_for_material_with_history(self, node, material, texture_type):
self._ensure_node_materials_are_editable(node)
before_snapshot = self.app._capture_node_material_snapshot(node)
changed = self._select_texture_for_material(node, material, texture_type)
if not changed:
@ -58,7 +72,7 @@ class EditorPanelsRightMaterialMixin:
def _draw_appearance_properties(self, node):
"""绘制材质属性Unity风格主材质入口"""
materials = self.app._get_node_materials(node)
materials = self._ensure_node_materials_are_editable(node)
if not materials:
fallback_material = self.app._ensure_material_for_node(node)
materials = [fallback_material] if fallback_material else []
@ -168,7 +182,7 @@ class EditorPanelsRightMaterialMixin:
def _draw_material_properties(self, node):
"""绘制材质属性"""
materials = self.app._get_node_materials(node)
materials = self._ensure_node_materials_are_editable(node)
if not materials:
imgui.text_colored((0.5, 0.5, 0.5, 1.0), "无材质")

View File

@ -443,9 +443,18 @@ class EditorPanelsTopMixin:
with imgui_ctx.begin("工具栏", self.app.showToolbar, flags) as (_, opened):
if not opened:
self.app.showToolbar = False
self.app._toolbar_window_rect = None
return
self.app.showToolbar = opened
window_pos = imgui.get_window_pos()
window_size = imgui.get_window_size()
self.app._toolbar_window_rect = (
float(window_pos.x),
float(window_pos.y),
float(window_size.x),
float(window_size.y),
)
command_manager = getattr(self.app, "command_manager", None)
can_undo = command_manager.can_undo() if command_manager else False
can_redo = command_manager.can_redo() if command_manager else False

View File

@ -16,8 +16,10 @@ class PanelDelegates:
return False
if require_attached:
try:
return bool(node.hasParent())
except Exception:
# Add debug output
return bool(node.has_parent() if hasattr(node, "has_parent") else node.hasParent())
except Exception as e:
print(f"[PanelDelegates] Error in has_parent: {e}")
return False
return True
@ -26,14 +28,16 @@ class PanelDelegates:
ssbo_editor = getattr(self, "ssbo_editor", None)
if ssbo_editor and hasattr(ssbo_editor, "has_active_selection") and ssbo_editor.has_active_selection():
ssbo_node = ssbo_editor.get_selection_scene_node()
return ssbo_node if self._node_is_valid(ssbo_node, require_attached=True) else None
if ssbo_node and self._node_is_valid(ssbo_node, require_attached=False):
return ssbo_node
return None
selection = getattr(self, "selection", None)
if selection and hasattr(selection, "getSelectedNode"):
node = selection.getSelectedNode()
else:
node = getattr(selection, "selectedNode", None) if selection else None
return node if self._node_is_valid(node, require_attached=True) else None
return node if self._node_is_valid(node, require_attached=False) else None
def _get_ssbo_selection_summary(self):
ssbo_editor = getattr(self, "ssbo_editor", None)

View File

@ -880,6 +880,109 @@ class PropertyHelpers:
if node and hasattr(node, "hasTag") and node.hasTag(tag_name):
texture_tags[texture_type] = node.getTag(tag_name)
# Fallback: some runtime nodes inherit textures from parent/root state and
# therefore have no explicit material_texture_* tags on the selected node.
# Capture the actual texture bindings as stable paths so save/load can
# persist them back into the source scene tree.
if node and not node.isEmpty():
texture_slots = self._get_material_texture_slots()
slot_to_type = {slot: tex_type for tex_type, slot in texture_slots.items()}
def record_texture_binding(texture_type, texture):
if not texture_type or texture_type in texture_tags or not texture:
return
resolved_path = ""
try:
if texture.hasFullpath():
fullpath = texture.getFullpath()
try:
resolved_path = fullpath.toOsSpecific()
except Exception:
resolved_path = str(fullpath)
except Exception:
resolved_path = ""
resolved_path = os.path.normpath(str(resolved_path).strip()) if resolved_path else ""
if not resolved_path or not os.path.exists(resolved_path):
try:
texture_name = os.path.normpath(str(texture.getName() or "").strip())
except Exception:
texture_name = ""
if texture_name and os.path.exists(texture_name):
resolved_path = texture_name
if resolved_path:
texture_tags[texture_type] = resolved_path
def infer_texture_type(stage):
if not stage:
return None
try:
stage_name = (stage.getName() or "").strip().lower()
except Exception:
stage_name = ""
if stage_name.endswith("_map"):
inferred_type = stage_name[:-4]
if inferred_type in texture_slots:
return inferred_type
try:
return slot_to_type.get(int(stage.getSort()))
except Exception:
return None
try:
texture_stages = node.findAllTextureStages()
except Exception:
texture_stages = None
if texture_stages:
for stage_index in range(texture_stages.getNumTextureStages()):
try:
stage = texture_stages.getTextureStage(stage_index)
except Exception:
continue
texture_type = infer_texture_type(stage)
try:
texture = node.getTexture(stage)
except Exception:
texture = None
record_texture_binding(texture_type, texture)
# Some imported/runtime nodes keep effective textures only in the
# inherited RenderState. Read TextureAttrib from net state as a
# second fallback so SSBO save can persist those bindings too.
if len(texture_tags) < len(texture_slots):
try:
from panda3d.core import TextureAttrib
net_state = node.getNetState()
if net_state.hasAttrib(TextureAttrib.getClassType()):
texture_attrib = net_state.getAttrib(TextureAttrib.getClassType())
if texture_attrib:
try:
num_on = texture_attrib.getNumOnStages()
get_stage = texture_attrib.getOnStage
get_texture = texture_attrib.getOnTexture
except Exception:
num_on = 0
get_stage = None
get_texture = None
for stage_index in range(int(num_on or 0)):
if get_stage is None or get_texture is None:
break
try:
stage = get_stage(stage_index)
texture = get_texture(stage)
except Exception:
continue
texture_type = infer_texture_type(stage)
record_texture_binding(texture_type, texture)
except Exception:
pass
effect_tags = {}
for tag_name in (
"material_effect_metallic_enabled",
@ -888,6 +991,14 @@ class PropertyHelpers:
):
effect_tags[tag_name] = bool(node and hasattr(node, "hasTag") and node.hasTag(tag_name))
if texture_tags:
if texture_tags.get("metallic"):
effect_tags["material_effect_metallic_enabled"] = True
if texture_tags.get("parallax"):
effect_tags["material_effect_parallax_enabled"] = True
if any(texture_path for texture_path in texture_tags.values()):
effect_tags["material_effect_default_texture_enabled"] = True
return {
"materials": material_entries,
"node_state": {
@ -1095,6 +1206,142 @@ class PropertyHelpers:
unique_materials.append(material)
return unique_materials
def _clone_material_for_node(self, material, node):
"""Clone one material so edits stay scoped to the selected node only."""
try:
from panda3d.core import Material
cloned_material = Material(material)
source_name = ""
try:
source_name = material.get_name() or ""
except Exception:
try:
source_name = material.getName() or ""
except Exception:
source_name = ""
node_name = self._get_node_name(node, "node") if hasattr(self, "_get_node_name") else "node"
clone_name = f"{source_name or 'material'}__editable__{node_name}"
try:
cloned_material.set_name(clone_name)
except Exception:
try:
cloned_material.setName(clone_name)
except Exception:
pass
return cloned_material
except Exception:
return material
def _ensure_unique_materials_for_node(self, node):
"""Detach shared/inherited materials so editing one child does not affect siblings."""
try:
from panda3d.core import MaterialAttrib
if not node or node.isEmpty():
return []
materials = self._get_node_materials(node)
if not materials:
return []
current_signature = tuple(
self._get_material_identity_key(material)
for material in materials
if material is not None
)
try:
stored_signature = tuple(node.getPythonTag("_editable_material_signature"))
except Exception:
stored_signature = None
if stored_signature == current_signature:
return materials
node_material_key = None
try:
if node.hasMaterial():
node_material_key = self._get_material_identity_key(node.getMaterial())
except Exception:
node_material_key = None
changed = False
cloned_materials = []
for material in materials:
if material is None:
continue
cloned_material = self._clone_material_for_node(material, node)
cloned_materials.append(cloned_material)
source_key = self._get_material_identity_key(material)
if cloned_material is material:
continue
try:
if node_material_key is not None and source_key == node_material_key:
node.setMaterial(cloned_material, 1)
changed = True
except Exception:
pass
geom_paths = self._get_geom_paths_for_material(node, material)
if not geom_paths and node_material_key is None:
try:
node.setMaterial(cloned_material, 1)
changed = True
except Exception:
pass
for geom_path in geom_paths:
try:
geom_path.setMaterial(cloned_material, 1)
changed = True
except Exception:
pass
try:
geom_path.setState(geom_path.getState().setAttrib(MaterialAttrib.make(cloned_material)))
except Exception:
pass
try:
geom_node = geom_path.node()
for geom_index in range(geom_node.getNumGeoms()):
geom_state = geom_node.getGeomState(geom_index)
geom_node.setGeomState(
geom_index,
geom_state.setAttrib(MaterialAttrib.make(cloned_material)),
)
changed = True
except Exception:
pass
if changed:
self._invalidate_material_render_cache()
latest_materials = self._get_node_materials(node)
if not latest_materials:
latest_materials = cloned_materials
latest_signature = tuple(
self._get_material_identity_key(material)
for material in latest_materials
if material is not None
)
try:
node.setPythonTag("_editable_material_signature", latest_signature)
except Exception:
pass
return latest_materials
except Exception as e:
print(f"隔离节点材质实例失败: {e}")
return self._get_node_materials(node)
def _get_material_identity_key(self, material):
try:
return getattr(material, "this", None) or id(material)
@ -1821,7 +2068,21 @@ class PropertyHelpers:
def _reset_material(self, node):
"""重置节点材质"""
try:
materials = list(node.find_all_materials())
materials = self._ensure_unique_materials_for_node(node)
if not materials:
fallback_material = self._ensure_material_for_node(node)
materials = [fallback_material] if fallback_material else []
# 先清理贴图与effect标签避免后续再次设置贴图时被旧状态污染
try:
self._clear_all_textures(node)
except Exception:
pass
try:
if node.hasTag("material_render_effect_signature"):
node.clearTag("material_render_effect_signature")
except Exception:
pass
for material in materials:
# 重置为默认材质属性

View File

@ -25,6 +25,7 @@ class ScriptPanels:
with self.style_manager.begin_styled_window("控制台", self.app.showConsole, flags) as (_, opened):
if not opened:
self.app.showConsole = False
self.app._console_window_rect = None
return
self.app.showConsole = opened
@ -119,6 +120,7 @@ class ScriptPanels:
with self.style_manager.begin_styled_window("脚本管理", self.app.showScriptPanel, flags) as (_, opened):
if not opened:
self.app.showScriptPanel = False
self.app._script_panel_window_rect = None
return
self.app.showScriptPanel = opened